Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f94d3f28 | |||
| c4047fc804 | |||
| 5564a50c3c | |||
| dc2df891fb | |||
| a361c30f1b | |||
| a9f33c8e9e |
@@ -97,3 +97,7 @@
|
||||
| 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.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,5 +1,5 @@
|
||||
name: ShineBridge
|
||||
version: "1.8.22"
|
||||
version: "1.8.28"
|
||||
slug: shinebridge
|
||||
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
||||
url: https://gitea.bitfire.work/retr0/shinebridge
|
||||
|
||||
+23
-16
@@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Tuple
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = "/data/history.db"
|
||||
RETENTION_DAYS = 7
|
||||
RETENTION_DAYS = 14
|
||||
|
||||
_lock = threading.Lock()
|
||||
_conn: sqlite3.Connection | None = None
|
||||
@@ -59,9 +59,19 @@ def init_db():
|
||||
""")
|
||||
c.commit()
|
||||
cleanup_old()
|
||||
_start_cleanup_scheduler()
|
||||
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:
|
||||
import datetime
|
||||
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]]]:
|
||||
"""Letzte `limit` Messpunkte pro Sensor — zum Befüllen der In-Memory-Deque beim Start."""
|
||||
with _lock:
|
||||
rows = _get_conn().execute("""
|
||||
SELECT sensor_id, ts, value
|
||||
FROM (
|
||||
SELECT sensor_id, ts, value,
|
||||
ROW_NUMBER() OVER (PARTITION BY sensor_id ORDER BY ts DESC) AS rn
|
||||
FROM measurements
|
||||
WHERE inv_id = ?
|
||||
)
|
||||
WHERE rn <= ?
|
||||
ORDER BY ts ASC
|
||||
""", (inv_id, limit)).fetchall()
|
||||
|
||||
result: Dict[str, List[Tuple[float, float]]] = {}
|
||||
for sensor_id, ts, value in rows:
|
||||
result.setdefault(sensor_id, []).append((ts, value))
|
||||
c = _get_conn()
|
||||
sensors = [r[0] for r in c.execute(
|
||||
"SELECT DISTINCT sensor_id FROM measurements WHERE inv_id = ?", (inv_id,)
|
||||
).fetchall()]
|
||||
result: Dict[str, List[Tuple[float, float]]] = {}
|
||||
for sid in sensors:
|
||||
rows = c.execute(
|
||||
"SELECT ts, value FROM measurements "
|
||||
"WHERE inv_id = ? AND sensor_id = ? ORDER BY ts DESC LIMIT ?",
|
||||
(inv_id, sid, limit),
|
||||
).fetchall()
|
||||
result[sid] = [(ts, val) for ts, val in reversed(rows)]
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -247,8 +247,9 @@ INVERTERS = {
|
||||
name="Kathrein Wallbox",
|
||||
manufacturer="Kathrein",
|
||||
sensors=_kathrein_sensors(),
|
||||
# Meter-Block + EVSE-Status + EMS-Setpoint
|
||||
read_ranges=[(0x0030, 48), (0x0060, 10), (0x00A2, 1)],
|
||||
# Meter-Block 0x0030-0x005F + charging_state 0x0060 (solo, 0x0061-0x0064 existieren nicht)
|
||||
# + EVSE-Status 0x0065-0x006A + EMS-Setpoint 0x00A2
|
||||
read_ranges=[(0x0030, 48), (0x0060, 1), (0x0065, 6), (0x00A2, 1)],
|
||||
protocol="kathrein",
|
||||
goodwe_family="",
|
||||
),
|
||||
|
||||
+34
-11
@@ -55,8 +55,8 @@ AGG_SENSOR_IDS: Dict[str, List[str]] = {
|
||||
"total_energy_today": ["energy_today", "e_day"],
|
||||
"total_energy_total": ["energy_total", "e_total"],
|
||||
"grid_power": ["grid_power"],
|
||||
"grid_import_kwh": ["import_kwh", "e_total_imp"],
|
||||
"grid_export_kwh": ["export_kwh", "e_total_exp"],
|
||||
"grid_import_kwh": ["import_kwh", "_int_import", "e_total_imp"],
|
||||
"grid_export_kwh": ["export_kwh", "_int_export", "e_total_exp"],
|
||||
"bat_charge_power": ["bat_charge_power"],
|
||||
"bat_discharge_power": ["bat_discharge_power"],
|
||||
"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",
|
||||
inv_id, inverter.name, host, interval)
|
||||
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(
|
||||
min_pv_power=inv_cfg.get("ems_min_pv", 1400),
|
||||
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",
|
||||
inv_id, inverter.name, host, port, interval)
|
||||
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",
|
||||
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)
|
||||
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():
|
||||
t0 = time.time()
|
||||
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:
|
||||
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
|
||||
if ems is not None and values is not None and inv_cfg.get("ems_enabled", True):
|
||||
pv_surplus = _get_pv_surplus()
|
||||
# 0x0060 manchmal nicht lesbar → 1 (EV Connected) annehmen,
|
||||
# damit EMS aktiviert; Wallbox ignoriert Befehle wenn kein Auto da
|
||||
charging_state = int(values.get("charging_state", 1))
|
||||
# charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug → 0 (Idle)
|
||||
charging_state = int(values.get("charging_state", 0))
|
||||
wallbox_power = values.get("total_power", 0.0)
|
||||
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power)
|
||||
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:
|
||||
d = State.inv_data.setdefault(inv_id, {"poll_count": 0})
|
||||
if values is not None:
|
||||
_fail_count = 0
|
||||
d["values"] = values
|
||||
d["last_update"] = time.time()
|
||||
d["modbus_ok"] = True
|
||||
@@ -347,12 +367,15 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||
q.append((now, val))
|
||||
history.write_batch(inv_id, now, values)
|
||||
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)
|
||||
else:
|
||||
d["modbus_ok"] = False
|
||||
if _publisher:
|
||||
_publisher.publish_status("offline", prefix)
|
||||
_fail_count += 1
|
||||
if _fail_count >= _OFFLINE_THRESHOLD:
|
||||
d["modbus_ok"] = False
|
||||
if _publisher:
|
||||
_publisher.publish_status("offline", prefix)
|
||||
|
||||
# Aggregate nach jedem erfolgreichen Poll neu berechnen und publizieren
|
||||
if values is not None and _publisher:
|
||||
|
||||
@@ -1693,7 +1693,7 @@ async function wizardStep2Next() {
|
||||
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 = [{
|
||||
const newInv = {
|
||||
id,
|
||||
name,
|
||||
inverter_model: model,
|
||||
@@ -1702,7 +1702,9 @@ async function wizardStep2Next() {
|
||||
modbus_address: parseInt(document.getElementById("wz-inv-addr").value) || 1,
|
||||
mqtt_topic_prefix: `growatt/${name.toLowerCase().replace(/\s+/g, "_")}`,
|
||||
update_interval: 30,
|
||||
}];
|
||||
};
|
||||
// Bestehende Geräte behalten — Wizard darf nicht die komplette Liste überschreiben
|
||||
const body = [...invertersList, newInv];
|
||||
try {
|
||||
await fetchJSON(api("api/inverters-config"), {
|
||||
method: "POST", headers: {"Content-Type": "application/json"},
|
||||
|
||||
Reference in New Issue
Block a user