mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 22:38:02 -04:00
Report who was shown in to charge on a Peblar (#183364)
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user