diff --git a/dbus_service.py b/dbus_service.py index bb8855f..f4c32a9 100644 --- a/dbus_service.py +++ b/dbus_service.py @@ -150,11 +150,27 @@ 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. + # chargeTotalImport is EVCC's cumulative kWh meter value. It keeps + # 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: + candidate = total_import + elif status != STATUS_DISCONNECTED: + 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["/Ac/Energy/Forward"] = float(lp.charged_energy) / 1000.0 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..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, @@ -132,13 +133,81 @@ 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_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 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