diff --git a/haos-addon/Dockerfile b/haos-addon/Dockerfile index 2286802..151bcc7 100644 --- a/haos-addon/Dockerfile +++ b/haos-addon/Dockerfile @@ -4,6 +4,7 @@ FROM ${BUILD_FROM} WORKDIR /app COPY src/ /app/ +COPY firmware/ /firmware/ RUN pip3 install --no-cache-dir \ pymodbus==3.6.9 \ diff --git a/haos-addon/firmware/nuttx-mbusd-shinelanx-dfu.bin b/haos-addon/firmware/nuttx-mbusd-shinelanx-dfu.bin new file mode 100755 index 0000000..1308bdf Binary files /dev/null and b/haos-addon/firmware/nuttx-mbusd-shinelanx-dfu.bin differ diff --git a/haos-addon/firmware/nuttx-mbusd-shinelanx.bin b/haos-addon/firmware/nuttx-mbusd-shinelanx.bin new file mode 100755 index 0000000..8e69b46 Binary files /dev/null and b/haos-addon/firmware/nuttx-mbusd-shinelanx.bin differ diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index 3dbebb5..f597df8 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -1,6 +1,7 @@ import json import logging import os +import re import threading import time import uuid @@ -30,6 +31,21 @@ WEB_DIR = os.path.join(os.path.dirname(__file__), "web") app = Flask(__name__, static_folder=WEB_DIR) + +@app.before_request +def _check_ingress(): + # Static files and the root page are always served (no API data inside) + if request.path == "/" or not request.path.startswith("/api/"): + return None + # Requests routed through HAOS ingress proxy carry this header + if request.headers.get("X-Ingress-Path"): + return None + # Allow loopback for local debugging / health checks + if request.remote_addr in ("127.0.0.1", "::1"): + return None + return jsonify({"error": "Zugriff nur über die HAOS-Oberfläche erlaubt"}), 403 + + # ── Aggregation ─────────────────────────────────────────────── # Welche Sensor-IDs fließen in welchen Aggregat-Bucket (Summe, außer AGG_AVG) @@ -452,6 +468,7 @@ def api_get_config(): cfg = {**State.mqtt_cfg, "inverters": State.inverters_cfg} cfg.pop("mqtt_pass", None) cfg["mqtt_connected"] = _publisher.connected if _publisher else False + cfg["mqtt_error"] = _publisher.last_error if _publisher else None return jsonify(cfg) @app.post("/api/config") @@ -856,6 +873,68 @@ def api_spot_price(): log.warning("Spot-Price API Fehler: %s", e) return jsonify({"ok": False, "data": _spot_cache.get("data", [])}) +_IP_RE = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$') + +@app.get("/api/flash/probe") +def api_flash_probe(): + import urllib.request as _ur + ip = request.args.get("ip", "").strip() + if not ip or not _IP_RE.match(ip): + return jsonify({"ota": False, "error": "invalid ip"}), 400 + try: + with _ur.urlopen(f"http://{ip}/", timeout=5) as r: + data = json.loads(r.read().decode()) + return jsonify({"ota": bool(data.get("ota")), "info": data}) + except Exception: + return jsonify({"ota": False}) + +@app.get("/api/flash/firmware") +def api_flash_firmware(): + fw_dir = "/firmware" + try: + files = sorted(f for f in os.listdir(fw_dir) if f.endswith(".bin")) + return jsonify({"files": files}) + except Exception: + return jsonify({"files": []}) + +@app.post("/api/flash/update") +def api_flash_update(): + import urllib.request as _ur + ip = request.args.get("ip", "").strip() + fw_name = request.args.get("fw", "").strip() + if not ip or not _IP_RE.match(ip): + return jsonify({"ok": False, "error": "invalid ip"}), 400 + try: + if fw_name: + fw_path = os.path.join("/firmware", os.path.basename(fw_name)) + with open(fw_path, "rb") as f: + data = f.read() + else: + data = request.get_data() + if len(data) < 256: + return jsonify({"ok": False, "error": "firmware too small"}), 400 + req = _ur.Request( + f"http://{ip}/update", data=data, method="POST", + headers={"Content-Type": "application/octet-stream", + "Content-Length": str(len(data))}) + with _ur.urlopen(req, timeout=90) as r: + return jsonify({"ok": True, "response": r.read().decode(errors="replace")}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 502 + +@app.post("/api/flash/reboot") +def api_flash_reboot(): + import urllib.request as _ur + ip = request.args.get("ip", "").strip() + if not ip or not _IP_RE.match(ip): + return jsonify({"ok": False, "error": "invalid ip"}), 400 + try: + req = _ur.Request(f"http://{ip}/reboot", data=b"", method="POST") + with _ur.urlopen(req, timeout=10) as r: + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 502 + @app.get("/") def index(): return send_from_directory(WEB_DIR, "index.html") diff --git a/haos-addon/src/mqtt_publisher.py b/haos-addon/src/mqtt_publisher.py index 06ec735..86a66db 100644 --- a/haos-addon/src/mqtt_publisher.py +++ b/haos-addon/src/mqtt_publisher.py @@ -12,12 +12,23 @@ AGG_DEVICE_ID = "shinebridge_aggregate" AGG_TOPIC = "shinebridge/aggregate" +_RC_MESSAGES = { + 1: "Falsche Protokollversion", + 2: "Client-ID abgelehnt", + 3: "Broker nicht verfügbar", + 4: "Falscher Benutzername oder Passwort", + 5: "Nicht autorisiert — Credentials prüfen", +} + + class MqttPublisher: def __init__(self, broker: str, port: int, user: str, password: str, agg_meta: Optional[Dict] = None): self._broker = broker self._port = port self._connected = False + self._last_error: Optional[str] = None + self._auth_failed = False self._registered: List[Tuple] = [] self._agg_meta: Dict = agg_meta or {} @@ -32,6 +43,8 @@ class MqttPublisher: def _on_connect(self, client, userdata, flags, rc): if rc == 0: self._connected = True + self._last_error = None + self._auth_failed = False log.info("MQTT verbunden: %s:%d", self._broker, self._port) for entry in self._registered: self._publish_discovery(*entry) @@ -40,11 +53,17 @@ class MqttPublisher: for topic, _ in self._subscriptions: client.subscribe(topic) else: - log.error("MQTT Verbindungsfehler rc=%d", rc) + msg = _RC_MESSAGES.get(rc, f"Unbekannter Fehler") + self._last_error = f"rc={rc}: {msg}" + log.error("MQTT Verbindungsfehler %s", self._last_error) + if rc == 5: + self._auth_failed = True + client.loop_stop() def _on_disconnect(self, client, userdata, rc): self._connected = False - log.warning("MQTT getrennt rc=%d", rc) + if rc != 0: + log.warning("MQTT getrennt rc=%d", rc) def _on_message(self, client, userdata, msg): for topic, callback in self._subscriptions: @@ -60,6 +79,9 @@ class MqttPublisher: self._client.subscribe(topic) def connect(self): + if self._auth_failed: + log.warning("MQTT connect übersprungen — Authentifizierung fehlgeschlagen") + return try: self._client.connect_async(self._broker, self._port, keepalive=60) self._client.loop_start() @@ -74,6 +96,10 @@ class MqttPublisher: def connected(self) -> bool: return self._connected + @property + def last_error(self) -> Optional[str]: + return self._last_error + # ── Gerät-Discovery ────────────────────────────────────── def register_inverter(self, inverter: Inverter, device_id: str, diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html index 59f054a..43a3f0e 100644 --- a/haos-addon/src/web/index.html +++ b/haos-addon/src/web/index.html @@ -154,6 +154,39 @@ .toast.show { transform: translateY(0); opacity: 1; } .toast.ok { border-color: var(--green); color: var(--green); } .toast.err { border-color: var(--red); color: var(--red); } + + /* Setup Wizard */ + .wz-overlay { position:fixed; inset:0; background:rgba(0,0,0,.88); z-index:300; + display:flex; align-items:center; justify-content:center; padding:20px; } + .wz-box { background:var(--surface); border:1px solid var(--border); + border-radius:14px; padding:32px; width:100%; max-width:460px; } + .wz-logo { text-align:center; margin-bottom:22px; } + .wz-logo h2 { font-size:19px; margin-bottom:4px; } + .wz-logo p { color:var(--text-dim); font-size:13px; } + .wz-stepper { display:flex; margin-bottom:28px; } + .wz-step { flex:1; text-align:center; font-size:11px; font-weight:600; + text-transform:uppercase; letter-spacing:.05em; padding:7px 4px; + color:var(--text-dim); border-bottom:2px solid var(--border); + transition:color .2s, border-color .2s; } + .wz-step.active { color:var(--accent); border-color:var(--accent); } + .wz-step.done { color:var(--green); border-color:var(--green); } + .wz-panel { display:none; } + .wz-panel.active { display:block; } + .wz-actions { display:flex; justify-content:space-between; align-items:center; margin-top:22px; } + .wz-skip { font-size:12px; color:var(--text-dim); cursor:pointer; + background:none; border:none; color:var(--text-dim); padding:0; } + .wz-skip:hover { color:var(--text); } + + /* Flash-Wizard */ + .flash-section { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px; max-width:560px; } + .flash-section h3 { font-size:13px; font-weight:600; color:var(--text-dim); text-transform:uppercase; letter-spacing:.06em; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); } + .flash-code { font-family:monospace; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:6px; padding:10px 14px; margin:6px 0; word-break:break-all; } + .flash-step { display:flex; gap:10px; margin-bottom:14px; align-items:flex-start; } + .flash-step-num { width:22px; height:22px; min-width:22px; border-radius:50%; background:var(--accent); color:#000; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; } + .flash-pinout { font-family:monospace; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:6px; padding:10px 14px; line-height:1.9; white-space:pre; } + .flash-progress { width:100%; height:6px; background:var(--surface2); border-radius:3px; overflow:hidden; margin:12px 0 6px; } + .flash-progress-bar { height:100%; background:var(--accent); border-radius:3px; transition:width .4s; } + .flash-status-msg { font-size:12px; color:var(--text-dim); min-height:18px; } @@ -179,6 +212,7 @@
Live-Daten
Geräte
Einstellungen
+
Flash
@@ -205,6 +239,7 @@

