Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions dbus_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions evcc_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/evcc_state_1lp.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"chargeVoltages": [230, 230, 230],
"effectiveMaxCurrent": 20,
"chargedEnergy": 250.0,
"chargeTotalImport": 12.345,
"chargeDuration": 600000000000
}
]
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/evcc_state_3lp.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"chargeVoltages": [230, 230, 230],
"effectiveMaxCurrent": 16,
"chargedEnergy": 0.0,
"chargeTotalImport": 1.234,
"chargeDuration": 0
},
{
Expand All @@ -22,6 +23,7 @@
"chargeVoltages": [230, 230, 230],
"effectiveMaxCurrent": 20,
"chargedEnergy": 1800.0,
"chargeTotalImport": 98.765,
"chargeDuration": 3600000000000
},
{
Expand All @@ -34,6 +36,7 @@
"chargeVoltages": [230, 230, 230],
"effectiveMaxCurrent": 16,
"chargedEnergy": 0.0,
"chargeTotalImport": 19341.908,
"chargeDuration": 0
}
]
Expand Down
75 changes: 72 additions & 3 deletions tests/test_dbus_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/test_evcc_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand Down