diff --git a/ROADMAP.md b/ROADMAP.md index ea9ea09..12106c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,11 +9,30 @@ ## Phase 2 (ShineBridge Add-on) - [ ] Flash-Wizard — NuttX-Firmware via USB DFU direkt aus dem HA Web UI flashen (kein ST-Link) -- [ ] Persistente History — Sensorwerte über Add-on-Neustart hinaus speichern +- [x] Persistente History — SQLite in /data/history.db, 7 Tage Retention (v1.3.0) - [ ] Weitere Growatt-Modelle — MOD 10000, SPH 10000 etc. --- +## ShineDiag — Portabler Vor-Ort-Diagnose-Gateway (Pi 3B) + +Hardware: Raspberry Pi 3B (eth0 → ShineLAN-X, wlan0 → WiFi-Hotspot „ShineDiag") + +### Pi-Netzwerk-Setup +- [ ] eth0: statische IP 10.0.0.1/24, dnsmasq DHCP → ShineLAN-X bekommt 10.0.0.100 +- [ ] wlan0: hostapd Hotspot „ShineDiag", WPA2 +- [ ] Autostart ShineDiag beim Boot (systemd) + +### ShineDiag Web App (`tools/shinediag/`) +- [ ] Flask-App: verbindet sich mit ShineLAN-X, liest alle Register +- [ ] Mobile-freundliche UI: Modell wählen, alle Sensorwerte anzeigen +- [ ] Rohdaten-Ansicht: Register-Adresse, Hex, Dezimal +- [ ] Fault-Codes decodiert (Growatt Status-Register) +- [ ] Export als JSON für Kundendokumentation +- [ ] Pi-Setup-Script (install.sh) + +--- + ## Phase 3 — Multi-Brand / Multi-Hardware ### Goodwe WiFi Stick (ESP32 + LAN) @@ -32,6 +51,23 @@ --- +## Phase 4 — Messkonzept + +Ziel: korrekte, widerspruchsfreie Energiebilanz im HA Energie-Dashboard. + +### Messkonzept definieren +- [ ] Messstellenplan: welcher Sensor misst was (SDM-630 = Netz, SPH = Batterie+AC, MIC = PV) +- [ ] Hausverbrauch berechnen: `Hausverbrauch = PV_gesamt + Netzbezug - Einspeisung - Bat_Ladung + Bat_Entladung` +- [ ] Vorzeichen-Konvention einheitlich festlegen (SDM-630 negativ = Einspeisung ✓) +- [ ] Sensorüberschneidungen klären: SPH meldet AC-Leistung UND Netzleistung — was ist was? + +### HA Energie-Dashboard +- [ ] Alle Dashboard-Slots mit den richtigen Aggregat-Sensoren belegen +- [ ] Virtuelle Sensoren für berechnete Größen (Hausverbrauch) als MQTT-Sensor anlegen +- [ ] Prüfen ob `total_increasing` auf allen Energie-Sensoren korrekt gesetzt ist + +--- + ## Erledigt - [x] v1.0.0 — Grundfunktion: ShineLAN-X + Growatt MIC via Modbus TCP + MQTT diff --git a/tools/shinediag/diagnose.py b/tools/shinediag/diagnose.py new file mode 100644 index 0000000..fb20eec --- /dev/null +++ b/tools/shinediag/diagnose.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""ShineDiag — Vor-Ort-Diagnose für Growatt-Wechselrichter via ShineLAN-X.""" + +import json +import logging +import os +import struct +import sys + +from flask import Flask, jsonify, request, send_from_directory + +# Shared code aus dem Add-on +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../haos-addon/src")) +from inverters import INVERTERS +from modbus_client import ModbusReader + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") +log = logging.getLogger(__name__) + +WEB_DIR = os.path.join(os.path.dirname(__file__), "web") +SHINELANX_IP = os.environ.get("SHINELANX_IP", "10.0.0.100") + +app = Flask(__name__, static_folder=WEB_DIR) + + +@app.get("/") +def index(): + return send_from_directory(WEB_DIR, "index.html") + + +@app.get("/api/models") +def api_models(): + return jsonify({ + k: {"id": v.id, "name": v.name, "manufacturer": v.manufacturer, + "sensor_count": len(v.sensors)} + for k, v in INVERTERS.items() + }) + + +@app.post("/api/scan") +def api_scan(): + """Liest alle definierten Sensoren eines Geräts aus.""" + body = request.get_json(force=True) or {} + model_id = body.get("model", "MIC_1500_TL_X") + ip = body.get("ip", SHINELANX_IP) + port = int(body.get("port", 502)) + slave = int(body.get("slave", 1)) + + inverter = INVERTERS.get(model_id) + if not inverter: + return jsonify({"error": f"Unbekanntes Modell: {model_id}"}), 400 + + reader = ModbusReader(host=ip, port=port, slave=slave, timeout=5.0) + values = reader.read(inverter) + reader.close() + + if values is None: + return jsonify({"ok": False, "error": "Keine Verbindung zum ShineLAN-X"}), 502 + + sensors_out = [] + for s in inverter.sensors: + sensors_out.append({ + "id": s.id, + "name": s.name, + "value": values.get(s.id), + "unit": s.unit, + "device_class": s.device_class, + "icon": s.icon, + }) + + return jsonify({"ok": True, "model": inverter.name, + "manufacturer": inverter.manufacturer, "sensors": sensors_out}) + + +@app.post("/api/raw") +def api_raw(): + """Liest einen rohen Register-Bereich aus — für Diagnose unbekannter Werte.""" + body = request.get_json(force=True) or {} + ip = body.get("ip", SHINELANX_IP) + port = int(body.get("port", 502)) + slave = int(body.get("slave", 1)) + start = max(0, int(body.get("start", 0))) + count = min(125, max(1, int(body.get("count", 100)))) + + from pymodbus.client import ModbusTcpClient + client = ModbusTcpClient(ip, port=port, timeout=5.0) + if not client.connect(): + return jsonify({"ok": False, "error": "Verbindung fehlgeschlagen"}), 502 + + try: + result = client.read_input_registers(start, count, slave=slave) + if result.isError(): + return jsonify({"ok": False, "error": str(result)}), 502 + regs = [] + for i, val in enumerate(result.registers): + regs.append({ + "addr": start + i, + "hex": f"0x{val:04X}", + "dec": val, + "signed": val if val < 0x8000 else val - 0x10000, + }) + return jsonify({"ok": True, "registers": regs}) + finally: + client.close() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "80")) + log.info("ShineDiag startet auf Port %d — ShineLAN-X: %s", port, SHINELANX_IP) + app.run(host="0.0.0.0", port=port, threaded=True) diff --git a/tools/shinediag/setup/dhcpcd.conf b/tools/shinediag/setup/dhcpcd.conf new file mode 100644 index 0000000..9808af2 --- /dev/null +++ b/tools/shinediag/setup/dhcpcd.conf @@ -0,0 +1,8 @@ +# /etc/dhcpcd.conf — eth0 feste IP für ShineDiag +# Diese Datei ans Ende von /etc/dhcpcd.conf anhängen + +interface eth0 +static ip_address=10.0.0.1/24 +static routers= +static domain_name_servers= +noipv6 diff --git a/tools/shinediag/setup/dnsmasq.conf b/tools/shinediag/setup/dnsmasq.conf new file mode 100644 index 0000000..60f7b1f --- /dev/null +++ b/tools/shinediag/setup/dnsmasq.conf @@ -0,0 +1,9 @@ +# /etc/dnsmasq.conf — DHCP für ShineLAN-X auf eth0 + +interface=eth0 +dhcp-range=10.0.0.100,10.0.0.200,12h + +# ShineLAN-X bekommt immer 10.0.0.100 (MAC anpassen!) +# dhcp-host=AA:BB:CC:DD:EE:FF,shinelanx,10.0.0.100 + +bind-interfaces diff --git a/tools/shinediag/setup/hostapd.conf b/tools/shinediag/setup/hostapd.conf new file mode 100644 index 0000000..fd09371 --- /dev/null +++ b/tools/shinediag/setup/hostapd.conf @@ -0,0 +1,16 @@ +# /etc/hostapd/hostapd.conf — WiFi-Hotspot "ShineDiag" + +interface=wlan0 +driver=nl80211 +ssid=ShineDiag +hw_mode=g +channel=6 +ieee80211n=1 +wmm_enabled=1 + +auth_algs=1 +wpa=2 +wpa_passphrase=shinelanx +wpa_key_mgmt=WPA-PSK +wpa_pairwise=CCMP +rsn_pairwise=CCMP diff --git a/tools/shinediag/setup/install.sh b/tools/shinediag/setup/install.sh new file mode 100644 index 0000000..9b25dea --- /dev/null +++ b/tools/shinediag/setup/install.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# ShineDiag Pi 3B Setup — einmalig als root ausführen +set -e + +echo "=== ShineDiag Installation ===" + +# Pakete +apt-get update -q +apt-get install -y python3-pip hostapd dnsmasq + +# Python-Abhängigkeiten +pip3 install flask pymodbus --break-system-packages + +# Netzwerk: eth0 statische IP +cat setup/dhcpcd.conf >> /etc/dhcpcd.conf + +# DHCP-Server für eth0 +cp setup/dnsmasq.conf /etc/dnsmasq.conf +systemctl enable dnsmasq + +# WiFi-Hotspot +cp setup/hostapd.conf /etc/hostapd/hostapd.conf +echo 'DAEMON_CONF="/etc/hostapd/hostapd.conf"' >> /etc/default/hostapd +systemctl unmask hostapd +systemctl enable hostapd + +# Wlan0 IP +cat >> /etc/dhcpcd.conf << 'EOF' + +interface wlan0 +static ip_address=10.0.1.1/24 +nohook wpa_supplicant +EOF + +# DHCP auch für wlan0 (MacBook-Verbindung) +cat >> /etc/dnsmasq.conf << 'EOF' + +interface=wlan0 +dhcp-range=10.0.1.100,10.0.1.200,1h +EOF + +# systemd-Service für ShineDiag +INSTALL_DIR="$(pwd)" +cat > /etc/systemd/system/shinediag.service << EOF +[Unit] +Description=ShineDiag Diagnose-Tool +After=network.target + +[Service] +ExecStart=/usr/bin/python3 ${INSTALL_DIR}/diagnose.py +WorkingDirectory=${INSTALL_DIR} +Environment=PORT=80 +Environment=SHINELANX_IP=10.0.0.100 +Restart=always +User=root + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable shinediag + +echo "" +echo "=== Fertig! Bitte neu starten: sudo reboot ===" +echo "" +echo "Nach dem Neustart:" +echo " WiFi: 'ShineDiag' | Passwort: shinelanx" +echo " Browser: http://10.0.1.1" diff --git a/tools/shinediag/web/index.html b/tools/shinediag/web/index.html new file mode 100644 index 0000000..693986c --- /dev/null +++ b/tools/shinediag/web/index.html @@ -0,0 +1,280 @@ + + + + + +ShineDiag + + + + +
+ + + + + + + + +
+

ShineDiag

+

Vor-Ort Diagnose via ShineLAN-X

+
+
+ +
+
+

Verbindung

+ + + + +
+
+ + +
+
+ + +
+
+ + + + + +
+ +
+
+ + +
+ +
+ +
+ + + +
SensorWertEinheit
+ +
+ + +
+
+ + + +