commit ef7dcecea2c883ad9d038c5f9be6cd2d6f6a2e1c Author: Gemini Bot Date: Tue Jan 20 10:46:00 2026 +0000 Initial commit: Gridseed WebGUI implementation diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml new file mode 100644 index 0000000..8bc0e89 --- /dev/null +++ b/.gitea/workflows/build-push.yaml @@ -0,0 +1,47 @@ +name: Docker Build & Push +run-name: ${{ gitea.actor }} flucht, aber baut 🔨 +on: + push: + branches: + - main + - master + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + # 1. DER FIX (Mit dem automatischen Gitea-Token) + # ${{ gitea.token }} hat IMMER Zugriff auf das Repo, in dem der Job läuft. + - name: Fix Git URL Resolution + run: git config --global url."http://${{ gitea.token }}@172.30.1.213/".insteadOf "http://gitea:3000/" + + # 2. Checkout + - name: Checkout Code + uses: actions/checkout@v3 + + # 3. Docker Login (Hier nehmen wir weiterhin deinen Runner-Token oder den Actor) + # Wenn der Login fehlschlägt, dann hat dein User keine Schreibrechte auf Packages. + # Aber probier erst mal Checkout. + - name: Login bei Registry + run: docker login 172.30.1.213 -u ${{ gitea.actor }} -p ${{ secrets.TOKEN_RUNNER }} + + # 4. Bauen + - name: Build & Push + run: | + REPO_LOWER=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]') + IMAGE_TAG="172.30.1.213/$REPO_LOWER:latest" + echo "Baue Image: $IMAGE_TAG" + docker build -t $IMAGE_TAG . + docker push $IMAGE_TAG + + # 5. Node-RED + - name: Webhook an Node-RED + if: always() + run: | + if [ "${{ job.status }}" == "success" ]; then + STATUS="success" + else + STATUS="failed" + fi + JSON_DATA=$(printf '{"status": "%s", "repo": "%s", "actor": "%s"}' "$STATUS" "${{ gitea.repository }}" "${{ gitea.actor }}") + curl -v -H "Content-Type: application/json" -X POST -d "$JSON_DATA" http://172.30.80.246:1880/gitea-status diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ff415d --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Gridseed WebGUI + +Ein modernes Web-Interface für den Betrieb von Gridseed ASIC Minern am Raspberry Pi 1 (oder neuer). Inspiriert von Klassikern wie MinePeon und Scripta, aber mit moderner Technologie und Optik. + +## Features + +- **Dashboard:** Echtzeit-Überwachung von Hashrate, Hardware-Fehlern, Shares und Temperaturen. +- **Visualisierung:** Live-Graph der Hashrate und übersichtliche Status-Karten. +- **Steuerung:** Neustart des Miners und Anpassung der Konfiguration über das Web-Interface. +- **Modern UI:** Responsive Dark-Mode Design basierend auf Bootstrap 5. +- **Leichtgewichtig:** Optimiert für Raspberry Pi 1 (Python Flask Backend, kein Docker notwendig). + +## Voraussetzungen + +- Raspberry Pi (ab Version 1) +- Python 3.7+ +- Installierter `cgminer` mit Gridseed-Support (muss separat installiert werden, z.B. dmaxl's fork). + +## Installation + +1. Repository klonen: + ```bash + git clone /opt/gridseed-gui + cd /opt/gridseed-gui + ``` + +2. Abhängigkeiten installieren & Starten: + Das `start_gui.sh` Skript kümmert sich um das Python Virtual Environment. + ```bash + ./start_gui.sh + ``` + +3. Zugriff: + Öffnen Sie im Browser `http://:5000` + +## Konfiguration + +### cgminer Setup +Damit die GUI mit dem Miner kommunizieren kann, muss der `cgminer` mit API-Support gestartet werden. +Die GUI erwartet, dass sie die Konfigurationsdatei `cgminer.conf` lesen und schreiben kann. + +Starten Sie Ihren Miner idealerweise so: +```bash +cgminer -c /opt/gridseed-gui/cgminer.conf +``` + +Stellen Sie sicher, dass in der `cgminer.conf` folgende API-Einstellungen gesetzt sind (passiert automatisch bei Nutzung der Standard-Config): +```json +"api-listen": true, +"api-allow": "W:127.0.0.1" +``` + +### Autostart +Um die GUI beim Booten zu starten, fügen Sie einen Eintrag in die `/etc/rc.local` oder erstellen Sie einen systemd Service. + +**Systemd Beispiel (`/etc/systemd/system/gridseed-gui.service`):** +```ini +[Unit] +Description=Gridseed Web GUI +After=network.target + +[Service] +User=pi +WorkingDirectory=/opt/gridseed-gui +ExecStart=/opt/gridseed-gui/start_gui.sh +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +## Screenshots + +Das Interface bietet eine übersichtliche Seitenleiste und ein Dashboard im Dark-Mode. + +## Lizenz +MIT diff --git a/app.py b/app.py new file mode 100644 index 0000000..4074325 --- /dev/null +++ b/app.py @@ -0,0 +1,76 @@ +from flask import Flask, render_template, jsonify, request, redirect, url_for, flash +from cgminer_api import CgminerAPI +from config_manager import ConfigManager +import os +import subprocess + +app = Flask(__name__) +app.secret_key = 'gridseed_miner_control_secret' + +miner_api = CgminerAPI() +config_mgr = ConfigManager() + +@app.route('/') +def index(): + return render_template('dashboard.html', active_page='dashboard') + +@app.route('/settings') +def settings(): + config = config_mgr.load_config() + return render_template('settings.html', config=config, active_page='settings') + +@app.route('/api/data') +def api_data(): + summary = miner_api.summary() + devs = miner_api.devs() + pools = miner_api.pools() + + # Process data for easier frontend consumption + data = { + 'summary': summary['SUMMARY'][0] if summary and 'SUMMARY' in summary else {}, + 'devs': devs['DEVS'] if devs and 'DEVS' in devs else [], + 'pools': pools['POOLS'] if pools and 'POOLS' in pools else [] + } + return jsonify(data) + +@app.route('/save_settings', methods=['POST']) +def save_settings(): + # Construct config object from form + # Note: cgminer config structure handling + + new_config = config_mgr.load_config() # Start with existing + + # Update simple fields + new_config['gridseed-options'] = f"freq={request.form.get('freq', '850')}" + + # Update pool (handling single pool for simplicity in this MVP, extended later) + pool_url = request.form.get('pool_url') + pool_user = request.form.get('pool_user') + pool_pass = request.form.get('pool_pass') + + if pool_url: + # Replace first pool or add if empty + pool_data = {"url": pool_url, "user": pool_user, "pass": pool_pass} + if new_config.get('pools'): + new_config['pools'][0] = pool_data + else: + new_config['pools'] = [pool_data] + + if config_mgr.save_config(new_config): + flash('Einstellungen gespeichert. Bitte Miner neu starten.', 'success') + else: + flash('Fehler beim Speichern.', 'danger') + + return redirect(url_for('settings')) + +@app.route('/control/restart') +def restart_miner(): + # Attempt to restart via API first, if fails, might need system command + result = miner_api.restart() + if result: + return jsonify({'status': 'success', 'message': 'Restart command sent'}) + return jsonify({'status': 'error', 'message': 'Could not contact miner'}) + +if __name__ == '__main__': + # Run on all interfaces + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/cgminer.conf b/cgminer.conf new file mode 100644 index 0000000..c0eab4a --- /dev/null +++ b/cgminer.conf @@ -0,0 +1,13 @@ +{ + "pools": [ + { + "url": "stratum+tcp://pool.ckpool.org:3333", + "user": "144N35t62x8qC21eQ8qW2q2q2q2q2q2q2q", + "pass": "x" + } + ], + "api-listen": true, + "api-allow": "W:127.0.0.1", + "gridseed-options": "freq=850,chips=5", + "freq": "850" +} diff --git a/cgminer_api.py b/cgminer_api.py new file mode 100644 index 0000000..7edc986 --- /dev/null +++ b/cgminer_api.py @@ -0,0 +1,70 @@ +import socket +import json + +class CgminerAPI: + def __init__(self, host='127.0.0.1', port=4028): + self.host = host + self.port = port + + def _send_command(self, command, parameter=None): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(2) + s.connect((self.host, self.port)) + + payload = {"command": command} + if parameter: + payload["parameter"] = parameter + + s.send(json.dumps(payload).encode('utf-8')) + + response = "" + while True: + data = s.recv(4096) + if not data: + break + response += data.decode('utf-8').replace('\x00', '') + + s.close() + + if response: + return json.loads(response) + return None + except Exception as e: + print(f"Error communicating with cgminer: {e}") + return None + + def summary(self): + """Get summary stats (hashrate, hw errors, etc)""" + return self._send_command("summary") + + def pools(self): + """Get pool stats""" + return self._send_command("pools") + + def devs(self): + """Get device stats (temperatures, individual chips)""" + return self._send_command("devs") + + def stats(self): + """Get general stats""" + return self._send_command("stats") + + def add_pool(self, url, user, password): + """Add a new pool""" + # Command format for addpool might vary, usually it's command=addpool, parameter=url,user,pass + # But cgminer api often expects just parameter string comma separated + param = f"{url},{user},{password}" + return self._send_command("addpool", param) + + def remove_pool(self, pool_id): + return self._send_command("removepool", str(pool_id)) + + def switch_pool(self, pool_id): + return self._send_command("switchpool", str(pool_id)) + + def restart(self): + return self._send_command("restart") + + def quit(self): + return self._send_command("quit") diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..237c65f --- /dev/null +++ b/config_manager.py @@ -0,0 +1,43 @@ +import json +import os + +class ConfigManager: + def __init__(self, config_path='cgminer.conf'): + self.config_path = config_path + + def load_config(self): + if not os.path.exists(self.config_path): + return self.get_default_config() + try: + with open(self.config_path, 'r') as f: + return json.load(f) + except: + return self.get_default_config() + + def save_config(self, config_data): + try: + # Validate numeric fields + if 'freq' in config_data: + config_data['freq'] = int(config_data['freq']) + + with open(self.config_path, 'w') as f: + json.dump(config_data, f, indent=4) + return True + except Exception as e: + print(f"Error saving config: {e}") + return False + + def get_default_config(self): + return { + "pools": [ + { + "url": "stratum+tcp://p2pool.org:9332", + "user": "user", + "pass": "password" + } + ], + "api-listen": True, + "api-allow": "W:127.0.0.1", + "gridseed-options": "freq=850,chips=5", + "freq": "850" + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e635204 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask +requests diff --git a/start_gui.sh b/start_gui.sh new file mode 100755 index 0000000..e9f6b8e --- /dev/null +++ b/start_gui.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Ensure we are in the script directory +cd "$(dirname "$0")" + +# Check if virtual environment exists +if [ ! -d "venv" ]; then + echo "Creating virtual environment..." + python3 -m venv venv + source venv/bin/activate + pip install -r requirements.txt +else + source venv/bin/activate +fi + +echo "Starting Gridseed WebGUI..." +# Run with gunicorn for production or python directly for dev. +# Using python for simplicity as requested on Pi 1 +python app.py diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..0f09bd4 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,55 @@ +:root { + --bs-body-bg: #121212; + --bs-body-color: #e0e0e0; +} + +.bg-black { + background-color: #000 !important; +} + +.card { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.nav-pills .nav-link.active, .nav-pills .show>.nav-link { + background-color: #0d6efd; + color: white !important; +} + +.nav-link { + color: #a0a0a0; + transition: all 0.2s; +} + +.nav-link:hover { + color: #fff; + background-color: rgba(255, 255, 255, 0.1); +} + +/* Custom scrollbar for dark theme */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: #1e1e1e; +} +::-webkit-scrollbar-thumb { + background: #555; + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: #777; +} + +.table-dark { + --bs-table-bg: #212529; + --bs-table-striped-bg: #2c3034; + --bs-table-striped-color: #fff; + --bs-table-active-bg: #373b3e; + --bs-table-active-color: #fff; + --bs-table-hover-bg: #323539; + --bs-table-hover-color: #fff; + color: #fff; + border-color: #373b3e; +} diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..e416f9e --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,141 @@ +document.addEventListener('DOMContentLoaded', function() { + // Initialize Chart + const ctx = document.getElementById('hashrateChart').getContext('2d'); + const hashrateChart = new Chart(ctx, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Hashrate (MH/s)', + data: [], + borderColor: '#0d6efd', + backgroundColor: 'rgba(13, 110, 253, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + } + }, + scales: { + x: { + grid: { color: '#333' }, + ticks: { color: '#aaa' } + }, + y: { + grid: { color: '#333' }, + ticks: { color: '#aaa' }, + beginAtZero: true + } + } + } + }); + + // Helper to format large numbers + function formatNumber(num) { + return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); + } + + function updateDashboard() { + fetch('/api/data') + .then(response => response.json()) + .then(data => { + const summary = data.summary; + const devs = data.devs; + const pools = data.pools; + + // Update Top Stats + if (summary) { + // Cgminer returns MHS in different fields depending on version, usually "MHS 5s" or similar + // The API returns dict keys. + // Let's inspect typical cgminer summary keys: "MHS av", "MHS 5s", "Accepted", "Hardware Errors", "Utility" + + const mhs = summary['MHS 5s'] || summary['MHS av'] || 0; + document.getElementById('stat-mhs').innerText = parseFloat(mhs).toFixed(2); + document.getElementById('stat-accepted').innerText = formatNumber(summary['Accepted'] || 0); + document.getElementById('stat-hw').innerText = formatNumber(summary['Hardware Errors'] || 0); + } + + // Update Max Temp + let maxTemp = 0; + if (devs && devs.length > 0) { + devs.forEach(dev => { + if (dev['Temperature'] > maxTemp) maxTemp = dev['Temperature']; + }); + } + document.getElementById('stat-temp').innerText = maxTemp.toFixed(1); + + // Update Chart + const now = new Date(); + const timeLabel = now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getSeconds()).padStart(2, '0'); + + if (summary) { + const currentMhs = summary['MHS 5s'] || summary['MHS av'] || 0; + + if (hashrateChart.data.labels.length > 60) { // Keep last 60 points + hashrateChart.data.labels.shift(); + hashrateChart.data.datasets[0].data.shift(); + } + hashrateChart.data.labels.push(timeLabel); + hashrateChart.data.datasets[0].data.push(currentMhs); + hashrateChart.update(); + } + + // Update Devs Table + const tbody = document.getElementById('devs-table-body'); + tbody.innerHTML = ''; + if (devs && devs.length > 0) { + devs.forEach(dev => { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${dev['ID']} + ${dev['Name'] || 'Gridseed'} + ${dev['Enabled']} + ${dev['Status']} + ${dev['Temperature']} + ${parseFloat(dev['MHS 5s'] || 0).toFixed(2)} + ${parseFloat(dev['MHS av'] || 0).toFixed(2)} + ${dev['Accepted']} + ${dev['Rejected']} + ${dev['Hardware Errors']} + `; + tbody.appendChild(tr); + }); + } else { + tbody.innerHTML = 'Keine Geräte gefunden...'; + } + + // Update Pools + const poolList = document.getElementById('pool-list'); + poolList.innerHTML = ''; + if (pools && pools.length > 0) { + pools.forEach(pool => { + const div = document.createElement('div'); + div.className = 'list-group-item bg-dark text-white border-secondary d-flex justify-content-between align-items-center'; + div.innerHTML = ` +
+
Pool ${pool['POOL']}
+ ${pool['URL']} +
+ ${pool['Status']} + `; + poolList.appendChild(div); + }); + } else { + poolList.innerHTML = '
Keine Pools konfiguriert...
'; + } + + }) + .catch(err => console.error('Error fetching data:', err)); + } + + // Update every 3 seconds + setInterval(updateDashboard, 3000); + updateDashboard(); // Initial call +}); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..87db0d3 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,58 @@ + + + + + + Gridseed Control + + + + + + +
+
+
+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+
+ + + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..e2f0fda --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Übersicht

+
+
+ + +
+
+
+
Hashrate (5s)
+
+

0.00

+

MH/s

+
+
+
+
+
+
Accepted Shares
+
+

0

+

Shares

+
+
+
+
+
+
HW Errors
+
+

0

+

Errors

+
+
+
+
+
+
Max Temp
+
+

0

+

°C

+
+
+
+
+ + +
+
+
+
Hashrate History (Last Hour)
+
+ +
+
+
+
+
+
Pool Status
+
+
+ +
Lade Pools...
+
+
+
+
+
+ + +
+
+
+
Gerätestatus
+
+
+ + + + + + + + + + + + + + + + + + + + +
IDNameEnabledStatusTemp (°C)MHS 5sMHS avgAcceptedRejectedHW Errors
Keine Geräte gefunden oder Verbindung getrennt...
+
+
+
+
+
+
+{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..4b207e1 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Einstellungen

+ +
+
+ +
+
+
+
Konfiguration
+
+
+
Pool Einstellungen
+ + + {% set pool = config['pools'][0] if config['pools'] and config['pools']|length > 0 else {} %} + +
+ + +
Beispiel: stratum+tcp://pool.example.com:3333
+
+ +
+
+ + +
+
+ + +
+
+ +
+
Hardware (Gridseed)
+ +
+ + +
Höhere Frequenzen benötigen möglicherweise mehr Spannung oder bessere Kühlung.
+
+ +
+ + Abbrechen +
+
+
+
+
+ +
+
+
Info
+
+

Nach dem Speichern der Einstellungen muss der Miner neu gestartet werden, damit die Änderungen wirksam werden.

+

Die cgminer.conf Datei wird automatisch aktualisiert.

+
+
+
+
+
+ + +{% endblock %}