Compare commits

...
11 Commits
Author SHA1 Message Date
retr0andClaude Sonnet 5 eb797ed8b7 ShineBridge v1.9.0 — Fokus auf Energiemanagement: Flash/OTA + Setup-Wizard ausgelagert
Repositionierung: ShineBridge macht ab jetzt eine Sache gut (Energiemanagement),
statt alles zu versuchen. Flash/OTA-Firmware-Update fürs ShineLAN-X und der
Ersteinrichtungs-Wizard sind kein Energiemanagement-Feature und wandern ins neue,
eigenständige Projekt 'growatt-diagnose-tool'.

- 4 Flash/OTA-Endpunkte aus main.py entfernt (probe/firmware/update/reboot)
- Flash-Tab, Flash-Panel und zugehöriges CSS/JS aus index.html entfernt
- Setup-Wizard-Overlay entfernt — Geräte werden über '+ Gerät hinzufügen' im
  Geräte-Tab angelegt (Button existierte bereits unabhängig vom Wizard)
- firmware/-Ordner (ShineLAN-X .bin-Dateien) entfernt, liegt jetzt im neuen Repo
- Dockerfile: COPY firmware/ entfernt
- Bugfix dabei gefunden: switchTab() referenzierte noch den entfernten
  'flash'-Tab per Index — hätte die Tab-Reihenfolge durcheinandergebracht

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 13:14:12 +02:00
retr0andClaude Sonnet 5 f5ed7c5df6 ShineBridge v1.8.31 — EMS-Fixes: konfigurierbarer Max-Ladestrom, Zwangsladen-Retry, Fault-State-Handling
- ems_max_current_ma konfigurierbar statt hardcoded 32A (Zwangsladen darf jetzt nicht mehr
  mehr fordern als die tatsächliche Wallbox liefern kann, z.B. 16A bei einer 11kW-Wallbox)
- Zwangsladen setzt den Ladestrom bei jedem Poll erneut (statt nur einmal beim Start) —
  ein einzelner fehlgeschlagener Modbus-Write hängt nicht mehr die ganze Nacht
- Unbekannte/Fehler-Ladezustände (z.B. Fahrzeug-Störung) werden nicht mehr wie 'abgesteckt'
  behandelt — Strom wird pausiert, aber der Zwangslade-Timer bleibt erhalten
- Neues Settings-Feld 'Max. Ladestrom (A)' im EMS-Dialog

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:46:59 +02:00
retr0andClaude Sonnet 4.6 b36b0194f7 ShineBridge v1.8.30 — Ingress-Fix: Flask wieder auf 0.0.0.0
127.0.0.1 brach HAOS Ingress (Proxy kommt von außerhalb localhost).
Sicherheit bleibt gewahrt: kein ports:-Eintrag in config.yaml,
Port 8099 wird nicht ans Host-Netz gemappt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:04:15 +02:00
retr0andClaude Sonnet 4.6 04a8fb3125 ShineBridge v1.8.29 — Port 8099 nur auf localhost binden
Flask bindet auf 127.0.0.1 statt 0.0.0.0. Mit host_network: true war Port
8099 direkt vom LAN erreichbar. HAOS-Ingress verbindet sich über localhost,
daher kein Funktionsverlust.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:23:01 +02:00
retr0andClaude Sonnet 4.6 d9f94d3f28 ShineBridge v1.8.28 — history.py: load_recent Fix + periodisches Cleanup
- load_recent(): Window-Funktion durch pro-Sensor-Indexabfragen ersetzt
  (SELECT ... ORDER BY ts DESC LIMIT N per sensor_id) — nutzt Index optimal,
  kein Full-Table-Scan mehr auf 1M+ Zeilen beim Start
- Periodisches Cleanup: täglich via Daemon-Thread statt nur beim Start —
  DB bleibt dauerhaft auf RETENTION_DAYS beschränkt
- RETENTION_DAYS: 7 → 14 (explizites Maximum per Konfiguration)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:31:46 +02:00
retr0andClaude Sonnet 4.6 c4047fc804 fix(v1.8.27): Fahrzeugerkennung + Wizard überschreibt Inverterliste
Bug 1 (inverters.py): Kathrein-Register 0x0061-0x0064 existieren nicht —
(0x0060, 10) schlug daher immer mit IllegalAddress fehl, charging_state
wurde nie gelesen → EMS meldete dauerhaft "kein Fahrzeug". Fix: aufgeteilt
in (0x0060, 1) + (0x0065, 6), sodass die Lücke übersprungen wird.

Bug 2 (index.html): wizardStep2Next() postete [neues_gerät] statt
[...invertersList, neues_gerät] → überschrieb die gesamte Inverterliste.
Wenn der Wizard bei einem bestehenden Setup erschien, flogen alle anderen
Geräte raus. Fix: bestehende Geräte werden beibehalten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:58:44 +02:00
retr0andClaude Sonnet 4.6 5564a50c3c fix(ems): Zwangsladen-Countdown läuft ohne angestecktes Auto
Kathrein Reg 0x0060 liefert IllegalAddress wenn kein Fahrzeug angeschlossen.
Default war 1 (STATE_CONNECTED) → EMS nahm Auto als verbunden an → Countdown.

