8 Commits

Author SHA1 Message Date
retr0 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
retr0 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
retr0 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
retr0 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
retr0 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
retr0 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
retr0 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
retr0 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
6 changed files with 69 additions and 32 deletions
+4
View File
@@ -97,3 +97,7 @@
| 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.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 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge name: ShineBridge
version: "1.8.22" version: "1.8.30"
slug: shinebridge slug: shinebridge
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
url: https://gitea.bitfire.work/retr0/shinebridge url: https://gitea.bitfire.work/retr0/shinebridge
+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="",
), ),
+34 -11
View File
@@ -55,8 +55,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,7 +280,7 @@ 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),
@@ -290,7 +290,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
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 lesbar1 (EV Connected) annehmen, # charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug0 (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:
+4 -2
View File
@@ -1693,7 +1693,7 @@ async function wizardStep2Next() {
if (!name || !ip) { showToast("Name und IP sind Pflichtfelder", "err"); return; } if (!name || !ip) { showToast("Name und IP sind Pflichtfelder", "err"); return; }
const model = document.getElementById("wz-inv-model").value; const model = document.getElementById("wz-inv-model").value;
const id = Math.random().toString(36).slice(2, 10); const id = Math.random().toString(36).slice(2, 10);
const body = [{ const newInv = {
id, id,
name, name,
inverter_model: model, inverter_model: model,
@@ -1702,7 +1702,9 @@ async function wizardStep2Next() {
modbus_address: parseInt(document.getElementById("wz-inv-addr").value) || 1, modbus_address: parseInt(document.getElementById("wz-inv-addr").value) || 1,
mqtt_topic_prefix: `growatt/${name.toLowerCase().replace(/\s+/g, "_")}`, mqtt_topic_prefix: `growatt/${name.toLowerCase().replace(/\s+/g, "_")}`,
update_interval: 30, update_interval: 30,
}]; };
// Bestehende Geräte behalten — Wizard darf nicht die komplette Liste überschreiben
const body = [...invertersList, newInv];
try { try {
await fetchJSON(api("api/inverters-config"), { await fetchJSON(api("api/inverters-config"), {
method: "POST", headers: {"Content-Type": "application/json"}, method: "POST", headers: {"Content-Type": "application/json"},