Skip to content

Commit a5efde8

Browse files
fbettagFranz Bettag
andauthored
Use chargeTotalImport for /Ac/Energy/Forward with monotonic guard
Prefer EVCC chargeTotalImport (kWh meter) over chargedEnergy for /Ac/Energy/Forward — required for IntegratedDevice loadpoints (heat pump, sgready-relay, …) where chargedEnergy stays 0. Guarded with max(candidate, previous) so transient EVCC drops (chargeTotalImport: null) cannot roll the VRM counter backwards. Cumulative counter also published when disconnected. Tests: source-switch, disconnected+total_import. Co-authored-by: Franz Bettag <franz@bettag.net>
1 parent 0cc90e5 commit a5efde8

6 files changed

Lines changed: 100 additions & 7 deletions

File tree

‎dbus_service.py‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,27 @@ def update(self, lp: Loadpoint) -> None:
150150
s["/Status"] = status
151151
s["/Connected"] = 1
152152

153-
# Cumulative counters only refreshed while connected. UNVERIFIED:
154-
# chargedEnergy is treated as Wh (-> /1000 = kWh). Must be confirmed
155-
# against a real EVCC /api/state sample before deploy.
153+
# chargeTotalImport is EVCC's cumulative kWh meter value. It keeps
154+
# increasing for IntegratedDevice loadpoints (heatpump, sgready-
155+
# relay, …) where chargedEnergy stays at 0 because EVCC has no
156+
# session semantics for "always connected" chargers. Fall back to
157+
# chargedEnergy / 1000 when the meter value is missing.
158+
total_import = float(lp.charge_total_import or 0.0)
159+
if total_import > 0:
160+
candidate = total_import
161+
elif status != STATUS_DISCONNECTED:
162+
candidate = float(lp.charged_energy) / 1000.0
163+
else:
164+
candidate = None
165+
166+
if candidate is not None:
167+
# /Ac/Energy/Forward must be monotonic for VRM. Guard against
168+
# transient source switches (e.g. EVCC briefly returning
169+
# chargeTotalImport: null) rolling the counter backwards.
170+
previous = float(s["/Ac/Energy/Forward"] or 0.0)
171+
s["/Ac/Energy/Forward"] = max(candidate, previous)
172+
156173
if status != STATUS_DISCONNECTED:
157-
s["/Ac/Energy/Forward"] = float(lp.charged_energy) / 1000.0
158174
s["/ChargingTime"] = int(lp.charge_duration_ns) // 1_000_000_000
159175

160176
idx = (int(s["/UpdateIndex"]) + 1) % 256

‎evcc_api.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Loadpoint:
2626
charge_voltages: List[float] = field(default_factory=lambda: [230.0, 230.0, 230.0])
2727
effective_max_current: int = 16
2828
charged_energy: float = 0.0
29+
charge_total_import: float = 0.0
2930
charge_duration_ns: int = 0
3031

3132

@@ -61,6 +62,7 @@ def _parse_loadpoints(state: dict) -> List[Loadpoint]:
6162
charge_voltages=_normalize_triple(raw.get("chargeVoltages"), 230.0),
6263
effective_max_current=int(raw.get("effectiveMaxCurrent") or 16),
6364
charged_energy=float(raw.get("chargedEnergy") or 0.0),
65+
charge_total_import=float(raw.get("chargeTotalImport") or 0.0),
6466
charge_duration_ns=int(raw.get("chargeDuration") or 0),
6567
))
6668
return out

‎tests/fixtures/evcc_state_1lp.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"chargeVoltages": [230, 230, 230],
1111
"effectiveMaxCurrent": 20,
1212
"chargedEnergy": 250.0,
13+
"chargeTotalImport": 12.345,
1314
"chargeDuration": 600000000000
1415
}
1516
]

‎tests/fixtures/evcc_state_3lp.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"chargeVoltages": [230, 230, 230],
1111
"effectiveMaxCurrent": 16,
1212
"chargedEnergy": 0.0,
13+
"chargeTotalImport": 1.234,
1314
"chargeDuration": 0
1415
},
1516
{
@@ -22,6 +23,7 @@
2223
"chargeVoltages": [230, 230, 230],
2324
"effectiveMaxCurrent": 20,
2425
"chargedEnergy": 1800.0,
26+
"chargeTotalImport": 98.765,
2527
"chargeDuration": 3600000000000
2628
},
2729
{
@@ -34,6 +36,7 @@
3436
"chargeVoltages": [230, 230, 230],
3537
"effectiveMaxCurrent": 16,
3638
"chargedEnergy": 0.0,
39+
"chargeTotalImport": 19341.908,
3740
"chargeDuration": 0
3841
}
3942
]

‎tests/test_dbus_service.py‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ def test_update_connected_not_charging(monkeypatch):
111111