IllegalAddress ist kein sporadischer Lesefehler, sondern das definierte Signal
der Wallbox für "kein Fahrzeug". Default auf 0 (STATE_IDLE) → EMS kehrt sofort
zu "kein Fahrzeug" zurück, _no_pv_since-Timer wird nicht gestartet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:12:35 +02:00
retr0andClaude Sonnet 4.6 dc2df891fb fix: TCP-Timeout auf 40% des Poll-Intervalls begrenzt (max. 5s)
Logs zeigen: Bei 10s Poll-Rate + 10s TCP-Timeout läuft ein fehlgeschlagener
Connect exakt so lange wie das Interval. stop.wait() wird 0 → nächster Poll
startet sofort → zweite parallele TCP-Verbindung → Wallbox überfordert → Spirale.

Fix: timeout = min(5.0, interval * 0.4). Bei 15s → 5.0s Timeout; bei 10s → 4.0s.
Ein Fehler belegt max. 40% des Intervalls, der Rest ist Wartezeit vor dem nächsten Versuch.
Gilt für WallboxReader (Kathrein) und ModbusReader (ShineLAN-X / SDM-630).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:08:59 +02:00
retr0andClaude Sonnet 4.6 a361c30f1b fix: Offline-Flapping — erst nach 3 aufeinanderfolgenden Lesefehlern offline
Ein einzelner UDP-Paketverlust (Goodwe) oder TCP-Timeout (Modbus) hat sofort
MQTT-Status "offline" getriggert. Bei 10s Poll-Rate reicht ein Ausreißer.

Fix: _fail_count pro Poll-Loop, OFFLINE_THRESHOLD=3. Erst wenn 3 Reads in Folge
scheitern (≥30s bei 10s Interval) wird offline publiziert. Erfolg resettet
den Zähler auf 0 und stellt online sofort wieder her.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 09:58:48 +02:00
retr0andClaude Sonnet 4.6 a9f33c8e9e fix(goodwe): Netzbezug-Periode zeigt 0 kWh — integrierter grid_power-Zähler
e_total_imp vom Goodwe-WR ist ein Lifetime-Zähler seit Inbetriebnahme.
Beim ersten Verbinden speichert save_period_start_if_new() den aktuellen
Wert als Periodenstart → Delta = 0.

Fix: _int_import/_int_export werden je Poll-Zyklus aus grid_power integriert
(W × dt / 3.600.000 → kWh), in der measurements-DB persistiert und
beim Neustart aus der DB wiederhergestellt. AGG_SENSOR_IDS bevorzugt nun
_int_import vor e_total_imp, SDM-630 (import_kwh) bleibt erste Wahl.
Private Keys (Prefix _) werden nicht an MQTT gepublished.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 09:55:24 +02:00
retr0andClaude Sonnet 4.6 512b743b16 Fix: Stromtarif-Einstellungen nach Neustart verloren + Finanzen-Layout (v1.8.22)
State.mqtt_cfg wurde beim Start nur mit 4 MQTT-Keys initialisiert — alle
Tarif/Billing-Keys fehlten, wurden nach Neustart auf Defaults zurückgesetzt.
Fix: alle persistenten Keys aus load_config() in State.mqtt_cfg übernehmen.

