v1.8.18: MQTT rc=5 Fehlerhandling, Port-Absicherung, Flash-Wizard + NuttX OTA
MQTT: - rc=5 (Not Authorized) stoppt Reconnect-Loop via _auth_failed Flag - Fehlermeldung im MQTT-Einstellungen-Banner sichtbar Sicherheit: - /api/* nur über HAOS-Ingress (X-Ingress-Path) oder Loopback erreichbar Flash-Wizard (Baustelle B): - Neuer Tab "Flash" mit IP-Eingabe und OTA-Modus-Erkennung - OTA: integrierte oder eigene Firmware via POST /api/flash/update auf Stick - Fortschrittsbalken + Polling bis Stick nach Reset wieder online - ST-Link-Erstflash-Anleitung (Pinout, st-flash Kommando) - Firmware-Binaries im Docker-Image unter /firmware/ NuttX OTA (Baustelle A, shinelanx-modbus): - ota_http.c: Zwei-Phasen OTA für STM32F103 Single-Bank Flash Stage 1: Firmware in Staging-Bereich (obere Flashhälfte) schreiben Stage 2: .ramfuncs aus SRAM heraus — Staging → App-Bereich kopieren, Reset - ota_http.h, Makefile und main.c entsprechend erweitert - ld.script.dfu: .ramfuncs in .data Section → Ausführung aus SRAM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ FROM ${BUILD_FROM}
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY src/ /app/
|
COPY src/ /app/
|
||||||
|
COPY firmware/ /firmware/
|
||||||
|
|
||||||
RUN pip3 install --no-cache-dir \
|
RUN pip3 install --no-cache-dir \
|
||||||
pymodbus==3.6.9 \
|
pymodbus==3.6.9 \
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -30,6 +31,21 @@ WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
|
|||||||
|
|
||||||
app = Flask(__name__, static_folder=WEB_DIR)
|
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 ───────────────────────────────────────────────
|
# ── Aggregation ───────────────────────────────────────────────
|
||||||
# Welche Sensor-IDs fließen in welchen Aggregat-Bucket (Summe, außer AGG_AVG)
|
# 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 = {**State.mqtt_cfg, "inverters": State.inverters_cfg}
|
||||||
cfg.pop("mqtt_pass", None)
|
cfg.pop("mqtt_pass", None)
|
||||||
cfg["mqtt_connected"] = _publisher.connected if _publisher else False
|
cfg["mqtt_connected"] = _publisher.connected if _publisher else False
|
||||||
|
cfg["mqtt_error"] = _publisher.last_error if _publisher else None
|
||||||
return jsonify(cfg)
|
return jsonify(cfg)
|
||||||
|
|
||||||
@app.post("/api/config")
|
@app.post("/api/config")
|
||||||
@@ -856,6 +873,68 @@ def api_spot_price():
|
|||||||
log.warning("Spot-Price API Fehler: %s", e)
|
log.warning("Spot-Price API Fehler: %s", e)
|
||||||
return jsonify({"ok": False, "data": _spot_cache.get("data", [])})
|
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("/")
|
@app.get("/")
|
||||||
def index():
|
def index():
|
||||||
return send_from_directory(WEB_DIR, "index.html")
|
return send_from_directory(WEB_DIR, "index.html")
|
||||||
|
|||||||
@@ -12,12 +12,23 @@ AGG_DEVICE_ID = "shinebridge_aggregate"
|
|||||||
AGG_TOPIC = "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:
|
class MqttPublisher:
|
||||||
def __init__(self, broker: str, port: int, user: str, password: str,
|
def __init__(self, broker: str, port: int, user: str, password: str,
|
||||||
agg_meta: Optional[Dict] = None):
|
agg_meta: Optional[Dict] = None):
|
||||||
self._broker = broker
|
self._broker = broker
|
||||||
self._port = port
|
self._port = port
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
self._last_error: Optional[str] = None
|
||||||
|
self._auth_failed = False
|
||||||
self._registered: List[Tuple] = []
|
self._registered: List[Tuple] = []
|
||||||
self._agg_meta: Dict = agg_meta or {}
|
self._agg_meta: Dict = agg_meta or {}
|
||||||
|
|
||||||
@@ -32,6 +43,8 @@ class MqttPublisher:
|
|||||||
def _on_connect(self, client, userdata, flags, rc):
|
def _on_connect(self, client, userdata, flags, rc):
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
self._connected = True
|
self._connected = True
|
||||||
|
self._last_error = None
|
||||||
|
self._auth_failed = False
|
||||||
log.info("MQTT verbunden: %s:%d", self._broker, self._port)
|
log.info("MQTT verbunden: %s:%d", self._broker, self._port)
|
||||||
for entry in self._registered:
|
for entry in self._registered:
|
||||||
self._publish_discovery(*entry)
|
self._publish_discovery(*entry)
|
||||||
@@ -40,11 +53,17 @@ class MqttPublisher:
|
|||||||
for topic, _ in self._subscriptions:
|
for topic, _ in self._subscriptions:
|
||||||
client.subscribe(topic)
|
client.subscribe(topic)
|
||||||
else:
|
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):
|
def _on_disconnect(self, client, userdata, rc):
|
||||||
self._connected = False
|
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):
|
def _on_message(self, client, userdata, msg):
|
||||||
for topic, callback in self._subscriptions:
|
for topic, callback in self._subscriptions:
|
||||||
@@ -60,6 +79,9 @@ class MqttPublisher:
|
|||||||
self._client.subscribe(topic)
|
self._client.subscribe(topic)
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
|
if self._auth_failed:
|
||||||
|
log.warning("MQTT connect übersprungen — Authentifizierung fehlgeschlagen")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self._client.connect_async(self._broker, self._port, keepalive=60)
|
self._client.connect_async(self._broker, self._port, keepalive=60)
|
||||||
self._client.loop_start()
|
self._client.loop_start()
|
||||||
@@ -74,6 +96,10 @@ class MqttPublisher:
|
|||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> Optional[str]:
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
# ── Gerät-Discovery ──────────────────────────────────────
|
# ── Gerät-Discovery ──────────────────────────────────────
|
||||||
|
|
||||||
def register_inverter(self, inverter: Inverter, device_id: str,
|
def register_inverter(self, inverter: Inverter, device_id: str,
|
||||||
|
|||||||
@@ -154,6 +154,39 @@
|
|||||||
.toast.show { transform: translateY(0); opacity: 1; }
|
.toast.show { transform: translateY(0); opacity: 1; }
|
||||||
.toast.ok { border-color: var(--green); color: var(--green); }
|
.toast.ok { border-color: var(--green); color: var(--green); }
|
||||||
.toast.err { border-color: var(--red); color: var(--red); }
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -179,6 +212,7 @@
|
|||||||
<div class="tab" onclick="switchTab('live')">Live-Daten</div>
|
<div class="tab" onclick="switchTab('live')">Live-Daten</div>
|
||||||
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
|
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
|
||||||
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
|
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
|
||||||
|
<div class="tab" onclick="switchTab('flash')">Flash</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Energy Panel -->
|
<!-- Energy Panel -->
|
||||||
@@ -205,6 +239,7 @@
|
|||||||
<div class="panel" id="panel-settings">
|
<div class="panel" id="panel-settings">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>MQTT Broker</h3>
|
<h3>MQTT Broker</h3>
|
||||||
|
<div id="mqtt-error-banner" style="display:none;background:var(--red,#c0392b);color:#fff;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:.9rem;line-height:1.4"></div>
|
||||||
<div class="field"><label>Broker</label>
|
<div class="field"><label>Broker</label>
|
||||||
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto"></div>
|
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto"></div>
|
||||||
<div class="field"><label>Port</label>
|
<div class="field"><label>Port</label>
|
||||||
@@ -305,6 +340,100 @@
|
|||||||
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
|
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Flash Panel -->
|
||||||
|
<div class="panel" id="panel-flash">
|
||||||
|
|
||||||
|
<!-- Probe / IP input -->
|
||||||
|
<div class="flash-section">
|
||||||
|
<h3>ShineLAN-X Stick</h3>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||||||
|
<input type="text" id="flash-ip" placeholder="192.168.1.xxx"
|
||||||
|
style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:8px 10px;outline:none"
|
||||||
|
onkeydown="if(event.key==='Enter')flashProbe()">
|
||||||
|
<button class="btn btn-secondary" onclick="flashProbe()">Verbinden</button>
|
||||||
|
</div>
|
||||||
|
<div id="flash-probe-status" style="font-size:13px;color:var(--text-dim)">IP eingeben und auf Verbinden klicken.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OTA mode -->
|
||||||
|
<div class="flash-section" id="flash-ota-section" style="display:none">
|
||||||
|
<h3>OTA-Update</h3>
|
||||||
|
<div style="margin-bottom:14px">
|
||||||
|
<div style="display:flex;gap:16px;margin-bottom:12px;flex-wrap:wrap">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
|
||||||
|
<input type="radio" name="flash-fw-src" value="bundled" id="flash-fw-bundled" onchange="flashFwSourceChange()" checked>
|
||||||
|
Integrierte Firmware
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
|
||||||
|
<input type="radio" name="flash-fw-src" value="custom" id="flash-fw-custom" onchange="flashFwSourceChange()">
|
||||||
|
Eigene Datei
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="flash-fw-bundled-ui">
|
||||||
|
<div class="field" style="margin-bottom:0">
|
||||||
|
<label>Firmware</label>
|
||||||
|
<select id="flash-fw-select" style="width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:8px 10px;outline:none"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="flash-fw-custom-ui" style="display:none">
|
||||||
|
<div class="field" style="margin-bottom:0">
|
||||||
|
<label>Firmware-Datei (.bin)</label>
|
||||||
|
<input type="file" id="flash-fw-file" accept=".bin" style="color:var(--text);font-size:13px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="flash-progress-wrap" style="display:none">
|
||||||
|
<div class="flash-progress"><div class="flash-progress-bar" id="flash-progress-bar" style="width:0%"></div></div>
|
||||||
|
<div class="flash-status-msg" id="flash-status-msg"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
|
||||||
|
<button class="btn btn-primary" id="flash-start-btn" onclick="flashStart()">Firmware flashen</button>
|
||||||
|
<button class="btn" onclick="flashReboot()">Neu starten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ST-Link first-flash guide (always visible) -->
|
||||||
|
<div class="flash-section" id="flash-stlink-section">
|
||||||
|
<h3>Erstflash via ST-Link</h3>
|
||||||
|
<p style="color:var(--text-dim);font-size:13px;margin-bottom:16px">Wenn der Stick noch nie geflasht wurde, ist OTA noch nicht verfügbar. Einen ST-Link v2 und <code style="font-size:11px;background:var(--surface2);padding:1px 5px;border-radius:3px">stlink-tools</code> werden benötigt.</p>
|
||||||
|
|
||||||
|
<div class="flash-step">
|
||||||
|
<div class="flash-step-num">1</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;margin-bottom:8px">SWD-Verbindung herstellen</div>
|
||||||
|
<div class="flash-pinout">PA13 → SWDIO
|
||||||
|
PA14 → SWCLK
|
||||||
|
GND → GND
|
||||||
|
3.3V → 3.3V (falls nicht extern versorgt)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flash-step">
|
||||||
|
<div class="flash-step-num">2</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;margin-bottom:8px">Flash-Kommando ausführen</div>
|
||||||
|
<div class="flash-code">st-flash --reset write nuttx-mbusd-shinelanx.bin 0x08000000</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-dim);margin-top:6px">Datei im ShineLAN-X Releases-Ordner des Repos oder als GitHub-Release.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flash-step">
|
||||||
|
<div class="flash-step-num">3</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;margin-bottom:6px">Auf DHCP-Adresse warten</div>
|
||||||
|
<div style="font-size:13px;color:var(--text-dim)">Nach dem Neustart erscheint der Stick im Netz. IP oben eintragen → Verbinden — danach sind OTA-Updates möglich.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:4px;padding:10px 14px;background:var(--surface2);border-radius:6px;font-size:12px;color:var(--text-dim)">
|
||||||
|
Nach dem Erstflash empfiehlt sich ein OTA-Update auf die DFU-Variante
|
||||||
|
(<code style="font-size:11px;background:var(--bg);padding:1px 5px;border-radius:3px">nuttx-mbusd-shinelanx-dfu.bin</code>),
|
||||||
|
die zukünftige OTA-Updates ohne ST-Link ermöglicht.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Inverter Edit Modal -->
|
<!-- Inverter Edit Modal -->
|
||||||
@@ -358,6 +487,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Setup Wizard -->
|
||||||
|
<div id="wizard-overlay" class="wz-overlay" style="display:none">
|
||||||
|
<div class="wz-box">
|
||||||
|
<div class="wz-logo">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" style="margin-bottom:8px">
|
||||||
|
<circle cx="12" cy="12" r="5" fill="#f0c040"/>
|
||||||
|
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"
|
||||||
|
stroke="#f0c040" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<h2>Willkommen bei ShineBridge</h2>
|
||||||
|
<p>Schnelle Einrichtung — 3 Schritte</p>
|
||||||
|
</div>
|
||||||
|
<div class="wz-stepper">
|
||||||
|
<div class="wz-step active" id="wz-step-1">1 · MQTT</div>
|
||||||
|
<div class="wz-step" id="wz-step-2">2 · Wechselrichter</div>
|
||||||
|
<div class="wz-step" id="wz-step-3">3 · Fertig</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: MQTT -->
|
||||||
|
<div class="wz-panel active" id="wz-panel-1">
|
||||||
|
<div class="field"><label>MQTT Broker</label>
|
||||||
|
<input type="text" id="wz-mqtt-broker" placeholder="core-mosquitto"></div>
|
||||||
|
<div class="field"><label>Port</label>
|
||||||
|
<input type="number" id="wz-mqtt-port" value="1883"></div>
|
||||||
|
<div class="field"><label>Benutzername <span style="color:var(--text-dim);font-weight:400">(optional)</span></label>
|
||||||
|
<input type="text" id="wz-mqtt-user" autocomplete="off"></div>
|
||||||
|
<div class="field"><label>Passwort <span style="color:var(--text-dim);font-weight:400">(optional)</span></label>
|
||||||
|
<input type="password" id="wz-mqtt-pass"></div>
|
||||||
|
<div class="wz-actions">
|
||||||
|
<button class="wz-skip" onclick="wizardSkip()">Überspringen</button>
|
||||||
|
<button class="btn btn-primary" onclick="wizardStep1Next()">Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Inverter -->
|
||||||
|
<div class="wz-panel" id="wz-panel-2">
|
||||||
|
<div class="field"><label>Name</label>
|
||||||
|
<input type="text" id="wz-inv-name" placeholder="z.B. Dach Süd"></div>
|
||||||
|
<div class="field"><label>Gerätetyp</label>
|
||||||
|
<select id="wz-inv-model"></select></div>
|
||||||
|
<div class="field"><label>IP-Adresse (ShineLAN-X)</label>
|
||||||
|
<input type="text" id="wz-inv-ip" placeholder="192.168.1.100"></div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||||
|
<div class="field"><label>Modbus Port</label>
|
||||||
|
<input type="number" id="wz-inv-port" value="502"></div>
|
||||||
|
<div class="field"><label>Slave-Adresse</label>
|
||||||
|
<input type="number" id="wz-inv-addr" value="1" min="1" max="247"></div>
|
||||||
|
</div>
|
||||||
|
<div class="wz-actions">
|
||||||
|
<button class="btn" onclick="wizardGoStep(1)">← Zurück</button>
|
||||||
|
<button class="btn btn-primary" onclick="wizardStep2Next()">Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Done -->
|
||||||
|
<div class="wz-panel" id="wz-panel-3">
|
||||||
|
<div style="text-align:center;padding:20px 0 10px">
|
||||||
|
<div style="font-size:52px;margin-bottom:14px;line-height:1">✓</div>
|
||||||
|
<p style="font-size:15px;font-weight:600;margin-bottom:8px">Einrichtung abgeschlossen!</p>
|
||||||
|
<p style="color:var(--text-dim);font-size:13px;line-height:1.6">
|
||||||
|
ShineBridge verbindet sich jetzt mit MQTT und deinem Wechselrichter.<br>
|
||||||
|
Die ersten Messwerte erscheinen nach wenigen Sekunden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="wz-actions" style="justify-content:center;margin-top:28px">
|
||||||
|
<button class="btn btn-primary" onclick="wizardFinish()">Los geht's →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<datalist id="z2m-device-list"></datalist>
|
<datalist id="z2m-device-list"></datalist>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
@@ -436,7 +636,7 @@ function showToast(msg, type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchTab(name) {
|
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(".tab")[i].classList.toggle("active", t === name);
|
||||||
document.querySelectorAll(".panel")[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"));
|
const cfg = await fetchJSON(api("api/config"));
|
||||||
globalConfig = cfg;
|
globalConfig = cfg;
|
||||||
loadSurplusDevices();
|
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-broker").value = cfg.mqtt_broker || "";
|
||||||
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
|
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
|
||||||
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
|
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
|
||||||
@@ -1296,10 +1503,212 @@ function renderSurplusStatus(surplusData) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 = '<option value="">Keine integrierten Firmwares</option>';
|
||||||
|
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 ──────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await Promise.all([loadSettings(), loadInverters(), loadModels()]);
|
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();
|
startRefresh();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user