Skip to content

Commit 6616437

Browse files
authored
Added detection between session and main meter energy values (lbbrhzn#919)
* Added better support for chargers not using errata 3.9 * Added requirement jsonschema==4.19.0 * Increase timeout to 60sec for test_cms_responses * Restore session energy on HA restart
1 parent b2a7f75 commit 6616437

File tree

3 files changed

+201
-17
lines changed

3 files changed

+201
-17
lines changed

custom_components/ocpp/api.py

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN
1313
from homeassistant.config_entries import ConfigEntry
14-
from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, TIME_MINUTES
14+
from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN, TIME_MINUTES
1515
from homeassistant.core import HomeAssistant
1616
from homeassistant.helpers import device_registry, entity_component, entity_registry
1717
import homeassistant.helpers.config_validation as cv
@@ -245,6 +245,12 @@ def get_metric(self, cp_id: str, measurand: str):
245245
return self.charge_points[cp_id]._metrics[measurand].value
246246
return None
247247

248+
def del_metric(self, cp_id: str, measurand: str):
249+
"""Set given measurand to None."""
250+
if cp_id in self.charge_points:
251+
self.charge_points[cp_id]._metrics[measurand].value = None
252+
return None
253+
248254
def get_unit(self, cp_id: str, measurand: str):
249255
"""Return unit of given measurand."""
250256
if cp_id in self.charge_points:
@@ -353,6 +359,7 @@ def __init__(
353359
self.received_boot_notification = False
354360
self.post_connect_success = False
355361
self.tasks = None
362+
self._charger_reports_session_energy = False
356363
self._metrics = defaultdict(lambda: Metric(None, None))
357364
self._metrics[cdet.identifier.value].value = id
358365
self._metrics[csess.session_time.value].unit = TIME_MINUTES
@@ -1073,6 +1080,30 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
10731080

10741081
transaction_id: int = kwargs.get(om.transaction_id.name, 0)
10751082

1083+
# If missing meter_start or active_transaction_id try to restore from HA states. If HA
1084+
# does not have values either, generate new ones.
1085+
if self._metrics[csess.meter_start.value].value is None:
1086+
value = self.get_ha_metric(csess.meter_start.value)
1087+
if value is None:
1088+
value = self._metrics[DEFAULT_MEASURAND].value
1089+
else:
1090+
value = float(value)
1091+
_LOGGER.debug(
1092+
f"{csess.meter_start.value} was None, restored value={value} from HA."
1093+
)
1094+
self._metrics[csess.meter_start.value].value = value
1095+
if self._metrics[csess.transaction_id.value].value is None:
1096+
value = self.get_ha_metric(csess.transaction_id.value)
1097+
if value is None:
1098+
value = kwargs.get(om.transaction_id.name)
1099+
else:
1100+
value = int(value)
1101+
_LOGGER.debug(
1102+
f"{csess.transaction_id.value} was None, restored value={value} from HA."
1103+
)
1104+
self._metrics[csess.transaction_id.value].value = value
1105+
self.active_transaction_id = value
1106+
10761107
transaction_matches: bool = False
10771108
# match is also false if no transaction is in progress ie active_transaction_id==transaction_id==0
10781109
if transaction_id == self.active_transaction_id and transaction_id != 0:
@@ -1099,8 +1130,25 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
10991130
if unit == DEFAULT_POWER_UNIT:
11001131
self._metrics[measurand].value = float(value) / 1000
11011132
self._metrics[measurand].unit = HA_POWER_UNIT
1102-
elif unit == DEFAULT_ENERGY_UNIT:
1103-
if transaction_matches:
1133+
elif unit == DEFAULT_ENERGY_UNIT or "Energy" in str(measurand):
1134+
if self._metrics[csess.meter_start.value].value == 0:
1135+
# Charger reports Energy.Active.Import.Register directly as Session energy for transactions
1136+
self._charger_reports_session_energy = True
1137+
if (
1138+
transaction_matches
1139+
and self._charger_reports_session_energy
1140+
and measurand == DEFAULT_MEASURAND
1141+
and connector_id
1142+
):
1143+
self._metrics[csess.session_energy.value].value = (
1144+
float(value) / 1000
1145+
)
1146+
self._metrics[csess.session_energy.value].extra_attr[
1147+
cstat.id_tag.name
1148+
] = self._metrics[cstat.id_tag.value].value
1149+
elif (
1150+
transaction_matches or self._charger_reports_session_energy
1151+
):
11041152
self._metrics[measurand].value = float(value) / 1000
11051153
self._metrics[measurand].unit = HA_ENERGY_UNIT
11061154
else:
@@ -1118,15 +1166,6 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
11181166
# _LOGGER.debug("Meter data not yet processed: %s", unprocessed)
11191167
if unprocessed is not None:
11201168
self.process_phases(unprocessed)
1121-
if csess.meter_start.value not in self._metrics:
1122-
self._metrics[csess.meter_start.value].value = self._metrics[
1123-
DEFAULT_MEASURAND
1124-
]
1125-
if csess.transaction_id.value not in self._metrics:
1126-
self._metrics[csess.transaction_id.value].value = kwargs.get(
1127-
om.transaction_id.name
1128-
)
1129-
self.active_transaction_id = kwargs.get(om.transaction_id.name)
11301169
if transaction_matches:
11311170
self._metrics[csess.session_time.value].value = round(
11321171
(
@@ -1136,7 +1175,10 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
11361175
/ 60
11371176
)
11381177
self._metrics[csess.session_time.value].unit = "min"
1139-
if self._metrics[csess.meter_start.value].value is not None:
1178+
if (
1179+
self._metrics[csess.meter_start.value].value is not None
1180+
and not self._charger_reports_session_energy
1181+
):
11401182
self._metrics[csess.session_energy.value].value = float(
11411183
self._metrics[DEFAULT_MEASURAND].value or 0
11421184
) - float(self._metrics[csess.meter_start.value].value)
@@ -1313,7 +1355,10 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs):
13131355
)
13141356
self.active_transaction_id = 0
13151357
self._metrics[cstat.stop_reason.value].value = kwargs.get(om.reason.name, None)
1316-
if self._metrics[csess.meter_start.value].value is not None:
1358+
if (
1359+
self._metrics[csess.meter_start.value].value is not None
1360+
and not self._charger_reports_session_energy
1361+
):
13171362
self._metrics[csess.session_energy.value].value = int(
13181363
meter_stop
13191364
) / 1000 - float(self._metrics[csess.meter_start.value].value)
@@ -1361,6 +1406,20 @@ def get_metric(self, measurand: str):
13611406
"""Return last known value for given measurand."""
13621407
return self._metrics[measurand].value
13631408

1409+
def get_ha_metric(self, measurand: str):
1410+
"""Return last known value in HA for given measurand."""
1411+
entity_id = "sensor." + "_".join(
1412+
[self.central.cpid.lower(), measurand.lower().replace(".", "_")]
1413+
)
1414+
try:
1415+
value = self.hass.states.get(entity_id).state
1416+
except Exception as e:
1417+
_LOGGER.debug(f"An error occurred when getting entity state from HA: {e}")
1418+
return None
1419+
if value == STATE_UNAVAILABLE or value == STATE_UNKNOWN:
1420+
return None
1421+
return value
1422+
13641423
def get_extra_attr(self, measurand: str):
13651424
"""Return last known extra attributes for given measurand."""
13661425
return self._metrics[measurand].extra_attr

requirements_dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
homeassistant>=2023.1.0b1
22
ocpp==0.19.0
33
websockets==11.0.3
4+
jsonschema==4.19.0

tests/test_charge_point.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
SERVICE_TURN_ON,
1212
)
1313
from homeassistant.const import ATTR_ENTITY_ID
14+
import pytest
1415
from pytest_homeassistant_custom_component.common import MockConfigEntry
1516
import websockets
1617