MQTT Broker

+
@@ -305,6 +340,100 @@
+ + +
+ + +
+

ShineLAN-X Stick

+
+ + +
+
IP eingeben und auf Verbinden klicken.
+
+ + + + + + + +
@@ -358,6 +487,77 @@
+ + +
@@ -436,7 +636,7 @@ function showToast(msg, type) { } function switchTab(name) { - ["energy","finance","live","inverters","settings"].forEach((t, i) => { + ["energy","finance","live","inverters","settings","flash"].forEach((t, i) => { document.querySelectorAll(".tab")[i].classList.toggle("active", t === name); document.querySelectorAll(".panel")[i].classList.toggle("active", t === name); }); @@ -1067,6 +1267,13 @@ async function loadSettings() { const cfg = await fetchJSON(api("api/config")); globalConfig = cfg; loadSurplusDevices(); + const errBanner = document.getElementById("mqtt-error-banner"); + if (cfg.mqtt_error) { + errBanner.textContent = "⚠ MQTT Fehler: " + cfg.mqtt_error; + errBanner.style.display = ""; + } else { + errBanner.style.display = "none"; + } document.getElementById("cfg-mqtt-broker").value = cfg.mqtt_broker || ""; document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883; document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || ""; @@ -1296,10 +1503,212 @@ function renderSurplusStatus(surplusData) { `; } +// ── Flash-Wizard ────────────────────────────────────────────── + +function flashFwSourceChange() { + const bundled = document.getElementById("flash-fw-bundled").checked; + document.getElementById("flash-fw-bundled-ui").style.display = bundled ? "" : "none"; + document.getElementById("flash-fw-custom-ui").style.display = bundled ? "none" : ""; +} + +async function flashProbe() { + const ip = document.getElementById("flash-ip").value.trim(); + const statusEl = document.getElementById("flash-probe-status"); + const otaSection = document.getElementById("flash-ota-section"); + if (!ip) { showToast("IP-Adresse eingeben", "err"); return; } + statusEl.textContent = "Verbinde..."; + statusEl.style.color = "var(--text-dim)"; + try { + const r = await fetchJSON(api(`api/flash/probe?ip=${encodeURIComponent(ip)}`)); + if (r.ota) { + statusEl.textContent = "✓ OTA-Modus aktiv"; + statusEl.style.color = "var(--green)"; + otaSection.style.display = ""; + await flashLoadFirmwareList(); + } else { + statusEl.textContent = "✗ Nicht erreichbar — ST-Link-Erstflash erforderlich"; + statusEl.style.color = "var(--red)"; + otaSection.style.display = "none"; + } + } catch(e) { + statusEl.textContent = "Verbindungsfehler"; + statusEl.style.color = "var(--red)"; + otaSection.style.display = "none"; + } +} + +async function flashLoadFirmwareList() { + try { + const r = await fetchJSON(api("api/flash/firmware")); + const sel = document.getElementById("flash-fw-select"); + sel.innerHTML = ""; + if (r.files && r.files.length) { + r.files.forEach(f => { + const opt = document.createElement("option"); + opt.value = f; opt.textContent = f; + sel.appendChild(opt); + }); + const dfu = r.files.find(f => f.includes("dfu")); + if (dfu) sel.value = dfu; + } else { + sel.innerHTML = ''; + document.getElementById("flash-fw-custom").checked = true; + flashFwSourceChange(); + } + } catch(e) { /* ignore */ } +} + +async function flashStart() { + const ip = document.getElementById("flash-ip").value.trim(); + if (!ip) { showToast("IP-Adresse eingeben", "err"); return; } + const bundled = document.getElementById("flash-fw-bundled").checked; + const progressWrap = document.getElementById("flash-progress-wrap"); + const progressBar = document.getElementById("flash-progress-bar"); + const statusMsg = document.getElementById("flash-status-msg"); + const startBtn = document.getElementById("flash-start-btn"); + + progressWrap.style.display = ""; + progressBar.style.background = "var(--accent)"; + progressBar.style.width = "0%"; + startBtn.disabled = true; + + let url, opts; + if (bundled) { + const fw = document.getElementById("flash-fw-select").value; + if (!fw) { showToast("Keine Firmware ausgewählt", "err"); startBtn.disabled = false; return; } + url = api(`api/flash/update?ip=${encodeURIComponent(ip)}&fw=${encodeURIComponent(fw)}`); + opts = { method: "POST" }; + statusMsg.textContent = `Übertrage ${fw} …`; + } else { + const file = document.getElementById("flash-fw-file").files[0]; + if (!file) { showToast("Datei auswählen", "err"); startBtn.disabled = false; return; } + url = api(`api/flash/update?ip=${encodeURIComponent(ip)}`); + opts = { method: "POST", body: await file.arrayBuffer(), + headers: {"Content-Type": "application/octet-stream"} }; + statusMsg.textContent = `Übertrage ${file.name} …`; + } + + progressBar.style.width = "25%"; + try { + const resp = await fetch(url, opts); + progressBar.style.width = "70%"; + const result = await resp.json(); + if (result.ok) { + statusMsg.textContent = "Warte auf Neustart …"; + progressBar.style.width = "85%"; + await flashPollReady(ip); + progressBar.style.width = "100%"; + progressBar.style.background = "var(--green)"; + statusMsg.textContent = "✓ Update abgeschlossen — Stick ist wieder online."; + showToast("Firmware erfolgreich geflasht", "ok"); + } else { + progressBar.style.width = "100%"; + progressBar.style.background = "var(--red)"; + statusMsg.textContent = `Fehler: ${result.error || "Unbekannt"}`; + showToast("Flash fehlgeschlagen", "err"); + } + } catch(e) { + progressBar.style.background = "var(--red)"; + statusMsg.textContent = `Verbindungsfehler: ${e.message}`; + showToast("Flash fehlgeschlagen", "err"); + } + startBtn.disabled = false; +} + +async function flashPollReady(ip, maxSec = 90) { + const start = Date.now(); + while ((Date.now() - start) / 1000 < maxSec) { + await new Promise(r => setTimeout(r, 3000)); + try { + const r = await fetchJSON(api(`api/flash/probe?ip=${encodeURIComponent(ip)}`)); + if (r.ota) return; + } catch(e) { /* still booting */ } + } +} + +async function flashReboot() { + const ip = document.getElementById("flash-ip").value.trim(); + if (!ip) { showToast("IP-Adresse eingeben", "err"); return; } + try { + await fetch(api(`api/flash/reboot?ip=${encodeURIComponent(ip)}`), { method: "POST" }); + showToast("Neustart-Befehl gesendet", "ok"); + } catch(e) { showToast("Neustart fehlgeschlagen", "err"); } +} + +// ── Setup Wizard ────────────────────────────────────────────── + +function wizardGoStep(n) { + for (let i = 1; i <= 3; i++) { + document.getElementById(`wz-panel-${i}`).classList.toggle("active", i === n); + const el = document.getElementById(`wz-step-${i}`); + el.classList.toggle("active", i === n); + el.classList.toggle("done", i < n); + } +} + +async function wizardStep1Next() { + const body = { + mqtt_broker: document.getElementById("wz-mqtt-broker").value.trim() || "core-mosquitto", + mqtt_port: parseInt(document.getElementById("wz-mqtt-port").value) || 1883, + mqtt_user: document.getElementById("wz-mqtt-user").value, + mqtt_pass: document.getElementById("wz-mqtt-pass").value, + }; + try { + await fetchJSON(api("api/config"), { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body), + }); + } catch(e) { showToast("Speichern fehlgeschlagen", "err"); return; } + wizardGoStep(2); +} + +async function wizardStep2Next() { + const name = document.getElementById("wz-inv-name").value.trim(); + const ip = document.getElementById("wz-inv-ip").value.trim(); + if (!name || !ip) { showToast("Name und IP sind Pflichtfelder", "err"); return; } + const model = document.getElementById("wz-inv-model").value; + const id = Math.random().toString(36).slice(2, 10); + const body = [{ + id, + name, + inverter_model: model, + modbus_ip: ip, + modbus_port: parseInt(document.getElementById("wz-inv-port").value) || 502, + modbus_address: parseInt(document.getElementById("wz-inv-addr").value) || 1, + mqtt_topic_prefix: `growatt/${name.toLowerCase().replace(/\s+/g, "_")}`, + update_interval: 30, + }]; + try { + await fetchJSON(api("api/inverters-config"), { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body), + }); + } catch(e) { showToast("Speichern fehlgeschlagen", "err"); return; } + wizardGoStep(3); +} + +function wizardFinish() { + document.getElementById("wizard-overlay").style.display = "none"; + loadSettings(); + loadInverters(); +} + +function wizardSkip() { + document.getElementById("wizard-overlay").style.display = "none"; +} + // ── Init ────────────────────────────────────────────────────── (async () => { await Promise.all([loadSettings(), loadInverters(), loadModels()]); + if (!globalConfig.inverters || globalConfig.inverters.length === 0) { + document.getElementById("wz-mqtt-broker").value = globalConfig.mqtt_broker || "core-mosquitto"; + document.getElementById("wz-mqtt-port").value = globalConfig.mqtt_port || 1883; + document.getElementById("wz-mqtt-user").value = globalConfig.mqtt_user || ""; + document.getElementById("wz-inv-model").innerHTML = + document.getElementById("modal-model").innerHTML; + document.getElementById("wizard-overlay").style.display = "flex"; + } startRefresh(); })();