From 8ce02dd34879c4f66cc09c395ebbdeb81e7c15f2 Mon Sep 17 00:00:00 2001 From: Frank <639906+syphernl@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:20:16 +0200 Subject: [PATCH] Read the Peblar meter history --- homeassistant/components/peblar/icons.json | 3 + homeassistant/components/peblar/services.py | 88 +++++++++- homeassistant/components/peblar/services.yaml | 14 ++ homeassistant/components/peblar/strings.json | 18 ++ tests/components/peblar/conftest.py | 4 + .../peblar/fixtures/meter_history.json | 32 ++++ tests/components/peblar/test_services.py | 154 +++++++++++++++++- 7 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 tests/components/peblar/fixtures/meter_history.json diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index ebd560158eddd..48a9573087ae1 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -97,6 +97,9 @@ "delete_vehicle_token": { "service": "mdi:car-off" }, + "get_meter_history": { + "service": "mdi:history" + }, "list_rfid_tokens": { "service": "mdi:card-account-details" }, diff --git a/homeassistant/components/peblar/services.py b/homeassistant/components/peblar/services.py index 827f3e12cf5a5..9b61c4385434c 100644 --- a/homeassistant/components/peblar/services.py +++ b/homeassistant/components/peblar/services.py @@ -12,7 +12,13 @@ ) import voluptuous as vol -from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_ALIAS, CONF_DESCRIPTION +from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, + CONF_AFTER, + CONF_ALIAS, + CONF_BEFORE, + CONF_DESCRIPTION, +) from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -26,6 +32,8 @@ async_get_config_entry, async_register_admin_service, ) +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonValueType from .const import CONF_EVCC_ID, CONF_UID, DOMAIN from .coordinator import PeblarConfigEntry @@ -35,11 +43,19 @@ SERVICE_ADD_VEHICLE_TOKEN = "add_vehicle_token" SERVICE_DELETE_RFID_TOKEN = "delete_rfid_token" SERVICE_DELETE_VEHICLE_TOKEN = "delete_vehicle_token" +SERVICE_GET_METER_HISTORY = "get_meter_history" SERVICE_LIST_RFID_TOKENS = "list_rfid_tokens" SERVICE_LIST_VEHICLE_TOKENS = "list_vehicle_tokens" CHARGER_SCHEMA = vol.Schema({vol.Required(ATTR_CONFIG_ENTRY_ID): str}) +METER_HISTORY_SCHEMA = CHARGER_SCHEMA.extend( + { + vol.Optional(CONF_AFTER): cv.datetime, + vol.Optional(CONF_BEFORE): cv.datetime, + } +) + TOKEN_SCHEMA = CHARGER_SCHEMA.extend({vol.Required(CONF_UID): str}) ADD_TOKEN_SCHEMA = TOKEN_SCHEMA.extend({vol.Required(CONF_DESCRIPTION): str}) @@ -59,6 +75,12 @@ ) +def _get_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: + """Return the client for a charger, whatever hardware it carries.""" + entry: PeblarConfigEntry = async_get_config_entry(hass, DOMAIN, entry_id) + return entry.runtime_data.user_configuration_coordinator.peblar + + def _get_rfid_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: """Return the client, for a charger that has an RFID reader. @@ -213,6 +235,62 @@ async def _handle_authorize_charge_session(call: ServiceCall) -> None: name=call.data.get(CONF_DESCRIPTION), ) + async def _handle_get_meter_history(call: ServiceCall) -> ServiceResponse: + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + peblar = _get_peblar(hass, entry_id) + + # A naive moment is read by the charger as its own local time, which + # is not necessarily the one Home Assistant runs in. Both bounds are + # anchored here instead, the way the datetime platform does it. + after = call.data.get(CONF_AFTER) + before = call.data.get(CONF_BEFORE) + async with _handle_peblar_errors(hass, entry_id): + history = await peblar.meter_history( + start=None if after is None else dt_util.as_utc(after), + stop=None if before is None else dt_util.as_utc(before), + ) + + sessions: list[JsonValueType] = [] + for index, session in enumerate(history.session): + # The session that is running has no end yet, and so no energy + # total to report for it either. + end_energy_kwh = None + end_time = None + energy_kwh = None + if (end_energy := session.session_end_energy_mwh) is not None: + end_energy_kwh = end_energy / 1000000 + energy_kwh = (end_energy - session.session_start_energy_mwh) / 1000000 + if session.session_end_time is not None: + end_time = dt_util.utc_from_timestamp( + session.session_end_time + ).isoformat() + sessions.append( + { + "session_number": session.session_number, + CONF_UID: session.auth_token, + "start_time": dt_util.utc_from_timestamp( + session.session_start_time + ).isoformat(), + "end_time": end_time, + "start_energy_kwh": session.session_start_energy_mwh / 1000000, + "end_energy_kwh": end_energy_kwh, + "energy_kwh": energy_kwh, + "checksum": session.checksum, + # The charger checks each record against its own checksum + # and reports the outcomes as a list of their own. A + # charger that returns fewer of those than it returns + # sessions leaves the rest unanswered, rather than + # unreported. + "corrupted": ( + history.corrupted_session[index] + if index < len(history.corrupted_session) + else None + ), + } + ) + + return {"corrupted": history.corrupted, "sessions": sessions} + async def _handle_list_vehicle_tokens(call: ServiceCall) -> ServiceResponse: entry_id = call.data[ATTR_CONFIG_ENTRY_ID] peblar = _get_autocharge_peblar(hass, entry_id) @@ -291,3 +369,11 @@ async def _handle_delete_vehicle_token(call: ServiceCall) -> None: _handle_authorize_charge_session, schema=AUTHORIZE_SCHEMA, ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_GET_METER_HISTORY, + _handle_get_meter_history, + schema=METER_HISTORY_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/peblar/services.yaml b/homeassistant/components/peblar/services.yaml index 50b8f66afbff0..9b2a9e989b9fe 100644 --- a/homeassistant/components/peblar/services.yaml +++ b/homeassistant/components/peblar/services.yaml @@ -83,3 +83,17 @@ authorize_charge_session: description: selector: text: + +get_meter_history: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: peblar + after: + selector: + datetime: + before: + selector: + datetime: diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 5348725833155..67abe5fa258f2 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -308,6 +308,24 @@ }, "name": "Delete autocharge vehicle" }, + "get_meter_history": { + "description": "Returns the charging sessions the charger's certified meter has recorded, including the token each session was authorized with.", + "fields": { + "after": { + "description": "Only return sessions that started at or after this moment.", + "name": "After" + }, + "before": { + "description": "Only return sessions that started at or before this moment.", + "name": "Before" + }, + "config_entry_id": { + "description": "The Peblar EV charger to read the meter history from.", + "name": "Peblar EV charger" + } + }, + "name": "Get meter history" + }, "list_rfid_tokens": { "description": "Returns the RFID tokens configured in the charger's standalone authorization list.", "fields": { diff --git a/tests/components/peblar/conftest.py b/tests/components/peblar/conftest.py index a26cce1ae0888..80374f032c21a 100644 --- a/tests/components/peblar/conftest.py +++ b/tests/components/peblar/conftest.py @@ -8,6 +8,7 @@ from peblar import ( PeblarEVInterface, PeblarMeter, + PeblarMeterHistory, PeblarSystem, PeblarSystemInformation, PeblarUserConfiguration, @@ -80,6 +81,9 @@ def mock_peblar(request: pytest.FixtureRequest) -> Generator[MagicMock]: peblar.system_information.return_value = PeblarSystemInformation.from_dict( system_information ) + peblar.meter_history.return_value = PeblarMeterHistory.from_json( + load_fixture("meter_history.json", DOMAIN) + ) # The event stream parks here until the entry unloads, the way a # real one waits on the charger rather than returning. diff --git a/tests/components/peblar/fixtures/meter_history.json b/tests/components/peblar/fixtures/meter_history.json new file mode 100644 index 0000000000000..0055ce10efd78 --- /dev/null +++ b/tests/components/peblar/fixtures/meter_history.json @@ -0,0 +1,32 @@ +{ + "Corrupted": false, + "CorruptedSession": [true, false], + "MetaData": { + "MeterHash": "0ac1e2b3c4d5e6f7", + "MeterVersion": "1.0.0", + "MidCertified": true, + "ProductPn": "PBLR-0000", + "ProductSn": "23-45-A4O-MOF", + "TimeZone": "Europe/Amsterdam" + }, + "Session": [ + { + "AuthToken": "AA:BB:CC:DD", + "Checksum": 42, + "SessionNumber": 1, + "SessionStartEnergymWh": 1000000, + "SessionStartTime": 1769083200, + "SessionEndEnergymWh": 8500000, + "SessionEndTime": 1769108400 + }, + { + "AuthToken": null, + "Checksum": 43, + "SessionNumber": 2, + "SessionStartEnergymWh": 8500000, + "SessionStartTime": 1769169600, + "SessionEndEnergymWh": null, + "SessionEndTime": null + } + ] +} diff --git a/tests/components/peblar/test_services.py b/tests/components/peblar/test_services.py index 220d2187add2e..448742a6d5cd7 100644 --- a/tests/components/peblar/test_services.py +++ b/tests/components/peblar/test_services.py @@ -1,5 +1,6 @@ """Tests for the Peblar integration services.""" +from datetime import UTC, datetime from typing import Any from unittest.mock import MagicMock @@ -20,6 +21,7 @@ SERVICE_AUTHORIZE_CHARGE_SESSION, SERVICE_DELETE_RFID_TOKEN, SERVICE_DELETE_VEHICLE_TOKEN, + SERVICE_GET_METER_HISTORY, SERVICE_LIST_RFID_TOKENS, SERVICE_LIST_VEHICLE_TOKENS, ) @@ -170,8 +172,19 @@ async def test_unloaded_config_entry_raises( (SERVICE_DELETE_RFID_TOKEN, "delete_rfid_token", {"uid": "AA:BB:CC:DD"}), ] +# The services that answer over the same error handling, whether or not +# they touch the RFID list. +FAILING_SERVICE_CALLS: list[tuple[str, str, dict[str, Any]]] = [ + *SERVICE_CALLS, + (SERVICE_GET_METER_HISTORY, "meter_history", {}), +] -@pytest.mark.parametrize(("service", "method_name", "service_data"), SERVICE_CALLS) +RESPONDING_SERVICES = {SERVICE_LIST_RFID_TOKENS, SERVICE_GET_METER_HISTORY} + + +@pytest.mark.parametrize( + ("service", "method_name", "service_data"), FAILING_SERVICE_CALLS +) @pytest.mark.parametrize( ("error", "translation_key"), [ @@ -198,7 +211,7 @@ async def test_service_communication_error( service, {"config_entry_id": init_integration.entry_id, **service_data}, blocking=True, - return_response=service == "list_rfid_tokens", + return_response=service in RESPONDING_SERVICES, ) assert excinfo.value.translation_domain == DOMAIN @@ -206,7 +219,9 @@ async def test_service_communication_error( assert excinfo.value.translation_placeholders == {"error": str(error)} -@pytest.mark.parametrize(("service", "method_name", "service_data"), SERVICE_CALLS) +@pytest.mark.parametrize( + ("service", "method_name", "service_data"), FAILING_SERVICE_CALLS +) async def test_service_authentication_error( hass: HomeAssistant, mock_peblar: MagicMock, @@ -227,7 +242,7 @@ async def test_service_authentication_error( service, {"config_entry_id": init_integration.entry_id, **service_data}, blocking=True, - return_response=service == "list_rfid_tokens", + return_response=service in RESPONDING_SERVICES, ) assert excinfo.value.translation_domain == DOMAIN @@ -502,3 +517,134 @@ async def test_authorize_charge_session_is_refused( assert excinfo.value.translation_domain == DOMAIN assert excinfo.value.translation_key == translation_key mock_peblar.rest_api.return_value.authorize_charge_session.assert_not_called() + + +async def test_get_meter_history( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test the meter history comes back as sessions. + + The second session in the fixture is the one still running: it has no + end, and so no energy total to report for it either. + """ + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_METER_HISTORY, + {"config_entry_id": init_integration.entry_id}, + blocking=True, + return_response=True, + ) + + assert result == { + "corrupted": False, + "sessions": [ + { + "session_number": 1, + "uid": "AA:BB:CC:DD", + "start_time": "2026-01-22T12:00:00+00:00", + "end_time": "2026-01-22T19:00:00+00:00", + "start_energy_kwh": 1.0, + "end_energy_kwh": 8.5, + "energy_kwh": 7.5, + "checksum": 42, + "corrupted": True, + }, + { + "session_number": 2, + "uid": None, + "start_time": "2026-01-23T12:00:00+00:00", + "end_time": None, + "start_energy_kwh": 8.5, + "end_energy_kwh": None, + "energy_kwh": None, + "checksum": 43, + "corrupted": False, + }, + ], + } + mock_peblar.meter_history.assert_called_once_with(start=None, stop=None) + + +async def test_get_meter_history_without_every_checksum_outcome( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test a session the charger returned no checksum outcome for. + + The outcomes come back as a list of their own, so a charger that + returns fewer of them than it returns sessions leaves the last ones + unanswered. + """ + mock_peblar.meter_history.return_value.corrupted_session = [False] + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_METER_HISTORY, + {"config_entry_id": init_integration.entry_id}, + blocking=True, + return_response=True, + ) + + assert [session["corrupted"] for session in result["sessions"]] == [False, None] + + +@pytest.mark.parametrize("mock_peblar", [{"HwHasRfid": False}], indirect=True) +async def test_get_meter_history_needs_no_rfid_reader( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test the meter records every session, tokens or no tokens.""" + await hass.services.async_call( + DOMAIN, + SERVICE_GET_METER_HISTORY, + {"config_entry_id": init_integration.entry_id}, + blocking=True, + return_response=True, + ) + + mock_peblar.meter_history.assert_called_once_with(start=None, stop=None) + + +@pytest.mark.parametrize( + ("after", "before"), + [ + ("2026-01-22T13:00:00+01:00", "2026-01-23T13:00:00+01:00"), + ("2026-01-22 13:00:00", "2026-01-23 13:00:00"), + ], + ids=["with offset", "naive"], +) +async def test_get_meter_history_time_range( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, + after: str, + before: str, +) -> None: + """Test both ends of the range reach the charger, as UTC. + + A moment without an offset is the one the user typed into the + frontend, so it is read in Home Assistant's own timezone rather than + handed to the charger for it to interpret. + """ + await hass.config.async_set_time_zone("Europe/Amsterdam") + + await hass.services.async_call( + DOMAIN, + SERVICE_GET_METER_HISTORY, + { + "config_entry_id": init_integration.entry_id, + "after": after, + "before": before, + }, + blocking=True, + return_response=True, + ) + + mock_peblar.meter_history.assert_called_once_with( + start=datetime(2026, 1, 22, 12, tzinfo=UTC), + stop=datetime(2026, 1, 23, 12, tzinfo=UTC), + )