diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index 40bb86c4dadf..9673fac88ae3 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -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 = { diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 6a04b34debe4..1bbcd0addfd4 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -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.""" diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 2f610ef127e9..534872583315 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -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." }, diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py index 8c103d115930..53fc2e2c61f0 100644 --- a/homeassistant/components/peblar/update.py +++ b/homeassistant/components/peblar/update.py @@ -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", + ) diff --git a/tests/components/peblar/snapshots/test_update.ambr b/tests/components/peblar/snapshots/test_update.ambr index 4ab471edbf78..e24de6cdbbca 100644 --- a/tests/components/peblar/snapshots/test_update.ambr +++ b/tests/components/peblar/snapshots/test_update.ambr @@ -30,7 +30,7 @@ 'platform': 'peblar', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': 0, + 'supported_features': , 'translation_key': 'customization', 'unique_id': '23-45-A4O-MOF_customization', 'unit_of_measurement': None, @@ -49,7 +49,7 @@ : None, : None, : None, - : , + : , : None, : None, }), @@ -92,7 +92,7 @@ 'platform': 'peblar', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': 0, + 'supported_features': , 'translation_key': None, 'unique_id': '23-45-A4O-MOF_firmware', 'unit_of_measurement': None, @@ -112,7 +112,7 @@ : None, : None, : None, - : , + : , : None, : None, }), diff --git a/tests/components/peblar/test_update.py b/tests/components/peblar/test_update.py index 97d6a5937d0f..3f620e9cc04b 100644 --- a/tests/components/peblar/test_update.py +++ b/tests/components/peblar/test_update.py @@ -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