Install firmware and customization updates on Peblar (#180581)

This commit is contained in:
Franck Nijhof
2026-08-30 17:08:16 +02:00
committed by GitHub
parent 0c76ebf310
commit 8185ecde66
6 changed files with 760 additions and 10 deletions
+14
View File
@@ -16,6 +16,20 @@ CONF_UID: Final = "uid"
EVENT_STREAM_RETRY_MINIMUM: Final = timedelta(seconds=5)
EVENT_STREAM_RETRY_MAXIMUM: Final = timedelta(minutes=5)
# How long a charger gets to start rebooting after it was asked to install
# a package. It downloads first, so this is generous: Peblar's own web
# interface waits the same three hours before it gives up.
UPDATE_REBOOT_START_TIMEOUT: Final = timedelta(hours=3)
# And how long it gets to come back once it has actually gone. Peblar
# allows ten minutes for that.
UPDATE_REBOOT_RETURN_TIMEOUT: Final = timedelta(minutes=10)
# How long the charger has to stay away before it counts as having
# rebooted. Peblar allows ten minutes for a reboot, so it is nowhere near
# a matter of seconds; anything shorter is the network dropping a poll.
UPDATE_REBOOT_MINIMUM_DOWNTIME: Final = timedelta(seconds=30)
LOGGER = logging.getLogger(__package__)
PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT = {
+157 -3
View File
@@ -2,7 +2,7 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import timedelta
from datetime import datetime, timedelta
from typing import Any, Concatenate, override
from peblar import (
@@ -20,11 +20,19 @@ from peblar import (
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER
from .const import (
DOMAIN,
LOGGER,
UPDATE_REBOOT_MINIMUM_DOWNTIME,
UPDATE_REBOOT_RETURN_TIMEOUT,
UPDATE_REBOOT_START_TIMEOUT,
)
@dataclass(kw_only=True)
@@ -111,11 +119,17 @@ class PeblarVersionDataUpdateCoordinator(
):
"""Class to manage fetching Peblar version information."""
config_entry: PeblarConfigEntry
install_in_progress = False
"""Set while the charger is busy installing a package."""
def __init__(
self, hass: HomeAssistant, entry: PeblarConfigEntry, peblar: Peblar
) -> None:
"""Initialize the coordinator."""
self.peblar = peblar
self._reboot_watcher: _RebootWatcher | None = None
super().__init__(
hass,
LOGGER,
@@ -123,6 +137,7 @@ class PeblarVersionDataUpdateCoordinator(
name=f"Peblar {entry.title} version",
update_interval=timedelta(hours=2),
)
entry.async_on_unload(self.async_stop_reboot_watcher)
@_coordinator_exception_handler
@override
@@ -133,6 +148,145 @@ class PeblarVersionDataUpdateCoordinator(
available=await self.peblar.available_versions(),
)
@callback
def async_refresh_after_restart(self) -> None:
"""Read the versions again once the charger has restarted.
Installing a package returns long before the charger is done: it
downloads, then reboots on its own. Rather than guess at a delay,
wait for the charger to drop off and come back, which the data
poll notices. Peblar's own web interface waits for the same two
moments, and allows a different amount of time for each.
Without this a charger that just updated keeps offering the update
it already took, until the two hourly version poll comes round.
"""
# Only ever one at a time: the update component refuses an install
# while the entity reports one in progress, which it does for as
# long as a watcher is running.
self.install_in_progress = True
self._reboot_watcher = _RebootWatcher(self)
self._reboot_watcher.async_start()
@callback
def async_stop_reboot_watcher(self) -> None:
"""Stop waiting on a reboot, if anything is still waiting on one."""
if (watcher := self._reboot_watcher) is None:
return
# Dropped first, so a watcher stopping itself cannot come back
# round here and stop itself again.
self._reboot_watcher = None
watcher.async_stop()
class _RebootWatcher:
"""Waits out the reboot that follows installing a package.
Two phases, because they are allowed very different amounts of time:
the charger downloads before it reboots, so going down at all may take
hours, while coming back afterwards should take minutes.
"""
def __init__(self, coordinator: PeblarVersionDataUpdateCoordinator) -> None:
"""Initialize the watcher."""
self._coordinator = coordinator
self._entry = coordinator.config_entry
self._data_coordinator = self._entry.runtime_data.data_coordinator
self._went_down_at: datetime | None = None
self._start_deadline: datetime | None = None
self._unsubscribe_listener: CALLBACK_TYPE | None = None
self._unsubscribe_timer: CALLBACK_TYPE | None = None
@callback
def async_start(self) -> None:
"""Start watching for the charger to go away and come back."""
self._unsubscribe_listener = self._data_coordinator.async_add_listener(
self._handle_data_coordinator_update
)
self._start_deadline = dt_util.utcnow() + UPDATE_REBOOT_START_TIMEOUT
self._async_set_deadline(UPDATE_REBOOT_START_TIMEOUT)
@callback
def _async_set_deadline(self, timeout: timedelta) -> None:
"""Give up if nothing happens within the given time."""
if self._unsubscribe_timer is not None:
self._unsubscribe_timer()
self._unsubscribe_timer = async_call_later(
self._coordinator.hass, timeout, self._handle_deadline
)
@callback
def _handle_deadline(self, _now: datetime) -> None:
"""Stop watching a charger that never did what was asked."""
self._unsubscribe_timer = None
self._coordinator.async_stop_reboot_watcher()
@callback
def _async_unsubscribe(self) -> None:
"""Stop following the charger."""
if self._unsubscribe_listener is not None:
self._unsubscribe_listener()
self._unsubscribe_listener = None
if self._unsubscribe_timer is not None:
self._unsubscribe_timer()
self._unsubscribe_timer = None
@callback
def async_stop(self) -> None:
"""Stop watching, however it ended."""
self._async_unsubscribe()
# The install is over as far as anyone here can tell, whether the
# charger came back or ran out of time.
self._coordinator.install_in_progress = False
self._coordinator.async_update_listeners()
@callback
def _handle_data_coordinator_update(self) -> None:
"""Follow the charger through its reboot."""
if not self._data_coordinator.last_update_success:
if self._went_down_at is None:
# It may have started rebooting, so the shorter allowance
# applies from here on.
self._went_down_at = dt_util.utcnow()
self._async_set_deadline(UPDATE_REBOOT_RETURN_TIMEOUT)
return
# Still reachable, so the charger has not started rebooting yet.
if self._went_down_at is None:
return
if dt_util.utcnow() - self._went_down_at < UPDATE_REBOOT_MINIMUM_DOWNTIME:
# Gone for a moment is the network, not a charger rebooting. Go
# back to waiting for the reboot to start, on what is left of the
# original allowance: blips must not keep extending it.
self._went_down_at = None
assert self._start_deadline is not None
self._async_set_deadline(
max(self._start_deadline - dt_util.utcnow(), timedelta(0))
)
return
# The polls keep coming, so stop following the charger right away
# rather than leave this to fire a second time.
self._async_unsubscribe()
self._entry.async_create_task(
self._coordinator.hass, self._async_finish(), eager_start=False
)
async def _async_finish(self) -> None:
"""Read the new versions before saying the install is done.
Letting go first would publish "nothing installing" next to the
versions from before the update, and for as long as the read takes,
the charger would be offering the package it just took.
"""
try:
await self._coordinator.async_request_refresh()
finally:
self._coordinator.async_stop_reboot_watcher()
class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]):
"""Class to manage fetching Peblar active data."""
@@ -209,6 +209,9 @@
"communication_error": {
"message": "An error occurred while communicating with the Peblar EV charger: {error}"
},
"customization_update_first": {
"message": "Install the customization update before the firmware update, the way the charger's own web interface does."
},
"managed_by_backoffice": {
"message": "{charger} is managed over OCPP, so its sessions are authorized by the backoffice."
},
+70 -1
View File
@@ -2,26 +2,40 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from typing import Any, override
from peblar import PackageType
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import (
PeblarConfigEntry,
PeblarVersionDataUpdateCoordinator,
PeblarVersionInformation,
)
from .entity import PeblarEntity
from .helpers import peblar_exception_handler
PARALLEL_UPDATES = 1
def _customization_update_pending(versions: PeblarVersionInformation) -> bool:
"""Return whether a customization package is waiting to be installed."""
return (
versions.available.customization is not None
and versions.available.customization != versions.current.customization
)
@dataclass(frozen=True, kw_only=True)
class PeblarUpdateEntityDescription(UpdateEntityDescription):
"""Describe an Peblar update entity."""
@@ -29,12 +43,14 @@ class PeblarUpdateEntityDescription(UpdateEntityDescription):
available_fn: Callable[[PeblarVersionInformation], str | None]
has_fn: Callable[[PeblarVersionInformation], bool] = lambda _: True
installed_fn: Callable[[PeblarVersionInformation], str | None]
package_type: PackageType
DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = (
PeblarUpdateEntityDescription(
key="firmware",
device_class=UpdateDeviceClass.FIRMWARE,
package_type=PackageType.FIRMWARE,
installed_fn=lambda x: x.current.firmware,
has_fn=lambda x: x.available.firmware is not None,
available_fn=lambda x: x.available.firmware,
@@ -42,6 +58,7 @@ DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = (
PeblarUpdateEntityDescription(
key="customization",
translation_key="customization",
package_type=PackageType.CUSTOMIZATION,
available_fn=lambda x: x.available.customization,
has_fn=lambda x: x.available.customization is not None,
installed_fn=lambda x: x.current.customization,
@@ -74,6 +91,20 @@ class PeblarUpdateEntity(
entity_description: PeblarUpdateEntityDescription
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
@property
@override
def in_progress(self) -> bool:
"""Return whether the charger is busy installing a package.
No percentage goes with it: the charger reports only whether an
update succeeded, never how far along it is.
"""
return self.coordinator.install_in_progress
@property
@override
def installed_version(self) -> str | None:
@@ -85,3 +116,41 @@ class PeblarUpdateEntity(
def latest_version(self) -> str | None:
"""Latest version available for install."""
return self.entity_description.available_fn(self.coordinator.data)
@peblar_exception_handler
@override
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install the package the charger has on offer."""
if self.entity_description.package_type is PackageType.FIRMWARE:
await self._async_raise_if_customization_pending()
await self.coordinator.peblar.update(
package_type=self.entity_description.package_type
)
self.coordinator.async_refresh_after_restart()
async def _async_raise_if_customization_pending(self) -> None:
"""Refuse firmware while a customization package is still waiting.
Peblar's own web interface installs the customization package first
and waits for the charger to come back before it touches the
firmware. Doing it the other way around is not a sequence the
charger is put through anywhere else.
Versions are polled once every two hours, and the charger answers
from its own cache unless told not to. Both are asked again here:
a customization published since the last poll is exactly the case
this refusal is for, and it would otherwise walk straight past it.
"""
versions = PeblarVersionInformation(
current=await self.coordinator.peblar.current_versions(),
available=await self.coordinator.peblar.available_versions(use_cache=False),
)
if _customization_update_pending(versions):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="customization_update_first",
)
@@ -30,7 +30,7 @@
'platform': 'peblar',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'supported_features': <UpdateEntityFeature: 5>,
'translation_key': 'customization',
'unique_id': '23-45-A4O-MOF_customization',
'unit_of_measurement': None,
@@ -49,7 +49,7 @@
<UpdateEntityStateAttribute.RELEASE_SUMMARY: 'release_summary'>: None,
<UpdateEntityStateAttribute.RELEASE_URL: 'release_url'>: None,
<UpdateEntityStateAttribute.SKIPPED_VERSION: 'skipped_version'>: None,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <UpdateEntityFeature: 0>,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <UpdateEntityFeature: 5>,
<UpdateEntityStateAttribute.TITLE: 'title'>: None,
<UpdateEntityStateAttribute.UPDATE_PERCENTAGE: 'update_percentage'>: None,
}),
@@ -92,7 +92,7 @@
'platform': 'peblar',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'supported_features': <UpdateEntityFeature: 5>,
'translation_key': None,
'unique_id': '23-45-A4O-MOF_firmware',
'unit_of_measurement': None,
@@ -112,7 +112,7 @@
<UpdateEntityStateAttribute.RELEASE_SUMMARY: 'release_summary'>: None,
<UpdateEntityStateAttribute.RELEASE_URL: 'release_url'>: None,
<UpdateEntityStateAttribute.SKIPPED_VERSION: 'skipped_version'>: None,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <UpdateEntityFeature: 0>,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <UpdateEntityFeature: 5>,
<UpdateEntityStateAttribute.TITLE: 'title'>: None,
<UpdateEntityStateAttribute.UPDATE_PERCENTAGE: 'update_percentage'>: None,
}),
+512 -2
View File
@@ -1,14 +1,42 @@
"""Tests for the Peblar update platform."""
import asyncio
from datetime import timedelta
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from peblar import PackageType, PeblarConnectionError, PeblarVersions
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.peblar.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.components.update import (
ATTR_IN_PROGRESS,
DOMAIN as UPDATE_DOMAIN,
SERVICE_INSTALL,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def _async_offer_both_updates(
hass: HomeAssistant, mock_peblar: MagicMock
) -> None:
"""Put the charger on older packages, so both updates are on offer."""
mock_peblar.current_versions.return_value = PeblarVersions.from_dict(
{"Customization": "Peblar-1.8", "Firmware": "1.6.1+1+WL-1"}
)
mock_peblar.available_versions.return_value = PeblarVersions.from_dict(
{"Customization": "Peblar-1.9", "Firmware": "1.6.2+1+WL-1"}
)
await hass.config_entries.async_reload(
hass.config_entries.async_entries(DOMAIN)[0].entry_id
)
await hass.async_block_till_done()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@@ -33,3 +61,485 @@ async def test_entities(
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_install_firmware(
hass: HomeAssistant,
mock_peblar: MagicMock,
) -> None:
"""Test installing the firmware asks the charger for that package.
Only the firmware is out of date in the fixtures, which is the case
where installing it straight away is fine.
"""
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: "update.peblar_ev_charger_firmware"},
blocking=True,
)
mock_peblar.update.assert_called_once_with(package_type=PackageType.FIRMWARE)
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_install_customization(
hass: HomeAssistant,
mock_peblar: MagicMock,
) -> None:
"""Test installing the customization asks the charger for that package."""
await _async_offer_both_updates(hass, mock_peblar)
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: "update.peblar_ev_charger_customization"},
blocking=True,
)
mock_peblar.update.assert_called_once_with(package_type=PackageType.CUSTOMIZATION)
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_install_firmware_refuses_while_customization_is_pending(
hass: HomeAssistant,
mock_peblar: MagicMock,
) -> None:
"""Test the charger is not put through a sequence it never sees.
Peblar's own web interface installs the customization package first and
waits for the charger to come back before it touches the firmware.
"""
await _async_offer_both_updates(hass, mock_peblar)
with pytest.raises(HomeAssistantError) as excinfo:
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: "update.peblar_ev_charger_firmware"},
blocking=True,
)
assert excinfo.value.translation_domain == DOMAIN
assert excinfo.value.translation_key == "customization_update_first"
mock_peblar.update.assert_not_called()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_install_firmware_asks_the_charger_for_fresh_versions(
hass: HomeAssistant,
mock_peblar: MagicMock,
) -> None:
"""Test a customization published since the last poll still blocks firmware.
Versions are polled once every two hours, and the charger answers from
its own cache unless told not to, so the refusal would be decided on an
answer that predates the very package it is meant to catch.
"""
mock_peblar.current_versions.return_value = PeblarVersions.from_dict(
{"Customization": "Peblar-1.9", "Firmware": "1.6.1+1+WL-1"}
)
mock_peblar.available_versions.return_value = PeblarVersions.from_dict(
{"Customization": "Peblar-1.9", "Firmware": "1.6.2+1+WL-1"}
)
await hass.config_entries.async_reload(
hass.config_entries.async_entries(DOMAIN)[0].entry_id
)
await hass.async_block_till_done()
# Peblar publishes a customization package right after that poll.
mock_peblar.available_versions.return_value = PeblarVersions.from_dict(
{"Customization": "Peblar-2.0", "Firmware": "1.6.2+1+WL-1"}
)
with pytest.raises(HomeAssistantError) as excinfo:
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: "update.peblar_ev_charger_firmware"},
blocking=True,
)
assert excinfo.value.translation_key == "customization_update_first"
mock_peblar.available_versions.assert_called_with(use_cache=False)
mock_peblar.update.assert_not_called()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_install_firmware_after_the_customization_landed(
hass: HomeAssistant,
mock_peblar: MagicMock,
) -> None:
"""Test the fresh answer counts the other way round too.
The customization was installed on the charger since the last poll, so
there is nothing left to wait for and the firmware may go ahead.
"""
await _async_offer_both_updates(hass, mock_peblar)
mock_peblar.current_versions.return_value = PeblarVersions.from_dict(
{"Customization": "Peblar-1.9", "Firmware": "1.6.1+1+WL-1"}
)
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: "update.peblar_ev_charger_firmware"},
blocking=True,
)
mock_peblar.update.assert_called_once_with(package_type=PackageType.FIRMWARE)
async def _async_install(hass: HomeAssistant, package: str = "firmware") -> None:
"""Install an update the way a user does."""
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: f"update.peblar_ev_charger_{package}"},
blocking=True,
)
async def _async_forget_the_version_reads_so_far(
hass: HomeAssistant, mock_peblar: MagicMock
) -> None:
"""Start counting version reads from here.
Setting up and installing both read the versions themselves, so let
that settle first: what the tests below are after is the one extra read
that following the charger through its reboot asks for.
"""
await hass.async_block_till_done()
mock_peblar.current_versions.reset_mock()
async def _async_poll(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
after: timedelta = timedelta(seconds=15),
) -> None:
"""Let the data coordinator run one poll, the given time from now."""
freezer.tick(after)
async_fire_time_changed(hass)
await hass.async_block_till_done()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_versions_are_reread_once_the_charger_is_back(
hass: HomeAssistant,
mock_peblar: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a charger that just updated stops offering the update it took.
Installing returns long before the charger is done, and versions are
otherwise polled every two hours. Dropping off and coming back is what
the charger does in between, and the data poll sees both moments.
"""
meter = mock_peblar.rest_api.return_value.meter
await _async_install(hass)
await _async_forget_the_version_reads_so_far(hass, mock_peblar)
# Still reachable, so the charger has not started rebooting yet.
await _async_poll(hass, freezer)
mock_peblar.current_versions.assert_not_called()
# It goes away to install and reboot.
meter.side_effect = PeblarConnectionError("Gone")
await _async_poll(hass, freezer)
await _async_poll(hass, freezer, after=timedelta(minutes=1))
mock_peblar.current_versions.assert_not_called()
# And comes back.
meter.side_effect = None
await _async_poll(hass, freezer)
mock_peblar.current_versions.assert_called_once()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_versions_are_not_reread_without_an_install(
hass: HomeAssistant,
mock_peblar: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a charger rebooting on its own does not trigger a version read."""
meter = mock_peblar.rest_api.return_value.meter
await _async_forget_the_version_reads_so_far(hass, mock_peblar)
meter.side_effect = PeblarConnectionError("Gone")
await _async_poll(hass, freezer)
await _async_poll(hass, freezer, after=timedelta(minutes=1))
meter.side_effect = None
await _async_poll(hass, freezer)
mock_peblar.current_versions.assert_not_called()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_a_single_missed_poll_is_not_a_reboot(
hass: HomeAssistant,
mock_peblar: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a blip on the network does not end the wait.
The charger is polled every ten seconds and may be downloading for
hours, so it gets asked a great many times. Treating a single missed
answer as a reboot would end the wait early, and the real reboot that
follows would go unnoticed.
"""
meter = mock_peblar.rest_api.return_value.meter
await _async_install(hass)
await _async_forget_the_version_reads_so_far(hass, mock_peblar)
# One missed answer, then the charger is there again.
meter.side_effect = PeblarConnectionError("Blip")
await _async_poll(hass, freezer)
meter.side_effect = None
await _async_poll(hass, freezer)
mock_peblar.current_versions.assert_not_called()
# The actual reboot still gets noticed.
meter.side_effect = PeblarConnectionError("Gone")
await _async_poll(hass, freezer)
await _async_poll(hass, freezer, after=timedelta(minutes=1))
meter.side_effect = None
await _async_poll(hass, freezer)
mock_peblar.current_versions.assert_called_once()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_a_slow_update_is_still_picked_up(
hass: HomeAssistant,
mock_peblar: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a charger that takes its time downloading is still followed.
The charger downloads the package before it reboots, so it can stay
reachable for a long while after the install call returns. Peblar's own
web interface allows three hours for that, far longer than the ten
minutes it allows for the reboot itself.
"""
meter = mock_peblar.rest_api.return_value.meter
await _async_install(hass)
await _async_forget_the_version_reads_so_far(hass, mock_peblar)
# Half an hour of downloading, still reachable.
await _async_poll(hass, freezer, after=timedelta(minutes=30))
# Only now does it reboot, and come back.
meter.side_effect = PeblarConnectionError("Gone")
await _async_poll(hass, freezer)
await _async_poll(hass, freezer, after=timedelta(minutes=1))
meter.side_effect = None
await _async_poll(hass, freezer)
mock_peblar.current_versions.assert_called_once()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_waiting_stops_for_a_charger_that_never_returns(
hass: HomeAssistant,
mock_peblar: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the wait ends once the charger is overdue coming back.
A charger that has gone down should be back within minutes. Waiting
beyond that means the update did not go the way it should have, and
whatever comes back later is not this update landing.
"""
meter = mock_peblar.rest_api.return_value.meter
await _async_install(hass)
await _async_forget_the_version_reads_so_far(hass, mock_peblar)
# The charger goes away, and stays away well past the ten minutes a
# reboot is allowed to take.
meter.side_effect = PeblarConnectionError("Gone")
await _async_poll(hass, freezer)
await _async_poll(hass, freezer, after=timedelta(minutes=1))
await _async_poll(hass, freezer, after=timedelta(minutes=20))
# Whatever comes back now is not this update landing.
meter.side_effect = None
await _async_poll(hass, freezer)
mock_peblar.current_versions.assert_not_called()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_blips_do_not_extend_the_wait(
hass: HomeAssistant,
mock_peblar: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the charger does not get longer than it was given.
A blip puts the wait back to waiting for the reboot to start, but on
what is left of the original allowance. Handing out a fresh three hours
each time would let a flaky network keep this going forever.
"""
meter = mock_peblar.rest_api.return_value.meter
await _async_install(hass)
# Nearly out of time, then a blip.
await _async_poll(hass, freezer, after=timedelta(hours=2, minutes=59))
meter.side_effect = PeblarConnectionError("Blip")
await _async_poll(hass, freezer)
meter.side_effect = None
await _async_poll(hass, freezer)
# Past the three hours the charger was given from the start.
await _async_poll(hass, freezer, after=timedelta(minutes=5))
# So a reboot now is no longer this update landing, however long the
# charger stays away for.
await _async_forget_the_version_reads_so_far(hass, mock_peblar)
meter.side_effect = PeblarConnectionError("Gone")
await _async_poll(hass, freezer)
meter.side_effect = None
await _async_poll(hass, freezer, after=timedelta(minutes=1))
mock_peblar.current_versions.assert_not_called()
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_the_install_runs_on_until_the_new_versions_are_in(
hass: HomeAssistant,
mock_peblar: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the button does not come back before the versions it acts on.
Calling the install done while the versions are still the ones from
before it would put the button back next to the very package the
charger has just taken.
"""
entity_id = "update.peblar_ev_charger_firmware"
meter = mock_peblar.rest_api.return_value.meter
reading_versions = asyncio.Event()
let_the_read_finish = asyncio.Event()
async def _read_slowly() -> PeblarVersions:
reading_versions.set()
await let_the_read_finish.wait()
return PeblarVersions.from_dict(
{"Customization": "Peblar-1.9", "Firmware": "1.6.2+1+WL-1"}
)
await _async_install(hass)
# It goes away to install and reboot.
meter.side_effect = PeblarConnectionError("Gone")
await _async_poll(hass, freezer)
await _async_poll(hass, freezer, after=timedelta(minutes=1))
# And comes back, on a charger that is slow to answer for its versions.
mock_peblar.current_versions.side_effect = _read_slowly
meter.side_effect = None
freezer.tick(timedelta(seconds=15))
async_fire_time_changed(hass)
await reading_versions.wait()
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_IN_PROGRESS] is True
let_the_read_finish.set()
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_IN_PROGRESS] is False
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_a_second_install_is_refused_while_one_runs(
hass: HomeAssistant,
mock_peblar: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the charger is not handed a second package mid update.
The install call returns while the charger is still downloading, so
without saying so the button would be offered again straight away.
Reporting the install as in progress is what makes the update
component refuse a second one.
"""
entity_id = "update.peblar_ev_charger_firmware"
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_IN_PROGRESS] is True
# Once the charger is back, it can be asked again.
meter = mock_peblar.rest_api.return_value.meter
meter.side_effect = PeblarConnectionError("Gone")
await _async_poll(hass, freezer)
await _async_poll(hass, freezer, after=timedelta(minutes=1))
meter.side_effect = None
await _async_poll(hass, freezer)
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_IN_PROGRESS] is False
@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_the_button_returns_for_a_charger_that_never_came_back(
hass: HomeAssistant,
mock_peblar: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a charger that goes missing does not block installs forever."""
entity_id = "update.peblar_ev_charger_firmware"
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
meter = mock_peblar.rest_api.return_value.meter
meter.side_effect = PeblarConnectionError("Gone")
await _async_poll(hass, freezer)
await _async_poll(hass, freezer, after=timedelta(minutes=1))
# Well past the ten minutes a reboot is allowed to take.
freezer.tick(timedelta(minutes=20))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_IN_PROGRESS] is False