From 5c8c1737dac1af939afade4f7c80d5807f7f97c3 Mon Sep 17 00:00:00 2001 From: Franz Bettag Date: Wed, 27 May 2026 09:33:28 +0200 Subject: [PATCH 1/2] Fix cumulative EVCC energy export --- dbus_service.py | 12 ++++++++---- evcc_api.py | 2 ++ tests/fixtures/evcc_state_1lp.json | 1 + tests/fixtures/evcc_state_3lp.json | 3 +++ tests/test_dbus_service.py | 19 +++++++++++++++++-- tests/test_evcc_api.py | 2 ++ 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/dbus_service.py b/dbus_service.py index bb8855f..d2b7ff1 100644 --- a/dbus_service.py +++ b/dbus_service.py @@ -150,11 +150,15 @@ def update(self, lp: Loadpoint) -> None: s["/Status"] = status s["/Connected"] = 1 - # Cumulative counters only refreshed while connected. UNVERIFIED: - # chargedEnergy is treated as Wh (-> /1000 = kWh). Must be confirmed - # against a real EVCC /api/state sample before deploy. - if status != STATUS_DISCONNECTED: + # chargeTotalImport is EVCC's cumulative kWh meter value. It keeps + # increasing for continuous heating devices where chargedEnergy is + # a per-session counter and may stay at 0. + total_import = float(lp.charge_total_import or 0.0) + if total_import > 0: + s["/Ac/Energy/Forward"] = total_import + elif status != STATUS_DISCONNECTED: s["/Ac/Energy/Forward"] = float(lp.charged_energy) / 1000.0 + if status != STATUS_DISCONNECTED: s["/ChargingTime"] = int(lp.charge_duration_ns) // 1_000_000_000 idx = (int(s["/UpdateIndex"]) + 1) % 256 diff --git a/evcc_api.py b/evcc_api.py index d44422b..21e1ae8 100644 --- a/evcc_api.py +++ b/evcc_api.py @@ -26,6 +26,7 @@ class Loadpoint: charge_voltages: List[float] = field(default_factory=lambda: [230.0, 230.0, 230.0]) effective_max_current: int = 16 charged_energy: float = 0.0 + charge_total_import: float = 0.0 charge_duration_ns: int = 0 @@ -61,6 +62,7 @@ def _parse_loadpoints(state: dict) -> List[Loadpoint]: charge_voltages=_normalize_triple(raw.get("chargeVoltages"), 230.0), effective_max_current=int(raw.get("effectiveMaxCurrent") or 16), charged_energy=float(raw.get("chargedEnergy") or 0.0), + charge_total_import=float(raw.get("chargeTotalImport") or 0.0), charge_duration_ns=int(raw.get("chargeDuration") or 0), )) return out diff --git a/tests/fixtures/evcc_state_1lp.json b/tests/fixtures/evcc_state_1lp.json index c31f63f..46b1a79 100644 --- a/tests/fixtures/evcc_state_1lp.json +++ b/tests/fixtures/evcc_state_1lp.json @@ -10,6 +10,7 @@ "chargeVoltages": [230, 230, 230], "effectiveMaxCurrent": 20, "chargedEnergy": 250.0, + "chargeTotalImport": 12.345, "chargeDuration": 600000000000 } ] diff --git a/tests/fixtures/evcc_state_3lp.json b/tests/fixtures/evcc_state_3lp.json index 1c5802c..14a8ea3 100644 --- a/tests/fixtures/evcc_state_3lp.json +++ b/tests/fixtures/evcc_state_3lp.json @@ -10,6 +10,7 @@ "chargeVoltages": [230, 230, 230], "effectiveMaxCurrent": 16, "chargedEnergy": 0.0, + "chargeTotalImport": 1.234, "chargeDuration": 0 }, { @@ -22,6 +23,7 @@ "chargeVoltages": [230, 230, 230], "effectiveMaxCurrent": 20, "chargedEnergy": 1800.0, + "chargeTotalImport": 98.765, "chargeDuration": 3600000000000 }, { @@ -34,6 +36,7 @@ "chargeVoltages": [230, 230, 230], "effectiveMaxCurrent": 16, "chargedEnergy": 0.0, + "chargeTotalImport": 19341.908, "chargeDuration": 0 } ] diff --git a/tests/test_dbus_service.py b/tests/test_dbus_service.py index a56bea5..54c631b 100644 --- a/tests/test_dbus_service.py +++ b/tests/test_dbus_service.py @@ -132,13 +132,28 @@ def test_update_charging_uses_per_phase_voltages(monkeypatch): assert sets["/Current"] == 19.5 assert sets["/MaxCurrent"] == 20 assert sets["/Mode"] == MODE_AUTO - # UNVERIFIED unit: chargedEnergy treated as Wh; must confirm against - # a live EVCC /api/state sample before merging the deploy. + # Fallback for EVCC payloads without chargeTotalImport. assert sets["/Ac/Energy/Forward"] == 1.8 assert sets["/ChargingTime"] == 3600 assert sets["/UpdateIndex"] == 6 +def test_update_prefers_charge_total_import_for_cumulative_energy(monkeypatch): + svc, vedbus, _ = _make_svc(monkeypatch) + vedbus.__getitem__.return_value = 0 + lp = Loadpoint( + title="Heatpump", connected=True, charging=False, mode="off", + charge_power=90.25, + charge_currents=[0.93, 0.49, 0.5], + charge_voltages=[237.5, 236.9, 236.9], + charged_energy=0.0, + charge_total_import=19341.908, + ) + svc.update(lp) + sets = dict(c.args for c in vedbus.__setitem__.call_args_list) + assert sets["/Ac/Energy/Forward"] == 19341.908 + + def test_update_index_wraps_at_255(monkeypatch): svc, vedbus, _ = _make_svc(monkeypatch) vedbus.__getitem__.return_value = 255 diff --git a/tests/test_evcc_api.py b/tests/test_evcc_api.py index d5ead05..f01d2e0 100644 --- a/tests/test_evcc_api.py +++ b/tests/test_evcc_api.py @@ -30,6 +30,7 @@ def test_fetch_state_returns_loadpoints_1lp(requests_mock): assert lp.charge_currents == [6.5, 0, 0] assert lp.effective_max_current == 20 assert lp.mode == "pv" + assert lp.charge_total_import == 12.345 def test_fetch_state_returns_loadpoints_3lp(requests_mock): @@ -90,6 +91,7 @@ def test_missing_optional_fields_use_defaults(): assert lps[0].charge_currents == [0, 0, 0] assert lps[0].connected is False assert lps[0].charge_power == 0.0 + assert lps[0].charge_total_import == 0.0 assert lps[0].effective_max_current == 16 From 113009c1b484f55b52d6b150b4495cf50e6a6fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20K=C3=BCgow?= Date: Thu, 28 May 2026 15:43:09 +0200 Subject: [PATCH 2/2] Hold /Ac/Energy/Forward monotonic across source switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Victron expects /Ac/Energy/Forward to be monotonically increasing. The dynamic source switch (chargeTotalImport -> chargedEnergy/1000) can roll the counter backwards if EVCC briefly returns chargeTotalImport: null/0 — observed on a loadpoint without an attached meter. For heating loadpoints chargedEnergy is structurally 0 (IntegratedDevice chargers have no session semantics), so the fallback would publish 0 to VRM and cause a counter reset. Guard the write with max(candidate, previous_vrm_value); no new state, identical behaviour while EVCC is stable. Also publish the cumulative counter when status == DISCONNECTED but a positive chargeTotalImport is present (lifetime meter semantics). Tests: - monotonic across high -> 0 -> high source switch - DISCONNECTED + positive total_import still publishes /Ac/Energy/Forward - existing per-phase-voltages test now uses per-key getitem mock (previous return_value=5 conflicted with the monotonic guard) --- dbus_service.py | 20 +++++++++++--- tests/test_dbus_service.py | 56 +++++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/dbus_service.py b/dbus_service.py index d2b7ff1..f4c32a9 100644 --- a/dbus_service.py +++ b/dbus_service.py @@ -151,13 +151,25 @@ def update(self, lp: Loadpoint) -> None: s["/Connected"] = 1 # chargeTotalImport is EVCC's cumulative kWh meter value. It keeps - # increasing for continuous heating devices where chargedEnergy is - # a per-session counter and may stay at 0. + # increasing for IntegratedDevice loadpoints (heatpump, sgready- + # relay, …) where chargedEnergy stays at 0 because EVCC has no + # session semantics for "always connected" chargers. Fall back to + # chargedEnergy / 1000 when the meter value is missing. total_import = float(lp.charge_total_import or 0.0) if total_import > 0: - s["/Ac/Energy/Forward"] = total_import + candidate = total_import elif status != STATUS_DISCONNECTED: - s["/Ac/Energy/Forward"] = float(lp.charged_energy) / 1000.0 + candidate = float(lp.charged_energy) / 1000.0 + else: + candidate = None + + if candidate is not None: + # /Ac/Energy/Forward must be monotonic for VRM. Guard against + # transient source switches (e.g. EVCC briefly returning + # chargeTotalImport: null) rolling the counter backwards. + previous = float(s["/Ac/Energy/Forward"] or 0.0) + s["/Ac/Energy/Forward"] = max(candidate, previous) + if status != STATUS_DISCONNECTED: s["/ChargingTime"] = int(lp.charge_duration_ns) // 1_000_000_000 diff --git a/tests/test_dbus_service.py b/tests/test_dbus_service.py index 54c631b..f299d18 100644 --- a/tests/test_dbus_service.py +++ b/tests/test_dbus_service.py @@ -111,7 +111,8 @@ def test_update_connected_not_charging(monkeypatch): def test_update_charging_uses_per_phase_voltages(monkeypatch): svc, vedbus, _ = _make_svc(monkeypatch) - vedbus.__getitem__.return_value = 5 + # /UpdateIndex starts at 5 (wraps to 6); other paths are uninitialised. + vedbus.__getitem__.side_effect = lambda k: 5 if k == "/UpdateIndex" else 0 lp = Loadpoint( title="HeatingElement", connected=True, charging=True, mode="pv", charge_power=4500.0, @@ -154,6 +155,59 @@ def test_update_prefers_charge_total_import_for_cumulative_energy(monkeypatch): assert sets["/Ac/Energy/Forward"] == 19341.908 +def test_update_energy_forward_is_monotonic_across_source_switch(monkeypatch): + """EVCC may transiently return chargeTotalImport: null / 0 (e.g. on a + loadpoint without a meter). The fallback to chargedEnergy/1000 must not + roll /Ac/Energy/Forward backwards for VRM. + """ + svc, vedbus, _ = _make_svc(monkeypatch) + + state = {"/Ac/Energy/Forward": 0.0, "/UpdateIndex": 0} + vedbus.__getitem__.side_effect = lambda k: state.get(k, 0) + vedbus.__setitem__.side_effect = lambda k, v: state.__setitem__(k, v) + + lp_high = Loadpoint( + title="Heatpump", connected=True, charging=True, mode="pv", + charged_energy=0.0, charge_total_import=19341.908, + ) + svc.update(lp_high) + assert state["/Ac/Energy/Forward"] == 19341.908 + + # EVCC transiently drops the meter value; chargedEnergy is 0 on a heating LP + lp_drop = Loadpoint( + title="Heatpump", connected=True, charging=True, mode="pv", + charged_energy=0.0, charge_total_import=0.0, + ) + svc.update(lp_drop) + assert state["/Ac/Energy/Forward"] == 19341.908 # held, no regression + + # Meter value comes back and grows + lp_resume = Loadpoint( + title="Heatpump", connected=True, charging=True, mode="pv", + charged_energy=0.0, charge_total_import=19500.0, + ) + svc.update(lp_resume) + assert state["/Ac/Energy/Forward"] == 19500.0 + + +def test_update_disconnected_with_total_import_still_publishes_cumulative(monkeypatch): + """A disconnected loadpoint with a positive chargeTotalImport (e.g. an + EV that just unplugged) should still publish the lifetime counter so + VRM history doesn't stall on disconnect. /ChargingTime stays unpublished. + """ + svc, vedbus, _ = _make_svc(monkeypatch) + vedbus.__getitem__.return_value = 0 + lp = Loadpoint( + title="Carport", connected=False, charging=False, mode="pv", + charged_energy=0.0, charge_total_import=15303.908, + ) + svc.update(lp) + sets = dict(c.args for c in vedbus.__setitem__.call_args_list) + assert sets["/Status"] == STATUS_DISCONNECTED + assert sets["/Ac/Energy/Forward"] == 15303.908 + assert "/ChargingTime" not in sets + + def test_update_index_wraps_at_255(monkeypatch): svc, vedbus, _ = _make_svc(monkeypatch) vedbus.__getitem__.return_value = 255