Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5ed7c5df6 | ||
|
|
b36b0194f7 | ||
|
|
04a8fb3125 | ||
|
|
d9f94d3f28 | ||
|
|
c4047fc804 | ||
|
|
5564a50c3c | ||
|
|
dc2df891fb | ||
|
|
a361c30f1b | ||
|
|
a9f33c8e9e | ||
|
|
512b743b16 |
@@ -6,3 +6,7 @@ shinelanx-modbus/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# versehentlich getrackte Alt-Dateien, gehoeren nicht ins Repo
|
||||||
|
Modbus-Server-Kathrein.pdf
|
||||||
|
ShineLAN-X/releases/*.bin
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.8.21"
|
version: "1.8.31"
|
||||||
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,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
|
||||||
|
|||||||
+22
-15
@@ -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
|
|
||||||
FROM measurements
|
|
||||||
WHERE inv_id = ?
|
|
||||||
)
|
|
||||||
WHERE rn <= ?
|
|
||||||
ORDER BY ts ASC
|
|
||||||
""", (inv_id, limit)).fetchall()
|
|
||||||
|
|
||||||
result: Dict[str, List[Tuple[float, float]]] = {}
|
result: Dict[str, List[Tuple[float, float]]] = {}
|
||||||
for sensor_id, ts, value in rows:
|
for sid in sensors:
|
||||||
result.setdefault(sensor_id, []).append((ts, value))
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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="",
|
||||||
),
|
),
|
||||||
|
|||||||
+38
-10
@@ -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,17 +280,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 +311,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 +330,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 +356,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,9 +368,12 @@ 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:
|
||||||
|
_fail_count += 1
|
||||||
|
if _fail_count >= _OFFLINE_THRESHOLD:
|
||||||
d["modbus_ok"] = False
|
d["modbus_ok"] = False
|
||||||
if _publisher:
|
if _publisher:
|
||||||
_publisher.publish_status("offline", prefix)
|
_publisher.publish_status("offline", prefix)
|
||||||
@@ -952,8 +976,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")
|
||||||
|
|||||||
@@ -495,6 +495,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">
|
||||||
@@ -957,34 +960,36 @@ 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 class="kv" style="color:${C.fixed};font-size:22px">${fEur(fixed_total_eur)}</div>
|
||||||
|
<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}
|
${spotCard}${savCard}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -1027,13 +1032,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 = `
|
||||||
|
<div style="border-top:1px solid var(--border);padding-top:24px;margin-top:4px">
|
||||||
|
<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>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<svg viewBox="0 0 ${W} ${H+24}" style="width:100%;max-width:${W}px;display:block">
|
<svg viewBox="0 0 ${W} ${H+24}" style="width:100%;max-width:${W}px;display:block">
|
||||||
${gridLines}${yLabels}${bars}${xLabels}${legend}
|
${gridLines}${yLabels}${bars}${xLabels}${legend}
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
</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 +1198,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 +1213,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 +1248,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);
|
||||||
@@ -1687,7 +1699,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,
|
||||||
@@ -1696,7 +1708,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"},
|
||||||
|
|||||||
Reference in New Issue
Block a user