mirror of
https://github.com/home-assistant/core.git
synced 2026-09-27 09:58:07 -04:00
Install firmware and customization updates on Peblar (#180581)
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user