Skip to content

Commit 61811c7

Browse files
authored
Merge pull request #1 from lbbrhzn/main
Added detection between session and main meter energy values (lbbrhzn#919)
2 parents a1b1b89 + 6616437 commit 61811c7

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
@@ -1103,6 +1110,30 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
11031110

11041111
transaction_id: int = kwargs.get(om.transaction_id.name, 0)
11051112

1113+
# If missing meter_start or active_transaction_id try to restore from HA states. If HA
1114+
# does not have values either, generate new ones.
1115+
if self._metrics[csess.meter_start.value].value is None:
1116+
value = self.get_ha_metric(csess.meter_start.value)
1117+
if value is None:
1118+
value = self._metrics[DEFAULT_MEASURAND].value
1119+
else:
1120+
value = float(value)
1121+
_LOGGER.debug(
1122+
f"{csess.meter_start.value} was None, restored value={value} from HA."
1123+
)
1124+
self._metrics[csess.meter_start.value].value = value
1125+
if self._metrics[csess.transaction_id.value].value is None:
1126+
value = self.get_ha_metric(csess.transaction_id.value)
1127+
if value is None:
1128+
value = kwargs.get(om.transaction_id.name)
1129+
else:
1130+
value = int(value)
1131+
_LOGGER.debug(
1132+
f"{csess.transaction_id.value} was None, restored value={value} from HA."
1133+
)
1134+
self._metrics[csess.transaction_id.value].value = value
1135+
self.active_transaction_id = value
1136+
11061137
transaction_matches: bool = False
11071138
# match is also false if no transaction is in progress ie active_transaction_id==transaction_id==0
11081139
if transaction_id == self.active_transaction_id and transaction_id != 0:
@@ -1129,8 +1160,25 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
11291160
if unit == DEFAULT_POWER_UNIT:
11301161
self._metrics[measurand].value = float(value) / 1000
11311162
self._metrics[measurand].unit = HA_POWER_UNIT
1132-
elif unit == DEFAULT_ENERGY_UNIT:
1133-
if transaction_matches:
1163+
elif unit == DEFAULT_ENERGY_UNIT or "Energy" in str(measurand):
1164+
if self._metrics[csess.meter_start.value].value == 0:
1165+
# Charger reports Energy.Active.Import.Register directly as Session energy for transactions
1166+
self._charger_reports_session_energy = True
1167+
if (
1168+
transaction_matches
1169+
and self._charger_reports_session_energy
1170+
and measurand == DEFAULT_MEASURAND
1171+
and connector_id
1172+
):
1173+
self._metrics[csess.session_energy.value].value = (
1174+
float(value) / 1000
1175+
)
1176+
self._metrics[csess.session_energy.value].extra_attr[
1177+
cstat.id_tag.name
1178+
] = self._metrics[cstat.id_tag.value].value
1179+
elif (
1180+
transaction_matches or self._charger_reports_session_energy
1181+
):
11341182
self._metrics[measurand].value = float(value) / 1000
11351183
self._metrics[measurand].unit = HA_ENERGY_UNIT
11361184
else:
@@ -1148,15 +1196,6 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
11481196
# _LOGGER.debug("Meter data not yet processed: %s", unprocessed)
11491197
if unprocessed is not None:
11501198
self.process_phases(unprocessed)
1151-
if csess.meter_start.value not in self._metrics:
1152-
self._metrics[csess.meter_start.value].value = self._metrics[
1153-
DEFAULT_MEASURAND
1154-
]
1155-
if csess.transaction_id.value not in self._metrics:
1156-
self._metrics[csess.transaction_id.value].value = kwargs.get(
1157-
om.transaction_id.name
1158-
)
1159-
self.active_transaction_id = kwargs.get(om.transaction_id.name)
11601199
if transaction_matches:
11611200
self._metrics[csess.session_time.value].value = round(
11621201
(
@@ -1166,7 +1205,10 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
11661205
/ 60
11671206
)
11681207
self._metrics[csess.session_time.value].unit = "min"
1169-
if self._metrics[csess.meter_start.value].value is not None:
1208+
if (
1209+
self._metrics[csess.meter_start.value].value is not None
1210+
and not self._charger_reports_session_energy
1211+
):
11701212
self._metrics[csess.session_energy.value].value = float(
11711213
self._metrics[DEFAULT_MEASURAND].value or 0
11721214
) - float(self._metrics[csess.meter_start.value].value)
@@ -1343,7 +1385,10 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs):
13431385
)
13441386
self.active_transaction_id = 0
13451387
self._metrics[cstat.stop_reason.value].value = kwargs.get(om.reason.name, None)
1346-
if self._metrics[csess.meter_start.value].value is not None:
1388+
if (
1389+
self._metrics[csess.meter_start.value].value is not None
1390+
and not self._charger_reports_session_energy
1391+
):
13471392
self._metrics[csess.session_energy.value].value = int(
13481393
meter_stop
13491394
) / 1000 - float(self._metrics[csess.meter_start.value].value)
@@ -1391,6 +1436,20 @@ def get_metric(self, measurand: str):
13911436
"""Return last known value for given measurand."""
13921437
return self._metrics[measurand].value
13931438

1439+
def get_ha_metric(self, measurand: str):
1440+
"""Return last known value in HA for given measurand."""
1441+
entity_id = "sensor." + "_".join(
1442+
[self.central.cpid.lower(), measurand.lower().replace(".", "_")]
1443+
)
1444+
try:
1445+
value = self.hass.states.get(entity_id).state
1446+
except Exception as e:
1447+
_LOGGER.debug(f"An error occurred when getting entity state from HA: {e}")
1448+
return None
1449+
if value == STATE_UNAVAILABLE or value == STATE_UNKNOWN:
1450+
return None
1451+
return value
1452+
13941453
def get_extra_attr(self, measurand: str):
13951454
"""Return last known extra attributes for given measurand."""
13961455
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)