@@ -44,6 +45,7 @@
4445
from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_2
4546

4647

48+
@pytest.mark.timeout(60) # Set timeout to 60 seconds for this test
4749
async def test_cms_responses(hass, socket_enabled):
4850
"""Test central system responses to a charger."""
4951

@@ -227,6 +229,63 @@ async def test_services(hass, socket_enabled):
227229

228230
await asyncio.sleep(1)
229231

232+
# test restore feature of meter_start and active_tranasction_id.
233+
async with websockets.connect(
234+
"ws://127.0.0.1:9000/CP_1_res_vals",
235+
subprotocols=["ocpp1.6"],
236+
) as ws:
237+
# use a different id for debugging
238+
cp = ChargePoint("CP_1_restore_values", ws)
239+
cp.active_transactionId = None
240+
# send None values
241+
try:
242+
await asyncio.wait_for(
243+
asyncio.gather(
244+
cp.start(),
245+
cp.send_meter_periodic_data(),
246+
),
247+
timeout=5,
248+
)
249+
except asyncio.TimeoutError:
250+
pass
251+
# check if None
252+
assert cs.get_metric("test_cpid", "Energy.Meter.Start") is None
253+
assert cs.get_metric("test_cpid", "Transaction.Id") is None
254+
# send new data
255+
try:
256+
await asyncio.wait_for(
257+
asyncio.gather(
258+
cp.send_start_transaction(12344),
259+
cp.send_meter_periodic_data(),
260+
),
261+
timeout=5,
262+
)
263+
except asyncio.TimeoutError:
264+
pass
265+
# save for reference the values for meter_start and transaction_id
266+
saved_meter_start = int(cs.get_metric("test_cpid", "Energy.Meter.Start"))
267+
saved_transactionId = int(cs.get_metric("test_cpid", "Transaction.Id"))
268+
# delete current values from api memory
269+
cs.del_metric("test_cpid", "Energy.Meter.Start")
270+
cs.del_metric("test_cpid", "Transaction.Id")
271+
# send new data
272+
try:
273+
await asyncio.wait_for(
274+
asyncio.gather(
275+
cp.send_meter_periodic_data(),
276+
),
277+
timeout=5,
278+
)
279+
except asyncio.TimeoutError:
280+
pass
281+
await ws.close()
282+
283+
# check if restored old values from HA when api have lost the values, i.e. simulated reboot of HA
284+
assert int(cs.get_metric("test_cpid", "Energy.Meter.Start")) == saved_meter_start
285+
assert int(cs.get_metric("test_cpid", "Transaction.Id")) == saved_transactionId
286+
287+
await asyncio.sleep(1)
288+
230289
# test ocpp messages sent from charger to cms
231290
async with websockets.connect(
232291
"ws://127.0.0.1:9000/CP_1_norm",
@@ -245,10 +304,11 @@ async def test_services(hass, socket_enabled):
245304
cp.send_security_event(),
246305
cp.send_firmware_status(),
247306
cp.send_data_transfer(),
248-
cp.send_start_transaction(),
307+
cp.send_start_transaction(12345),
249308
cp.send_meter_err_phases(),
250309
cp.send_meter_line_voltage(),
251310
cp.send_meter_periodic_data(),
311+
cp.send_main_meter_clock_data(),
252312
# add delay to allow meter data to be processed
253313
cp.send_stop_transaction(2),
254314
),
@@ -260,6 +320,9 @@ async def test_services(hass, socket_enabled):
260320
assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int(
261321
1305570 / 1000
262322
)
323+
assert int(cs.get_metric("test_cpid", "Energy.Session")) == int(
324+
(54321 - 12345) / 1000
325+
)
263326
assert int(cs.get_metric("test_cpid", "Current.Import")) == int(0)
264327
assert int(cs.get_metric("test_cpid", "Voltage")) == int(228)
265328
assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh"
@@ -310,6 +373,43 @@ async def test_services(hass, socket_enabled):
310373

