From 56ca4ea2e12fa2f5d62bca199000dedca9a2d820 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 30 Sep 2026 14:04:43 +0200 Subject: [PATCH] Report who was shown in to charge on a Peblar (#183364) --- homeassistant/components/peblar/__init__.py | 9 +- homeassistant/components/peblar/const.py | 5 + .../components/peblar/coordinator.py | 81 ++++++- homeassistant/components/peblar/event.py | 91 ++++++++ homeassistant/components/peblar/icons.json | 5 + homeassistant/components/peblar/strings.json | 12 + homeassistant/components/peblar/websocket.py | 34 ++- tests/components/peblar/conftest.py | 15 ++ .../peblar/fixtures/meter_history.json | 24 ++ .../peblar/snapshots/test_event.ambr | 59 +++++ tests/components/peblar/test_event.py | 206 ++++++++++++++++++ tests/components/peblar/test_services.py | 2 + tests/components/peblar/test_websocket.py | 55 ++++- 13 files changed, 577 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/peblar/event.py create mode 100644 tests/components/peblar/fixtures/meter_history.json create mode 100644 tests/components/peblar/snapshots/test_event.ambr create mode 100644 tests/components/peblar/test_event.py diff --git a/homeassistant/components/peblar/__init__.py b/homeassistant/components/peblar/__init__.py index 08c301dd5fc9..ee4ccf598fd8 100644 --- a/homeassistant/components/peblar/__init__.py +++ b/homeassistant/components/peblar/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import ( + PeblarAuthorizationDataUpdateCoordinator, PeblarConfigEntry, PeblarDataUpdateCoordinator, PeblarRuntimeData, @@ -34,6 +35,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.EVENT, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -83,21 +85,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bo hass, entry, peblar ) version_coordinator = PeblarVersionDataUpdateCoordinator(hass, entry, peblar) + authorization_coordinator = PeblarAuthorizationDataUpdateCoordinator( + hass, entry, peblar + ) await asyncio.gather( meter_coordinator.async_config_entry_first_refresh(), user_configuration_coordinator.async_config_entry_first_refresh(), version_coordinator.async_config_entry_first_refresh(), + authorization_coordinator.async_config_entry_first_refresh(), ) # Store the runtime data entry.runtime_data = PeblarRuntimeData( + authorization_coordinator=authorization_coordinator, data_coordinator=meter_coordinator, system_information=system_information, user_configuration_coordinator=user_configuration_coordinator, version_coordinator=version_coordinator, ) - listener = PeblarSessionListener(hass, entry, peblar, meter_coordinator) + listener = PeblarSessionListener(hass, entry, peblar) entry.async_create_background_task( hass, listener.async_run(), name=f"Peblar {entry.title} event stream" ) diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index 9673fac88ae3..009c0624fa7e 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -11,6 +11,11 @@ DOMAIN: Final = "peblar" CONF_EVCC_ID: Final = "evcc_id" CONF_UID: Final = "uid" +# How far back to look for the session the charger is on. Wide enough to +# still find one after a quiet week, narrow enough to stay a few kilobytes: +# asking without a bound hands back the charger's entire history. +SESSION_HISTORY_WINDOW: Final = timedelta(days=7) + # The stream only makes the poll quicker, so there is no hurry, and no # point hammering a charger that is switched off. EVENT_STREAM_RETRY_MINIMUM: Final = timedelta(seconds=5) diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 1bbcd0addfd4..b152851aac0f 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -29,6 +29,7 @@ from homeassistant.util import dt as dt_util from .const import ( DOMAIN, LOGGER, + SESSION_HISTORY_WINDOW, UPDATE_REBOOT_MINIMUM_DOWNTIME, UPDATE_REBOOT_RETURN_TIMEOUT, UPDATE_REBOOT_START_TIMEOUT, @@ -39,6 +40,7 @@ from .const import ( class PeblarRuntimeData: """Class to hold runtime data.""" + authorization_coordinator: PeblarAuthorizationDataUpdateCoordinator data_coordinator: PeblarDataUpdateCoordinator last_known_charging_limit = 6 system_information: PeblarSystemInformation @@ -49,6 +51,20 @@ class PeblarRuntimeData: type PeblarConfigEntry = ConfigEntry[PeblarRuntimeData] +@dataclass(kw_only=True, frozen=True) +class PeblarSessionAuthorization: + """Class to hold who was let in to start a charging session.""" + + session_number: int + """Counts up with every session, so a higher one is a later session.""" + + started_at: datetime + """When the charger started the session, by its own clock.""" + + token: str + """What the card that authorized it was called.""" + + @dataclass(kw_only=True, frozen=True) class PeblarVersionInformation: """Class to hold version information.""" @@ -74,7 +90,8 @@ class PeblarData: def _coordinator_exception_handler[ _DataUpdateCoordinatorT: PeblarDataUpdateCoordinator | PeblarVersionDataUpdateCoordinator - | PeblarUserConfigurationDataUpdateCoordinator, + | PeblarUserConfigurationDataUpdateCoordinator + | PeblarAuthorizationDataUpdateCoordinator, **_P, ]( func: Callable[Concatenate[_DataUpdateCoordinatorT, _P], Coroutine[Any, Any, Any]], @@ -180,6 +197,68 @@ class PeblarVersionDataUpdateCoordinator( watcher.async_stop() +class PeblarAuthorizationDataUpdateCoordinator( + DataUpdateCoordinator[PeblarSessionAuthorization | None] +): + """Class to manage fetching who was let in to charge. + + The charger does not announce this as it happens. It lives in the + meter history, which is a request of its own and a far heavier one + than the poll it sits beside, so this runs on its own slower clock + and gets pulled forward when the session changes. + """ + + def __init__( + self, hass: HomeAssistant, entry: PeblarConfigEntry, peblar: Peblar + ) -> None: + """Initialize the coordinator.""" + self.peblar = peblar + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"Peblar {entry.title} authorization", + update_interval=timedelta(minutes=5), + ) + + @_coordinator_exception_handler + @override + async def _async_update_data(self) -> PeblarSessionAuthorization | None: + """Fetch data from the Peblar device. + + Returns what the card was called rather than what it is: the + identifier is the card, and someone carries that card around. + """ + history = await self.peblar.meter_history( + start=dt_util.utcnow() - SESSION_HISTORY_WINDOW + ) + if not history.session: + return None + + # A charger set to charge without authentication was shown no + # card, so there is nothing to look up and nobody to name. + session = history.session[-1] + if session.auth_token is None: + return None + + token = next( + ( + known.rfid_token_description + for known in await self.peblar.rfid_tokens() + if known.rfid_token_uid == session.auth_token + ), + None, + ) + if token is None: + return None + + return PeblarSessionAuthorization( + session_number=session.session_number, + started_at=dt_util.utc_from_timestamp(session.session_start_time), + token=token, + ) + + class _RebootWatcher: """Waits out the reboot that follows installing a package. diff --git a/homeassistant/components/peblar/event.py b/homeassistant/components/peblar/event.py new file mode 100644 index 000000000000..6df24dedbd03 --- /dev/null +++ b/homeassistant/components/peblar/event.py @@ -0,0 +1,91 @@ +"""Support for Peblar events.""" + +from typing import override + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PeblarAuthorizationDataUpdateCoordinator, PeblarConfigEntry +from .entity import PeblarEntity + +# The coordinator does the talking; the entity only reads what it found. +PARALLEL_UPDATES = 0 + +ATTR_SESSION_NUMBER = "session_number" +ATTR_STARTED_AT = "started_at" +ATTR_TOKEN = "token" + +EVENT_SESSION_AUTHORIZED = "session_authorized" + +DESCRIPTION = EventEntityDescription( + key="session_authorization", + translation_key="session_authorization", + event_types=[EVENT_SESSION_AUTHORIZED], +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Peblar events based on a config entry.""" + # A charger without a reader has no standalone list to authorize + # against, so nobody is ever shown in. + if not entry.runtime_data.system_information.hardware_has_rfid: + return + + async_add_entities( + [ + PeblarAuthorizationEventEntity( + entry=entry, + coordinator=entry.runtime_data.authorization_coordinator, + description=DESCRIPTION, + ) + ] + ) + + +class PeblarAuthorizationEventEntity( + PeblarEntity[PeblarAuthorizationDataUpdateCoordinator], EventEntity +): + """Reports a card being shown to start a charging session. + + The charger does not push this as it happens, so it is read back from + the session the charger is on. A session already running when this + entity starts is not reported: it was authorized before anyone here + was watching, and dating it now would put the wrong time on it. + """ + + _session_number: int | None = None + + @override + async def async_added_to_hass(self) -> None: + """Take note of the session already running, without reporting it.""" + await super().async_added_to_hass() + if (authorization := self.coordinator.data) is not None: + self._session_number = authorization.session_number + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Report a session that was not there the last time we looked.""" + authorization = self.coordinator.data + if ( + authorization is not None + and authorization.session_number != self._session_number + ): + self._session_number = authorization.session_number + self._trigger_event( + EVENT_SESSION_AUTHORIZED, + { + ATTR_SESSION_NUMBER: authorization.session_number, + # The charger's own start time, not the moment this + # was noticed: the two are seconds apart at best. + ATTR_STARTED_AT: authorization.started_at.isoformat(), + ATTR_TOKEN: authorization.token, + }, + ) + + super()._handle_coordinator_update() diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index ebd560158edd..1b26aac580ef 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -13,6 +13,11 @@ "default": "mdi:lock-open" } }, + "event": { + "session_authorization": { + "default": "mdi:card-account-details" + } + }, "number": { "charge_current_limit": { "default": "mdi:speedometer" diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index bd7abf30282b..4d5a99dc6975 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -70,6 +70,18 @@ "name": "Unlock socket" } }, + "event": { + "session_authorization": { + "name": "Session authorization", + "state_attributes": { + "event_type": { + "state": { + "session_authorized": "Session authorized" + } + } + } + } + }, "number": { "charge_current_limit": { "name": "Charge limit" diff --git a/homeassistant/components/peblar/websocket.py b/homeassistant/components/peblar/websocket.py index d01558b1bd52..598a9bee15a7 100644 --- a/homeassistant/components/peblar/websocket.py +++ b/homeassistant/components/peblar/websocket.py @@ -2,12 +2,12 @@ import asyncio -from peblar import Peblar, PeblarError, PeblarSessionStatus +from peblar import Peblar, PeblarError, PeblarSessionStatus, SessionState from homeassistant.core import HomeAssistant, callback from .const import EVENT_STREAM_RETRY_MAXIMUM, EVENT_STREAM_RETRY_MINIMUM, LOGGER -from .coordinator import PeblarConfigEntry, PeblarDataUpdateCoordinator +from .coordinator import PeblarConfigEntry class PeblarSessionListener: @@ -24,14 +24,13 @@ class PeblarSessionListener: hass: HomeAssistant, entry: PeblarConfigEntry, peblar: Peblar, - coordinator: PeblarDataUpdateCoordinator, ) -> None: """Initialize the listener.""" self._hass = hass self._entry = entry self._peblar = peblar - self._coordinator = coordinator self._retry = EVENT_STREAM_RETRY_MINIMUM + self._state: SessionState | None = None async def async_run(self) -> None: """Keep a subscription up for as long as the entry is loaded.""" @@ -68,13 +67,26 @@ class PeblarSessionListener: @callback def _handle_session_status(self, status: PeblarSessionStatus) -> None: - """Ask the poll to catch up, now the session has moved on. + """Bring the polls forward, now the session has moved on. - The charger sends the current status right after subscribing, so - the first call says nothing new. Refreshing anyway is harmless and - cheaper than working out which one that was. + Most of what arrives here says nothing new: the charger repeats + the status every couple of seconds while it charges, and sends + the current one right after subscribing. Acting only on a session + that actually moved keeps the meter history, a request many times + heavier than the poll beside it, from being fetched for as long as + a car is plugged in. """ + if status.state == self._state: + return + + self._state = status.state LOGGER.debug("Peblar session for %s is %s", self._entry.title, status.state) - self._entry.async_create_task( - self._hass, self._coordinator.async_request_refresh(), eager_start=False - ) + + runtime_data = self._entry.runtime_data + for coordinator in ( + runtime_data.data_coordinator, + runtime_data.authorization_coordinator, + ): + self._entry.async_create_task( + self._hass, coordinator.async_request_refresh(), eager_start=False + ) diff --git a/tests/components/peblar/conftest.py b/tests/components/peblar/conftest.py index a26cce1ae088..5f144a96bdc0 100644 --- a/tests/components/peblar/conftest.py +++ b/tests/components/peblar/conftest.py @@ -8,6 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, patch from peblar import ( PeblarEVInterface, PeblarMeter, + PeblarMeterHistory, + PeblarRfidToken, PeblarSystem, PeblarSystemInformation, PeblarUserConfiguration, @@ -80,6 +82,19 @@ 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) + ) + peblar.rfid_tokens.return_value = [ + PeblarRfidToken( + rfid_token_uid="1D0A0B0C0D0E03", + rfid_token_description="Frenck", + ), + PeblarRfidToken( + rfid_token_uid="1D0102030405D3", + rfid_token_description="Spare", + ), + ] # 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 000000000000..e567586924b0 --- /dev/null +++ b/tests/components/peblar/fixtures/meter_history.json @@ -0,0 +1,24 @@ +{ + "Corrupted": false, + "CorruptedSession": [false, false], + "Session": [ + { + "AuthToken": null, + "Checksum": 3735928559, + "SessionNumber": 41, + "SessionStartEnergymWh": 84000000, + "SessionStartTime": 1756454400, + "SessionEndEnergymWh": 96000000, + "SessionEndTime": 1756465200 + }, + { + "AuthToken": "1D0A0B0C0D0E03", + "Checksum": 3405691582, + "SessionNumber": 42, + "SessionStartEnergymWh": 96000000, + "SessionStartTime": 1756540800, + "SessionEndEnergymWh": null, + "SessionEndTime": null + } + ] +} diff --git a/tests/components/peblar/snapshots/test_event.ambr b/tests/components/peblar/snapshots/test_event.ambr new file mode 100644 index 000000000000..231fc18e8463 --- /dev/null +++ b/tests/components/peblar/snapshots/test_event.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_entities[event][event.peblar_ev_charger_session_authorization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'session_authorized', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.peblar_ev_charger_session_authorization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Session authorization', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Session authorization', + 'platform': 'peblar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'session_authorization', + 'unique_id': '23-45-A4O-MOF_session_authorization', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event][event.peblar_ev_charger_session_authorization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : list([ + 'session_authorized', + ]), + : 'Peblar EV Charger Session authorization', + }), + 'context': , + 'entity_id': 'event.peblar_ev_charger_session_authorization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/peblar/test_event.py b/tests/components/peblar/test_event.py new file mode 100644 index 000000000000..d274afdb1366 --- /dev/null +++ b/tests/components/peblar/test_event.py @@ -0,0 +1,206 @@ +"""Tests for the Peblar event platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from peblar import PeblarMeterHistory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "event.peblar_ev_charger_session_authorization" + + +def _history(session_number: int, auth_token: str | None) -> PeblarMeterHistory: + """Return a history holding one session, authorized with this token.""" + return PeblarMeterHistory.from_dict( + { + "Corrupted": False, + "CorruptedSession": [False], + "Session": [ + { + "AuthToken": auth_token, + "Checksum": 3405691582, + "SessionNumber": session_number, + "SessionStartEnergymWh": 96000000, + "SessionStartTime": 1756540800, + } + ], + } + ) + + +async def _async_poll(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Let the authorization coordinator run one poll.""" + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +@pytest.mark.parametrize("init_integration", [Platform.EVENT], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the event entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("init_integration", [Platform.EVENT], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_a_session_already_running_is_not_reported( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the session in progress at startup does not fire an event. + + It was authorized before anyone here was watching, and stamping it + with the time it was noticed would put the wrong time on it. The poll + matters here: setting up happens before the entity exists, so the + first chance to get this wrong is the poll that follows. + """ + assert hass.states.get(ENTITY_ID).state == STATE_UNKNOWN + + await _async_poll(hass, freezer) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize("init_integration", [Platform.EVENT], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_only_the_recent_past_is_asked_for(mock_peblar: MagicMock) -> None: + """Test the charger is asked for a window, not for everything it has. + + Without a bound it hands back its entire history, which on a charger + in daily use is tens of times larger than what is needed here. + """ + start = mock_peblar.meter_history.call_args.kwargs["start"] + + assert start is not None + assert timedelta(days=6) < dt_util.utcnow() - start < timedelta(days=8) + + +@pytest.mark.parametrize("init_integration", [Platform.EVENT], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_a_new_session_names_who_started_it( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a card shown to the charger reports who it belongs to. + + The card is what an automation acts on, so it comes along by the name + someone gave it rather than by the identifier on the card itself. + """ + mock_peblar.meter_history.return_value = _history(43, "1D0A0B0C0D0E03") + await _async_poll(hass, freezer) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state != STATE_UNKNOWN + assert state.attributes["event_type"] == "session_authorized" + assert state.attributes["token"] == "Frenck" + assert state.attributes["session_number"] == 43 + assert state.attributes["started_at"] == "2025-08-30T08:00:00+00:00" + assert "1D0A0B0C0D0E03" not in str(state.attributes) + + +@pytest.mark.parametrize("init_integration", [Platform.EVENT], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_the_same_session_is_only_reported_once( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a session that is still running does not report again. + + Every poll hands back the session the charger is on, so without + looking at which session it is, a car left plugged in overnight would + report being authorized every five minutes. + """ + mock_peblar.meter_history.return_value = _history(43, "1D0A0B0C0D0E03") + await _async_poll(hass, freezer) + + first = hass.states.get(ENTITY_ID).state + + await _async_poll(hass, freezer) + await _async_poll(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == first + + +@pytest.mark.parametrize("init_integration", [Platform.EVENT], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_the_same_card_twice_is_reported_twice( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test charging twice on one card reports both times. + + This is what a state cannot do: the name does not change between the + two, so anything watching for a change would miss the second. + """ + mock_peblar.meter_history.return_value = _history(43, "1D0A0B0C0D0E03") + await _async_poll(hass, freezer) + first = hass.states.get(ENTITY_ID).state + + mock_peblar.meter_history.return_value = _history(44, "1D0A0B0C0D0E03") + await _async_poll(hass, freezer) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state != first + assert state.attributes["token"] == "Frenck" + assert state.attributes["session_number"] == 44 + + +@pytest.mark.parametrize("init_integration", [Platform.EVENT], indirect=True) +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "history", + [ + PeblarMeterHistory(corrupted=False, corrupted_session=[], session=[]), + _history(43, None), + _history(43, "0000000000FFFF"), + ], + ids=["nothing charged in the window", "charged without a card", "card deleted"], +) +async def test_nothing_to_report( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, + history: PeblarMeterHistory, +) -> None: + """Test a session nobody was shown in for stays quiet. + + A charger set to charge without authentication was shown no card, a + quiet charger has no session to point at, and a card deleted since + has no name left to give. + """ + mock_peblar.meter_history.return_value = history + await _async_poll(hass, freezer) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize("mock_peblar", [{"HwHasRfid": False}], indirect=True) +@pytest.mark.parametrize("init_integration", [Platform.EVENT], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_no_reader_no_entity(hass: HomeAssistant) -> None: + """Test a charger without a reader shows nobody in.""" + assert hass.states.get(ENTITY_ID) is None diff --git a/tests/components/peblar/test_services.py b/tests/components/peblar/test_services.py index c0f87639fc90..942e68043c1f 100644 --- a/tests/components/peblar/test_services.py +++ b/tests/components/peblar/test_services.py @@ -59,6 +59,8 @@ async def test_list_rfid_tokens( init_integration: MockConfigEntry, ) -> None: """Test list_rfid_tokens returns token list.""" + # Setting up reads the list too, and this is about what the action does. + mock_peblar.rfid_tokens.reset_mock() mock_peblar.rfid_tokens.return_value = [ PeblarRfidToken( rfid_token_uid="AA:BB:CC:DD", diff --git a/tests/components/peblar/test_websocket.py b/tests/components/peblar/test_websocket.py index f106dd53e712..65983c35b891 100644 --- a/tests/components/peblar/test_websocket.py +++ b/tests/components/peblar/test_websocket.py @@ -1,15 +1,17 @@ """Tests for the Peblar event stream.""" import asyncio +from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from peblar import PeblarConnectionError, PeblarSessionStatus, SessionState import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed pytestmark = [ pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True), @@ -24,22 +26,59 @@ async def test_the_stream_is_subscribed_to(mock_peblar: MagicMock) -> None: websocket.subscribe_session_status.assert_awaited_once() -async def test_a_session_change_pulls_the_poll_forward( +def _async_report(mock_peblar: MagicMock, state: SessionState) -> None: + """Have the charger report a session state on the stream.""" + websocket = mock_peblar.websocket.return_value + handle_session_status = websocket.subscribe_session_status.call_args.args[0] + handle_session_status(PeblarSessionStatus(state=state, meter_data=None)) + + +async def test_a_session_change_pulls_the_polls_forward( hass: HomeAssistant, mock_peblar: MagicMock, ) -> None: - """Test an event asks the poll to catch up rather than waiting it out.""" + """Test an event asks the polls to catch up rather than waiting them out.""" meter = mock_peblar.rest_api.return_value.meter meter.reset_mock() + mock_peblar.meter_history.reset_mock() - websocket = mock_peblar.websocket.return_value - handle_session_status = websocket.subscribe_session_status.call_args.args[0] - handle_session_status( - PeblarSessionStatus(state=SessionState.CHARGING, meter_data=None) - ) + _async_report(mock_peblar, SessionState.CHARGING) await hass.async_block_till_done() meter.assert_awaited() + mock_peblar.meter_history.assert_awaited() + + +async def test_a_session_saying_the_same_thing_is_let_be( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the charger repeating itself does not set the polls off again. + + While a car charges the status arrives every couple of seconds and + says the same thing every time. Acting on each one would have the + meter history, a request many times heavier than the poll beside it, + fetched around the clock for as long as the car is plugged in. + + The repeats are spread out here on purpose. Sent back to back the + coordinator's own debouncer would swallow them, and this would pass + whether or not anything looked at the state. + """ + _async_report(mock_peblar, SessionState.CHARGING) + await hass.async_block_till_done() + + mock_peblar.meter_history.reset_mock() + + for _ in range(3): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + _async_report(mock_peblar, SessionState.CHARGING) + await hass.async_block_till_done() + + mock_peblar.meter_history.assert_not_awaited() async def test_the_wait_backs_off_and_settles_once_the_charger_answers(