Finanzen-Tab: mehr Abstände, größere Karten (22px Wert), Abschnittsüberschriften,
Trennlinie vor dem Chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:26:52 +02:00
12 changed files with 147 additions and 526 deletions
+5
View File
@@ -6,3 +6,8 @@ shinelanx-modbus/
__pycache__/ __pycache__/
*.pyc *.pyc
.env .env
# versehentlich getrackte Alt-Dateien, gehoeren nicht ins Repo
Modbus-Server-Kathrein.pdf
ShineLAN-X/releases/*.bin
growatt-diagnose-tool/
+5
View File
@@ -96,3 +96,8 @@
| v1.8.19 | 2026-05-05 | Fix: Mobile-Layout — Tabs scrollen, kein horizontaler Overflow | | v1.8.19 | 2026-05-05 | Fix: Mobile-Layout — Tabs scrollen, kein horizontaler Overflow |
| v1.8.20 | 2026-05-05 | Fix: Eigenversorgungskarte bei PV offline, Stromtarif-Einstellungen gehen nicht verloren | | v1.8.20 | 2026-05-05 | Fix: Eigenversorgungskarte bei PV offline, Stromtarif-Einstellungen gehen nicht verloren |
| v1.8.21 | 2026-05-05 | Fix: Finanzen-Tab bleibt nicht bei "Lade..." hängen (fEur/fKwh Scope-Bug) | | v1.8.21 | 2026-05-05 | Fix: Finanzen-Tab bleibt nicht bei "Lade..." hängen (fEur/fKwh Scope-Bug) |
| v1.8.22 | 2026-05-06 | Fix: Stromtarif-Einstellungen bleiben nach Neustart erhalten; Finanzen-Tab Layout |
| v1.8.23 | 2026-05-07 | Fix: Goodwe Netzbezug-Periode zeigt 0 kWh — integrierter grid_power-Zähler statt e_total_imp-Delta |
| v1.8.24 | 2026-05-07 | Fix: WR/Wallbox-Offline-Flapping — erst nach 3 aufeinanderfolgenden Lesefehlern offline schalten |
| v1.8.25 | 2026-05-07 | Fix: TCP-Timeout auf max. 40% des Poll-Intervalls begrenzt (Wallbox + Modbus) — verhindert Poll-Überlappung |
| v1.8.26 | 2026-05-07 | Fix: EMS-Countdown läuft ohne Auto — Kathrein 0x0060 IllegalAddress = kein Fahrzeug (default war fälschlich 1=Connected) |
+1
View File
@@ -0,0 +1 @@
firmware/
-1
View File
@@ -4,7 +4,6 @@ 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 \
+2 -2
View File
@@ -1,7 +1,7 @@
name: ShineBridge name: ShineBridge
version: "1.8.21" version: "1.9.0"
slug: shinebridge slug: shinebridge
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
url: https://gitea.bitfire.work/retr0/shinebridge url: https://gitea.bitfire.work/retr0/shinebridge
arch: arch:
- aarch64 - aarch64
Binary file not shown.
Binary file not shown.
+26 -6
View File
@@ -23,6 +23,11 @@ TARGET_HOUR = 6 # Zielstunde: Vollladung bis 06:00
POLL_INTERVAL_S = 30 # Sekunden zwischen EMS-Updates POLL_INTERVAL_S = 30 # Sekunden zwischen EMS-Updates
PHASES = 3 # Anzahl der Phasen PHASES = 3 # Anzahl der Phasen
VOLTAGE_V = 230 # Netzspannung VOLTAGE_V = 230 # Netzspannung
MAX_CHARGE_MA = 32000 # Absolutes Wallbox-Limit (Hardware-Grenze WallboxReader).
# ACHTUNG: die tatsächlich installierte Wallbox/Zuleitung kann
# niedriger dimensioniert sein (z.B. 11kW-Wallbox = 16A) — dann
# über max_charge_ma im Konstruktor auf den echten Wert begrenzen,
# sonst fordert das Zwangsladen mehr an als das Gerät liefern kann.
# Charging-State Werte (Kathrein Modbus) # Charging-State Werte (Kathrein Modbus)
STATE_IDLE = 0 STATE_IDLE = 0
@@ -39,11 +44,13 @@ class EmsController:
pv_timeout_h: float = PV_TIMEOUT_H, pv_timeout_h: float = PV_TIMEOUT_H,
target_hour: int = TARGET_HOUR, target_hour: int = TARGET_HOUR,
phases: int = PHASES, phases: int = PHASES,
max_charge_ma: int = MAX_CHARGE_MA,
): ):
self.min_pv_power = min_pv_power self.min_pv_power = min_pv_power
self.pv_timeout_h = pv_timeout_h self.pv_timeout_h = pv_timeout_h
self.target_hour = target_hour self.target_hour = target_hour
self.phases = phases self.phases = phases
self.max_charge_ma = max_charge_ma
self._pv_ok_since: Optional[float] = None # Zeitpunkt letzter ausreichender PV self._pv_ok_since: Optional[float] = None # Zeitpunkt letzter ausreichender PV
self._no_pv_since: Optional[float] = None # Zeitpunkt seit kein PV self._no_pv_since: Optional[float] = None # Zeitpunkt seit kein PV
@@ -66,7 +73,7 @@ class EmsController:
total_w = surplus_w + wallbox_w total_w = surplus_w + wallbox_w
current_a = total_w / (VOLTAGE_V * self.phases) current_a = total_w / (VOLTAGE_V * self.phases)
ma = int(current_a * 1000) ma = int(current_a * 1000)
return max(6000, min(32000, ma)) return max(6000, min(self.max_charge_ma, ma))
def update(self, wallbox_reader, pv_surplus_w: float, charging_state: int, def update(self, wallbox_reader, pv_surplus_w: float, charging_state: int,
wallbox_power_w: float = 0.0) -> str: wallbox_power_w: float = 0.0) -> str:
@@ -84,8 +91,8 @@ class EmsController:
if wallbox_reader.enable_ems(): if wallbox_reader.enable_ems():
self._ems_enabled = True self._ems_enabled = True
log.info("[EMS] EMS aktiviert") log.info("[EMS] EMS aktiviert")
else: elif charging_state == STATE_IDLE:
# Auto weg → alles zurücksetzen # Wirklich kein Fahrzeug angesteckt → alles zurücksetzen, Timer neu starten
if self._ems_enabled: if self._ems_enabled:
wallbox_reader.set_current(0) wallbox_reader.set_current(0)
self._ems_enabled = False self._ems_enabled = False
@@ -93,6 +100,16 @@ class EmsController:
self._no_pv_since = None self._no_pv_since = None
log.info("[EMS] Auto abgesteckt — EMS zurückgesetzt") log.info("[EMS] Auto abgesteckt — EMS zurückgesetzt")
return "kein Fahrzeug" return "kein Fahrzeug"
else:
# Unbekannter/nicht abgedeckter Ladezustand (z.B. Fehler/Störung am Fahrzeug).
# NICHT wie "abgesteckt" behandeln — sonst verliert der Zwangslade-Timer seinen
# Fortschritt und die Deadline (Zielzeit) wird nie mehr erreicht, obwohl das Auto
# die ganze Zeit angesteckt war. Strom sicherheitshalber pausieren, aber Zustand/
# Timer erhalten, damit nach Rückkehr in einen bekannten Zustand nahtlos weitergeht.
wallbox_reader.set_current(0)
log.warning("[EMS] Unbekannter Ladezustand %d (evtl. Störung am Fahrzeug) — "
"Strom pausiert, Zwangslade-Timer bleibt erhalten", charging_state)
return f"unbekannter Zustand ({charging_state})"
if charging_state == STATE_COMPLETED: if charging_state == STATE_COMPLETED:
wallbox_reader.set_current(0) wallbox_reader.set_current(0)
@@ -116,13 +133,16 @@ class EmsController:
no_pv_h = (now - self._no_pv_since) / 3600.0 no_pv_h = (now - self._no_pv_since) / 3600.0
if no_pv_h >= self.pv_timeout_h: if no_pv_h >= self.pv_timeout_h:
# Zwangsladen bis Zielzeit # Zwangsladen bis Zielzeit — Strom bei JEDEM Update erneut setzen (nicht nur beim
# ersten Eintritt), sonst bleibt ein einzelner fehlgeschlagener/verworfener Write
# (Modbus-Hänger, Fahrzeug-Störung) unbemerkt für den Rest der Nacht stehen.
target = self._next_target() target = self._next_target()
mins_left = (target - datetime.now()).total_seconds() / 60 mins_left = (target - datetime.now()).total_seconds() / 60
if not self._forced_charging: if not self._forced_charging:
wallbox_reader.set_current(32000, self.phases)
self._forced_charging = True self._forced_charging = True
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00", int(mins_left), self.target_hour) log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00, max %.0fA",
int(mins_left), self.target_hour, self.max_charge_ma / 1000)
wallbox_reader.set_current(self.max_charge_ma, self.phases)
return f"Zwangsladen ({no_pv_h:.1f}h kein PV, {int(mins_left)}min bis {self.target_hour:02d}:00)" return f"Zwangsladen ({no_pv_h:.1f}h kein PV, {int(mins_left)}min bis {self.target_hour:02d}:00)"
# Warten auf PV # Warten auf PV
+23 -16
View File
@@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Tuple
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
DB_PATH = "/data/history.db" DB_PATH = "/data/history.db"
RETENTION_DAYS = 7 RETENTION_DAYS = 14
_lock = threading.Lock() _lock = threading.Lock()
_conn: sqlite3.Connection | None = None _conn: sqlite3.Connection | None = None
@@ -59,9 +59,19 @@ def init_db():
""") """)
c.commit() c.commit()
cleanup_old() cleanup_old()
_start_cleanup_scheduler()
log.info("History DB initialisiert: %s", DB_PATH) log.info("History DB initialisiert: %s", DB_PATH)
def _start_cleanup_scheduler():
def _loop():
while True:
time.sleep(86400)
cleanup_old()
t = threading.Thread(target=_loop, daemon=True, name="history-cleanup")
t.start()
def period_key(period_type: str, billing_day: int = 1, billing_month: int = 1) -> str: def period_key(period_type: str, billing_day: int = 1, billing_month: int = 1) -> str:
import datetime import datetime
today = datetime.date.today() today = datetime.date.today()
@@ -117,21 +127,18 @@ def write_batch(inv_id: str, ts: float, values: Dict[str, float]):
def load_recent(inv_id: str, limit: int = 300) -> Dict[str, List[Tuple[float, float]]]: def load_recent(inv_id: str, limit: int = 300) -> Dict[str, List[Tuple[float, float]]]:
"""Letzte `limit` Messpunkte pro Sensor — zum Befüllen der In-Memory-Deque beim Start.""" """Letzte `limit` Messpunkte pro Sensor — zum Befüllen der In-Memory-Deque beim Start."""
with _lock: with _lock:
rows = _get_conn().execute(""" c = _get_conn()
SELECT sensor_id, ts, value sensors = [r[0] for r in c.execute(
FROM ( "SELECT DISTINCT sensor_id FROM measurements WHERE inv_id = ?", (inv_id,)
SELECT sensor_id, ts, value, ).fetchall()]
ROW_NUMBER() OVER (PARTITION BY sensor_id ORDER BY ts DESC) AS rn result: Dict[str, List[Tuple[float, float]]] = {}
FROM measurements for sid in sensors:
WHERE inv_id = ? rows = c.execute(
) "SELECT ts, value FROM measurements "
WHERE rn <= ? "WHERE inv_id = ? AND sensor_id = ? ORDER BY ts DESC LIMIT ?",
ORDER BY ts ASC (inv_id, sid, limit),
""", (inv_id, limit)).fetchall() ).fetchall()
result[sid] = [(ts, val) for ts, val in reversed(rows)]
result: Dict[str, List[Tuple[float, float]]] = {}
for sensor_id, ts, value in rows:
result.setdefault(sensor_id, []).append((ts, value))
return result return result
+3 -2
View File
@@ -247,8 +247,9 @@ INVERTERS = {
name="Kathrein Wallbox", name="Kathrein Wallbox",
manufacturer="Kathrein", manufacturer="Kathrein",
sensors=_kathrein_sensors(), sensors=_kathrein_sensors(),
# Meter-Block + EVSE-Status + EMS-Setpoint # Meter-Block 0x0030-0x005F + charging_state 0x0060 (solo, 0x0061-0x0064 existieren nicht)
read_ranges=[(0x0030, 48), (0x0060, 10), (0x00A2, 1)], # + EVSE-Status 0x0065-0x006A + EMS-Setpoint 0x00A2
read_ranges=[(0x0030, 48), (0x0060, 1), (0x0065, 6), (0x00A2, 1)],
protocol="kathrein", protocol="kathrein",
goodwe_family="", goodwe_family="",
), ),
+43 -75
View File
@@ -1,7 +1,6 @@
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
@@ -55,8 +54,8 @@ AGG_SENSOR_IDS: Dict[str, List[str]] = {
"total_energy_today": ["energy_today", "e_day"], "total_energy_today": ["energy_today", "e_day"],
"total_energy_total": ["energy_total", "e_total"], "total_energy_total": ["energy_total", "e_total"],
"grid_power": ["grid_power"], "grid_power": ["grid_power"],
"grid_import_kwh": ["import_kwh", "e_total_imp"], "grid_import_kwh": ["import_kwh", "_int_import", "e_total_imp"],
"grid_export_kwh": ["export_kwh", "e_total_exp"], "grid_export_kwh": ["export_kwh", "_int_export", "e_total_exp"],
"bat_charge_power": ["bat_charge_power"], "bat_charge_power": ["bat_charge_power"],
"bat_discharge_power": ["bat_discharge_power"], "bat_discharge_power": ["bat_discharge_power"],
"bat_charge_total": ["bat_charge_total", "e_bat_charge_total"], "bat_charge_total": ["bat_charge_total", "e_bat_charge_total"],
@@ -280,17 +279,18 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds", log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds",
inv_id, inverter.name, host, interval) inv_id, inverter.name, host, interval)
elif inverter.protocol == "kathrein": elif inverter.protocol == "kathrein":
reader = WallboxReader(host=host, port=port) reader = WallboxReader(host=host, port=port, timeout=min(5.0, interval * 0.4))
ems = EmsController( ems = EmsController(
min_pv_power=inv_cfg.get("ems_min_pv", 1400), min_pv_power=inv_cfg.get("ems_min_pv", 1400),
pv_timeout_h=inv_cfg.get("ems_timeout", 4.0), pv_timeout_h=inv_cfg.get("ems_timeout", 4.0),
target_hour=inv_cfg.get("ems_target_hour", 6), target_hour=inv_cfg.get("ems_target_hour", 6),
phases=inv_cfg.get("ems_phases", 3), phases=inv_cfg.get("ems_phases", 3),
max_charge_ma=inv_cfg.get("ems_max_current_ma", 32000),
) )
log.info("[%s] Poll-Loop: %s @ %s:%s (Kathrein EMS) alle %ds", log.info("[%s] Poll-Loop: %s @ %s:%s (Kathrein EMS) alle %ds",
inv_id, inverter.name, host, port, interval) inv_id, inverter.name, host, port, interval)
else: else:
reader = ModbusReader(host=host, port=port, slave=slave) reader = ModbusReader(host=host, port=port, slave=slave, timeout=min(5.0, interval * 0.4))
log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds", log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds",
inv_id, inverter.name, host, port, interval) inv_id, inverter.name, host, port, interval)
@@ -310,6 +310,13 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
q.append(pt) q.append(pt)
log.info("[%s] %d Sensoren aus DB geladen", inv_id, len(hist_data)) log.info("[%s] %d Sensoren aus DB geladen", inv_id, len(hist_data))
# Integrierte Grid-Zähler — starten bei 0, wachsen mit jedem Poll, persistieren in DB
inv_int_import = hist_data["_int_import"][-1][1] if hist_data.get("_int_import") else 0.0
inv_int_export = hist_data["_int_export"][-1][1] if hist_data.get("_int_export") else 0.0
last_grid_ts = time.time()
_fail_count = 0 # aufeinanderfolgende Lesefehler; erst ab >= 3 → offline
_OFFLINE_THRESHOLD = 3
while not stop.is_set(): while not stop.is_set():
t0 = time.time() t0 = time.time()
values = reader.read(inverter) values = reader.read(inverter)
@@ -322,12 +329,24 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
if values and "grid_power" not in values and "import_kwh" in values and "total_power" in values: if values and "grid_power" not in values and "import_kwh" in values and "total_power" in values:
values["grid_power"] = values["total_power"] values["grid_power"] = values["total_power"]
# grid_power integrieren → interne kWh-Zähler (Periodenberechnung für Goodwe / kein SDM-630)
if values is not None and "grid_power" in values:
dt_s = t0 - last_grid_ts
if 0 < dt_s < 300:
gp = values["grid_power"]
if gp > 0:
inv_int_import = round(inv_int_import + gp * dt_s / 3_600_000, 6)
elif gp < 0:
inv_int_export = round(inv_int_export + (-gp) * dt_s / 3_600_000, 6)
values["_int_import"] = inv_int_import
values["_int_export"] = inv_int_export
last_grid_ts = t0
# EMS: PV-Überschuss aus anderen Geräten holen und Ladestrom regeln # EMS: PV-Überschuss aus anderen Geräten holen und Ladestrom regeln
if ems is not None and values is not None and inv_cfg.get("ems_enabled", True): if ems is not None and values is not None and inv_cfg.get("ems_enabled", True):
pv_surplus = _get_pv_surplus() pv_surplus = _get_pv_surplus()
# 0x0060 manchmal nicht lesbar → 1 (EV Connected) annehmen, # charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug → 0 (Idle)
# damit EMS aktiviert; Wallbox ignoriert Befehle wenn kein Auto da charging_state = int(values.get("charging_state", 0))
charging_state = int(values.get("charging_state", 1))
wallbox_power = values.get("total_power", 0.0) wallbox_power = values.get("total_power", 0.0)
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power) ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power)
values["ems_status_code"] = float(charging_state) values["ems_status_code"] = float(charging_state)
@@ -336,6 +355,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
with State.lock: with State.lock:
d = State.inv_data.setdefault(inv_id, {"poll_count": 0}) d = State.inv_data.setdefault(inv_id, {"poll_count": 0})
if values is not None: if values is not None:
_fail_count = 0
d["values"] = values d["values"] = values
d["last_update"] = time.time() d["last_update"] = time.time()
d["modbus_ok"] = True d["modbus_ok"] = True
@@ -347,12 +367,15 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
q.append((now, val)) q.append((now, val))
history.write_batch(inv_id, now, values) history.write_batch(inv_id, now, values)
if _publisher: if _publisher:
_publisher.publish_data(values, prefix) pub_values = {k: v for k, v in values.items() if not k.startswith("_")}
_publisher.publish_data(pub_values, prefix)
_publisher.publish_status("online", prefix) _publisher.publish_status("online", prefix)
else: else:
d["modbus_ok"] = False _fail_count += 1
if _publisher: if _fail_count >= _OFFLINE_THRESHOLD:
_publisher.publish_status("offline", prefix) d["modbus_ok"] = False
if _publisher:
_publisher.publish_status("offline", prefix)
# Aggregate nach jedem erfolgreichen Poll neu berechnen und publizieren # Aggregate nach jedem erfolgreichen Poll neu berechnen und publizieren
if values is not None and _publisher: if values is not None and _publisher:
@@ -876,67 +899,8 @@ 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}$') # Flash/OTA-Endpunkte ausgelagert nach growatt-diagnose-tool (v1.9.0) —
# ShineBridge fokussiert sich auf Energiemanagement, kein Firmware-Flashing mehr.
@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():
@@ -952,8 +916,12 @@ if __name__ == "__main__":
history.init_db() history.init_db()
cfg = load_config() cfg = load_config()
with State.lock: with State.lock:
State.mqtt_cfg = {k: cfg[k] for k in State.mqtt_cfg = {k: cfg[k] for k in (
("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass")} "mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass",
"price_import", "price_export", "billing_day", "billing_month",
"tariff_type", "spot_country", "spot_markup", "spot_chart",
"billing_tracker_enabled", "monthly_rate_eur", "grundpreis_eur_per_month",
) if k in cfg}
State.inverters_cfg = cfg.get("inverters", []) State.inverters_cfg = cfg.get("inverters", [])
State.surplus_devices_cfg = cfg.get("surplus_devices", []) State.surplus_devices_cfg = cfg.get("surplus_devices", [])
State.z2m_base = cfg.get("z2m_base", "zigbee2mqtt") State.z2m_base = cfg.get("z2m_base", "zigbee2mqtt")
+39 -424
View File
@@ -162,48 +162,14 @@
.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); }
@media (max-width: 520px) { @media (max-width: 520px) {
main { padding: 10px; } main { padding: 10px; }
.settings-section { max-width: 100%; } .settings-section { max-width: 100%; }
.sensor-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } .sensor-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.inv-list { grid-template-columns: 1fr; } .inv-list { grid-template-columns: 1fr; }
.kwh-card .kv { font-size: 15px; } .kwh-card .kv { font-size: 15px; }
.flash-section { max-width: 100% !important; }
header svg { display: none; } header svg { display: none; }
} }
/* 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>
@@ -229,7 +195,6 @@
<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 -->
@@ -358,99 +323,7 @@
</div> </div>
</div> </div>
<!-- Flash Panel --> <!-- Flash/OTA-Panel ausgelagert nach growatt-diagnose-tool (v1.9.0) -->
<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 -->
@@ -495,6 +368,9 @@ GND → GND
<option value="2">2-phasig</option> <option value="2">2-phasig</option>
<option value="3" selected>3-phasig</option> <option value="3" selected>3-phasig</option>
</select></div> </select></div>
<div class="field"><label>Max. Ladestrom (A)</label>
<input type="number" id="ems-max-current" value="32" min="6" max="32" step="1">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Obergrenze für Zwangsladen — auf die tatsächliche Wallbox-/Zuleitungs-Absicherung setzen (z.B. 16A bei einer 11kW-Wallbox), nicht höher als die Hardware zulässt</div></div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@@ -504,77 +380,6 @@ GND → GND
</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>
@@ -653,7 +458,7 @@ function showToast(msg, type) {
} }
function switchTab(name) { function switchTab(name) {
["energy","finance","live","inverters","settings","flash"].forEach((t, i) => { ["energy","finance","live","inverters","settings"].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);
}); });
@@ -957,37 +762,39 @@ async function loadFinance() {
const lohnt = savings_eur > 0; const lohnt = savings_eur > 0;
const col = lohnt ? C.green : C.red; const col = lohnt ? C.green : C.red;
const icon = lohnt ? '✓' : '✗'; const icon = lohnt ? '✓' : '✗';
empfehlung = `<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid ${col};border-radius:var(--radius);padding:14px 18px;margin-bottom:16px"> empfehlung = `<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid ${col};border-radius:var(--radius);padding:18px 22px;margin-bottom:24px">
<div> <div>
<div style="font-weight:700;font-size:15px;color:${col}">${icon} Flexibler Tarif würde sich ${lohnt ? 'lohnen' : 'nicht lohnen'}</div> <div style="font-weight:700;font-size:15px;color:${col}">${icon} Flexibler Tarif würde sich ${lohnt ? 'lohnen' : 'nicht lohnen'}</div>
<div style="font-size:12px;color:var(--text-dim);margin-top:3px">Basierend auf ${spot_days} Tagen im Abrechnungsjahr</div> <div style="font-size:12px;color:var(--text-dim);margin-top:5px">Basierend auf ${spot_days} Tagen im Abrechnungsjahr</div>
</div> </div>
<div style="font-size:22px;font-weight:700;color:${col}">${lohnt ? '−' : '+'}${fEur(Math.abs(savings_eur))}</div> <div style="font-size:26px;font-weight:700;color:${col};margin-left:20px;white-space:nowrap">${lohnt ? '−' : '+'}${fEur(Math.abs(savings_eur))}</div>
</div>`; </div>`;
} }
// ── Summary-Karten ─────────────────────────────────────────── // ── Summary-Karten ───────────────────────────────────────────
const spotCard = spot_total_eur !== null const spotCard = spot_total_eur !== null
? `<div class="kwh-card" style="border-top:3px solid ${C.spot}"> ? `<div class="kwh-card" style="border-top:3px solid ${C.spot};padding:18px 10px">
<div class="kv" style="color:${C.spot}">${fEur(spot_total_eur)}</div> <div class="kv" style="color:${C.spot};font-size:22px">${fEur(spot_total_eur)}</div>
<div class="kl">Spot-Tarif (hypothetisch)<br><span style="opacity:.6">${spot_days} Tage mit Preisdaten</span></div> <div class="kl" style="font-size:11px;margin-top:6px">Spot-Tarif (hypothetisch)<br><span style="opacity:.6">${spot_days} Tage mit Preisdaten</span></div>
</div>` </div>`
: `<div class="kwh-card"><div class="kv" style="color:var(--text-dim)">–</div><div class="kl">Spot-Tarif<br><span style="opacity:.6">Noch keine Daten</span></div></div>`; : `<div class="kwh-card" style="padding:18px 10px"><div class="kv" style="color:var(--text-dim);font-size:22px">–</div><div class="kl" style="font-size:11px;margin-top:6px">Spot-Tarif<br><span style="opacity:.6">Noch keine Daten</span></div></div>`;
const savCard = savings_eur !== null const savCard = savings_eur !== null
? `<div class="kwh-card" style="border-top:3px solid ${savings_eur > 0 ? C.green : C.red}"> ? `<div class="kwh-card" style="border-top:3px solid ${savings_eur > 0 ? C.green : C.red};padding:18px 10px">
<div class="kv" style="color:${savings_eur > 0 ? C.green : C.red}">${savings_eur > 0 ? '−' : '+'}${fEur(Math.abs(savings_eur))}</div> <div class="kv" style="color:${savings_eur > 0 ? C.green : C.red};font-size:22px">${savings_eur > 0 ? '−' : '+'}${fEur(Math.abs(savings_eur))}</div>
<div class="kl">${savings_eur > 0 ? 'Ersparnis mit Spot' : 'Mehrkosten mit Spot'}<br><span style="opacity:.6">gegenüber Festpreis</span></div> <div class="kl" style="font-size:11px;margin-top:6px">${savings_eur > 0 ? 'Ersparnis mit Spot' : 'Mehrkosten mit Spot'}<br><span style="opacity:.6">gegenüber Festpreis</span></div>
</div>` </div>`
: ''; : '';
const cards = `<div class="energy-kwh" style="margin-bottom:20px"> const cards = `
<div class="kwh-card" style="border-top:3px solid ${C.fixed}"> <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:10px">Kostenübersicht · Abrechnungsjahr</div>
<div class="kv" style="color:${C.fixed}">${fEur(fixed_total_eur)}</div> <div class="energy-kwh" style="margin-bottom:28px;gap:12px">
<div class="kl">Festpreis-Kosten<br><span style="opacity:.6">${total_days} Tage</span></div> <div class="kwh-card" style="border-top:3px solid ${C.fixed};padding:18px 10px">
</div> <div class="kv" style="color:${C.fixed};font-size:22px">${fEur(fixed_total_eur)}</div>
${spotCard}${savCard} <div class="kl" style="font-size:11px;margin-top:6px">Festpreis-Kosten<br><span style="opacity:.6">${total_days} Tage</span></div>
</div>`; </div>
${spotCard}${savCard}
</div>`;
// ── SVG-Balkendiagramm ──────────────────────────────────────── // ── SVG-Balkendiagramm ────────────────────────────────────────
const W = 600, H = 180, PL = 44, PR = 12, PT = 10, PB = 28; const W = 600, H = 180, PL = 44, PR = 12, PT = 10, PB = 28;
@@ -1027,13 +834,17 @@ async function loadFinance() {
const legend = `<text x="${PL}" y="${H+18}" font-size="10" fill="${C.fixed}">■ Festpreis</text> const legend = `<text x="${PL}" y="${H+18}" font-size="10" fill="${C.fixed}">■ Festpreis</text>
<text x="${PL+70}" y="${H+18}" font-size="10" fill="${C.spot}">■ Spot (hypothetisch)</text>`; <text x="${PL+70}" y="${H+18}" font-size="10" fill="${C.spot}">■ Spot (hypothetisch)</text>`;
const chart = `<div style="margin-bottom:20px;overflow-x:auto"> const chartSection = `
<svg viewBox="0 0 ${W} ${H+24}" style="width:100%;max-width:${W}px;display:block"> <div style="border-top:1px solid var(--border);padding-top:24px;margin-top:4px">
${gridLines}${yLabels}${bars}${xLabels}${legend} <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:14px">Kosten je Tag</div>
</svg> <div style="overflow-x:auto">
</div>`; <svg viewBox="0 0 ${W} ${H+24}" style="width:100%;max-width:${W}px;display:block">
${gridLines}${yLabels}${bars}${xLabels}${legend}
</svg>
</div>
</div>`;
el.innerHTML = empfehlung + cards + chart; el.innerHTML = `<div style="padding:4px 0">${empfehlung}${cards}${chartSection}</div>`;
} catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; console.error('loadFinance:', e); } } catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; console.error('loadFinance:', e); }
} }
@@ -1189,6 +1000,7 @@ async function openModal(invId) {
document.getElementById("ems-timeout").value = inv.ems_timeout ?? 4; document.getElementById("ems-timeout").value = inv.ems_timeout ?? 4;
document.getElementById("ems-target-hour").value = inv.ems_target_hour ?? 6; document.getElementById("ems-target-hour").value = inv.ems_target_hour ?? 6;
document.getElementById("ems-phases").value = inv.ems_phases ?? 3; document.getElementById("ems-phases").value = inv.ems_phases ?? 3;
document.getElementById("ems-max-current").value = inv.ems_max_current_ma ? inv.ems_max_current_ma / 1000 : 32;
document.getElementById("ems-enabled").checked = inv.ems_enabled ?? true; document.getElementById("ems-enabled").checked = inv.ems_enabled ?? true;
} else { } else {
document.getElementById("modal-title").textContent = "Gerät hinzufügen"; document.getElementById("modal-title").textContent = "Gerät hinzufügen";
@@ -1203,6 +1015,7 @@ async function openModal(invId) {
document.getElementById("ems-timeout").value = "4"; document.getElementById("ems-timeout").value = "4";
document.getElementById("ems-target-hour").value = "6"; document.getElementById("ems-target-hour").value = "6";
document.getElementById("ems-phases").value = "3"; document.getElementById("ems-phases").value = "3";
document.getElementById("ems-max-current").value = "32";
} }
toggleEmsSection(modelSel.value); toggleEmsSection(modelSel.value);
modal.classList.add("open"); modal.classList.add("open");
@@ -1237,6 +1050,7 @@ async function saveInverter() {
inv.ems_timeout = parseFloat(document.getElementById("ems-timeout").value); inv.ems_timeout = parseFloat(document.getElementById("ems-timeout").value);
inv.ems_target_hour = parseInt(document.getElementById("ems-target-hour").value); inv.ems_target_hour = parseInt(document.getElementById("ems-target-hour").value);
inv.ems_phases = parseInt(document.getElementById("ems-phases").value); inv.ems_phases = parseInt(document.getElementById("ems-phases").value);
inv.ems_max_current_ma = Math.round(parseFloat(document.getElementById("ems-max-current").value) * 1000);
inv.ems_enabled = document.getElementById("ems-enabled").checked; inv.ems_enabled = document.getElementById("ems-enabled").checked;
} }
const idx = invertersList.findIndex(i => i.id === id); const idx = invertersList.findIndex(i => i.id === id);
@@ -1522,212 +1336,13 @@ function renderSurplusStatus(surplusData) {
</div>`; </div>`;
} }
// ── Flash-Wizard ────────────────────────────────────────────── // Flash-Wizard + Setup-Wizard ausgelagert nach growatt-diagnose-tool (v1.9.0) —
// Geräte werden jetzt direkt über "+ Gerät hinzufügen" im Geräte-Tab angelegt.
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();
})(); })();