311374
await asyncio.sleep(1)
312375

376+
# test ocpp messages sent from charger that don't support errata 3.9
377+
# i.e. "Energy.Meter.Start" starts from 0 for each session and "Energy.Active.Import.Register"
378+
# reports starting from 0 Wh for every new transaction id. Total main meter values are without transaction id.
379+
async with websockets.connect(
380+
"ws://127.0.0.1:9000/CP_1_non_er_3.9",
381+
subprotocols=["ocpp1.6"],
382+
) as ws:
383+
# use a different id for debugging
384+
cp = ChargePoint("CP_1_non_errata_3.9", ws)
385+
try:
386+
await asyncio.wait_for(
387+
asyncio.gather(
388+
cp.start(),
389+
cp.send_start_transaction(0),
390+
cp.send_meter_periodic_data(),
391+
cp.send_main_meter_clock_data(),
392+
# add delay to allow meter data to be processed
393+
cp.send_stop_transaction(2),
394+
),
395+
timeout=5,
396+
)
397+
except asyncio.TimeoutError:
398+
pass
399+
await ws.close()
400+
401+
# Last sent "Energy.Active.Import.Register" value without transaction id should be here.
402+
assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int(
403+
67230012 / 1000
404+
)
405+
assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh"
406+
407+
# Last sent "Energy.Active.Import.Register" value with transaction id should be here.
408+
assert int(cs.get_metric("test_cpid", "Energy.Session")) == int(1305570 / 1000)
409+
assert cs.get_unit("test_cpid", "Energy.Session") == "kWh"
410+
411+
await asyncio.sleep(1)
412+
313413
# test ocpp rejection messages sent from charger to cms
314414
cs.charge_points["test_cpid"].received_boot_notification = False
315415
cs.charge_points["test_cpid"].post_connect_success = False
@@ -604,12 +704,12 @@ async def send_data_transfer(self):
604704
resp = await self.call(request)
605705
assert resp.status == DataTransferStatus.accepted
606706

607-
async def send_start_transaction(self):
707+
async def send_start_transaction(self, meter_start: int = 12345):
608708
"""Send a start transaction notification."""
609709
request = call.StartTransactionPayload(
610710
connector_id=1,
611711
id_tag="test_cp",
612-
meter_start=12345,
712+
meter_start=meter_start,
613713
timestamp=datetime.now(tz=timezone.utc).isoformat(),
614714
)
615715
resp = await self.call(request)
@@ -871,6 +971,30 @@ async def send_meter_err_phases(self):
871971
resp = await self.call(request)
872972
assert resp is not None
873973

974+
async def send_main_meter_clock_data(self):
975+
"""Send periodic main meter value. Main meter values dont have transaction_id."""
976+
while self.active_transactionId == 0:
977+
await asyncio.sleep(1)
978+
request = call.MeterValuesPayload(
979+
connector_id=1,
980+
meter_value=[
981+
{
982+
"timestamp": "2021-06-21T16:15:09Z",
983+
"sampledValue": [
984+
{
985+
"value": "67230012",
986+
"context": "Sample.Clock",
987+
"format": "Raw",
988+
"measurand": "Energy.Active.Import.Register",
989+
"location": "Inlet",
990+
},
991+
],
992+
}
993+
],
994+
)
995+
resp = await self.call(request)
996+
assert resp is not None
997+
874998
async def send_meter_clock_data(self):
875999
"""Send periodic meter data notification."""
8761000
self.active_transactionId = 0

0 commit comments

Comments
 (0)