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
+
Broker
Port
@@ -305,6 +340,100 @@
+
+
+
+
+
+
+
ShineLAN-X Stick
+
+
+ Verbinden
+
+
IP eingeben und auf Verbinden klicken.
+
+
+
+
+
OTA-Update
+
+
+
+ Firmware flashen
+ Neu starten
+
+
+
+
+
+
Erstflash via ST-Link
+
Wenn der Stick noch nie geflasht wurde, ist OTA noch nicht verfügbar. Einen ST-Link v2 und stlink-tools werden benötigt.
+
+
+
1
+
+
SWD-Verbindung herstellen
+
PA13 → SWDIO
+PA14 → SWCLK
+GND → GND
+3.3V → 3.3V (falls nicht extern versorgt)
+
+
+
+
+
2
+
+
Flash-Kommando ausführen
+
st-flash --reset write nuttx-mbusd-shinelanx.bin 0x08000000
+
Datei im ShineLAN-X Releases-Ordner des Repos oder als GitHub-Release.
+
+
+
+
+
3
+
+
Auf DHCP-Adresse warten
+
Nach dem Neustart erscheint der Stick im Netz. IP oben eintragen → Verbinden — danach sind OTA-Updates möglich.
+
+
+
+
+ Nach dem Erstflash empfiehlt sich ein OTA-Update auf die DFU-Variante
+ (nuttx-mbusd-shinelanx-dfu.bin),
+ die zukünftige OTA-Updates ohne ST-Link ermöglicht.
+
+
+
+
@@ -358,6 +487,77 @@
+
+
+
+
+
+
+
+
+
Willkommen bei ShineBridge
+
Schnelle Einrichtung — 3 Schritte
+
+
+
1 · MQTT
+
2 · Wechselrichter
+
3 · Fertig
+
+
+
+
+
+
+
+
Name
+
+
Gerätetyp
+
+
IP-Adresse (ShineLAN-X)
+
+
+
+ ← Zurück
+ Weiter →
+
+
+
+
+
+
+
✓
+
Einrichtung abgeschlossen!
+
+ ShineBridge verbindet sich jetzt mit MQTT und deinem Wechselrichter.
+ Die ersten Messwerte erscheinen nach wenigen Sekunden.
+
+
+
+ Los geht's →
+
+
+
+
+
@@ -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 = 'Keine integrierten Firmwares ';
+ 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();
})();