112112
def test_update_charging_uses_per_phase_voltages(monkeypatch):
113113
svc, vedbus, _ = _make_svc(monkeypatch)
114-
vedbus.__getitem__.return_value = 5
114+
# /UpdateIndex starts at 5 (wraps to 6); other paths are uninitialised.
115+
vedbus.__getitem__.side_effect = lambda k: 5 if k == "/UpdateIndex" else 0
115116
lp = Loadpoint(
116117
title="HeatingElement", connected=True, charging=True, mode="pv",
117118
charge_power=4500.0,
@@ -132,13 +133,81 @@ def test_update_charging_uses_per_phase_voltages(monkeypatch):
132133
assert sets["/Current"] == 19.5
133134
assert sets["/MaxCurrent"] == 20
134135
assert sets["/Mode"] == MODE_AUTO
135-
# UNVERIFIED unit: chargedEnergy treated as Wh; must confirm against
136-
# a live EVCC /api/state sample before merging the deploy.
136+
# Fallback for EVCC payloads without chargeTotalImport.
137137
assert sets["/Ac/Energy/Forward"] == 1.8
138138
assert sets["/ChargingTime"] == 3600
139139
assert sets["/UpdateIndex"] == 6
140140

141141

142+
def test_update_prefers_charge_total_import_for_cumulative_energy(monkeypatch):
143+
svc, vedbus, _ = _make_svc(monkeypatch)
144+
vedbus.__getitem__.return_value = 0
145+
lp = Loadpoint(
146+
title="Heatpump", connected=True, charging=False, mode="off",
147+
charge_power=90.25,
148+
charge_currents=[0.93, 0.49, 0.5],
149+
charge_voltages=[237.5, 236.9, 236.9],
150+
charged_energy=0.0,
151+
charge_total_import=19341.908,
152+
)
153+
svc.update(lp)
154+
sets = dict(c.args for c in vedbus.__setitem__.call_args_list)
155+
assert sets["/Ac/Energy/Forward"] == 19341.908
156+
157+
158+
def test_update_energy_forward_is_monotonic_across_source_switch(monkeypatch):
159+
"""EVCC may transiently return chargeTotalImport: null / 0 (e.g. on a
160+
loadpoint without a meter). The fallback to chargedEnergy/1000 must not
161+
roll /Ac/Energy/Forward backwards for VRM.
162+
"""
163+
svc, vedbus, _ = _make_svc(monkeypatch)
164+
165+
state = {"/Ac/Energy/Forward": 0.0, "/UpdateIndex": 0}
166+
vedbus.__getitem__.side_effect = lambda k: state.get(k, 0)
167+
vedbus.__setitem__.side_effect = lambda k, v: state.__setitem__(k, v)
168+
169+
lp_high = Loadpoint(
170+
title="Heatpump", connected=True, charging=True, mode="pv",
171+
charged_energy=0.0, charge_total_import=19341.908,
172+
)
173+
svc.update(lp_high)
174+
assert state["/Ac/Energy/Forward"] == 19341.908
175+
176+
# EVCC transiently drops the meter value; chargedEnergy is 0 on a heating LP
177+
lp_drop = Loadpoint(
178+
title="Heatpump", connected=True, charging=True, mode="pv",
179+
charged_energy=0.0, charge_total_import=0.0,
180+
)
181+
svc.update(lp_drop)
182+
assert state["/Ac/Energy/Forward"] == 19341.908 # held, no regression
183+
184+
# Meter value comes back and grows
185+
lp_resume = Loadpoint(
186+
title="Heatpump", connected=True, charging=True, mode="pv",
187+
charged_energy=0.0, charge_total_import=19500.0,
188+
)
189+
svc.update(lp_resume)
190+
assert state["/Ac/Energy/Forward"] == 19500.0
191+
192+
193+
def test_update_disconnected_with_total_import_still_publishes_cumulative(monkeypatch):
194+
"""A disconnected loadpoint with a positive chargeTotalImport (e.g. an
195+
EV that just unplugged) should still publish the lifetime counter so
196+
VRM history doesn't stall on disconnect. /ChargingTime stays unpublished.
197+
"""
198+
svc, vedbus, _ = _make_svc(monkeypatch)
199+
vedbus.__getitem__.return_value = 0
200+
lp = Loadpoint(
201+
title="Carport", connected=False, charging=False, mode="pv",
202+
charged_energy=0.0, charge_total_import=15303.908,
203+
)
204+
svc.update(lp)
205+
sets = dict(c.args for c in vedbus.__setitem__.call_args_list)
206+
assert sets["/Status"] == STATUS_DISCONNECTED
207+
assert sets["/Ac/Energy/Forward"] == 15303.908
208+
assert "/ChargingTime" not in sets
209+
210+
142211
def test_update_index_wraps_at_255(monkeypatch):
143212
svc, vedbus, _ = _make_svc(monkeypatch)
144213
vedbus.__getitem__.return_value = 255

‎tests/test_evcc_api.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def test_fetch_state_returns_loadpoints_1lp(requests_mock):
3030
assert lp.charge_currents == [6.5, 0, 0]
3131
assert lp.effective_max_current == 20
3232
assert lp.mode == "pv"
33+
assert lp.charge_total_import == 12.345
3334

3435

3536
def test_fetch_state_returns_loadpoints_3lp(requests_mock):
@@ -90,6 +91,7 @@ def test_missing_optional_fields_use_defaults():
9091
assert lps[0].charge_currents == [0, 0, 0]
9192
assert lps[0].connected is False
9293
assert lps[0].charge_power == 0.0
94+
assert lps[0].charge_total_import == 0.0
9395
assert lps[0].effective_max_current == 16
9496

9597

0 commit comments

Comments
 (0)