Report who was shown in to charge on a Peblar (#183364)

This commit is contained in:
Franck Nijhof
2026-09-30 14:04:43 +02:00
committed by GitHub
parent 0d6f4aa062
commit 56ca4ea2e1
13 changed files with 577 additions and 21 deletions
+8 -1
View File
@@ -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"
)
+5
View File
@@ -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)
+80 -1
View File
@@ -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.
+91
View File
@@ -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()
@@ -13,6 +13,11 @@
"default": "mdi:lock-open"
}
},
"event": {
"session_authorization": {
"default": "mdi:card-account-details"
}
},
"number": {
"charge_current_limit": {
"default": "mdi:speedometer"
@@ -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"
+23 -11
View File
@@ -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
)
+15
View File
@@ -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.
@@ -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
}
]
}
@@ -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({
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'session_authorized',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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({
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'session_authorized',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Peblar EV Charger Session authorization',
}),
'context': <ANY>,
'entity_id': 'event.peblar_ev_charger_session_authorization',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+206
View File
@@ -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
+2
View File
@@ -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",
+47 -8
View File
@@ -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(