diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 310bd3a9752c..34b4b819576d 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -4,11 +4,17 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN -from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator +from .coordinator import ( + ElgatoConfigEntry, + ElgatoDataUpdateCoordinator, + ElgatoFirmwareCoordinator, +) from .services import async_setup_services +ELGATO_KEY: HassKey[ElgatoFirmwareCoordinator] = HassKey(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BUTTON, @@ -17,12 +23,29 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the component.""" + """Set up the component. + + Elgato publishes one firmware catalog covering every model, so a single + coordinator serves every device rather than each config entry fetching + the same thing. + """ async_setup_services(hass) + + coordinator = ElgatoFirmwareCoordinator(hass) + hass.data[ELGATO_KEY] = coordinator + + # Elgato's servers are not on the local network and a request to them can + # sit there for its full timeout, so nothing waits on this. The update + # entities fill themselves in once the answer arrives. + hass.async_create_background_task( + coordinator.async_refresh(), f"{DOMAIN}_firmware_refresh" + ) + return True diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index f117497b3a7b..66ffec9b551e 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import elgato_exception_handler +from .helpers import elgato_device_action PARALLEL_UPDATES = 1 @@ -78,7 +78,7 @@ class ElgatoButtonEntity(ElgatoEntity, ButtonEntity): f"{coordinator.data.info.serial_number}_{description.key}" ) - @elgato_exception_handler + @elgato_device_action @override async def async_press(self) -> None: """Trigger button press on the Elgato device.""" diff --git a/homeassistant/components/elgato/const.py b/homeassistant/components/elgato/const.py index a3da1b7d4165..b898d8cd79ac 100644 --- a/homeassistant/components/elgato/const.py +++ b/homeassistant/components/elgato/const.py @@ -10,5 +10,9 @@ DOMAIN: Final = "elgato" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=10) +# Elgato publishes firmware a handful of times a year, bundled with a new +# Control Center release. Asking more often than this buys nothing. +FIRMWARE_SCAN_INTERVAL = timedelta(hours=12) + # Attributes ATTR_ON = "on" diff --git a/homeassistant/components/elgato/coordinator.py b/homeassistant/components/elgato/coordinator.py index 7e2afde85795..d372178cceba 100644 --- a/homeassistant/components/elgato/coordinator.py +++ b/homeassistant/components/elgato/coordinator.py @@ -1,5 +1,6 @@ """DataUpdateCoordinator for Elgato.""" +import asyncio from dataclasses import dataclass from typing import override @@ -8,6 +9,8 @@ from elgato import ( Elgato, ElgatoConnectionError, ElgatoError, + FirmwareCatalog, + FirmwareVersion, Info, Settings, State, @@ -19,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN, FIRMWARE_SCAN_INTERVAL, LOGGER, SCAN_INTERVAL type ElgatoConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator] @@ -42,11 +45,14 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]): def __init__(self, hass: HomeAssistant, entry: ElgatoConfigEntry) -> None: """Initialize the coordinator.""" - self.config_entry = entry self.client = Elgato( entry.data[CONF_HOST], session=async_get_clientsession(hass), ) + # A firmware install gets the device to itself. It stops answering + # while it erases a flash slot, and enough traffic during that window + # takes its HTTP server down with it and restarts the light. + self.device_lock = asyncio.Lock() super().__init__( hass, LOGGER, @@ -59,15 +65,16 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]): async def _async_update_data(self) -> ElgatoData: """Fetch data from the Elgato device.""" try: - if self.has_battery is None: - self.has_battery = await self.client.has_battery() + async with self.device_lock: + if self.has_battery is None: + self.has_battery = await self.client.has_battery() - return ElgatoData( - battery=await self.client.battery() if self.has_battery else None, - info=await self.client.info(), - settings=await self.client.settings(), - state=await self.client.state(), - ) + return ElgatoData( + battery=await self.client.battery() if self.has_battery else None, + info=await self.client.info(), + settings=await self.client.settings(), + state=await self.client.state(), + ) except ElgatoConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, @@ -78,3 +85,40 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]): translation_domain=DOMAIN, translation_key="unknown_error", ) from err + + +class ElgatoFirmwareCoordinator(DataUpdateCoordinator[dict[int, FirmwareVersion]]): + """Class to manage fetching the firmware Elgato ships. + + Elgato publishes one catalog covering every model, so this is shared by + all Elgato devices rather than set up per config entry. It also lives on + Elgato's servers rather than the local network, and changes a handful of + times a year, so it runs on its own cadence. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the global Elgato firmware updater.""" + self.catalog = FirmwareCatalog(session=async_get_clientsession(hass)) + super().__init__( + hass, + LOGGER, + config_entry=None, + name=f"{DOMAIN}_firmware", + update_interval=FIRMWARE_SCAN_INTERVAL, + ) + + @override + async def _async_update_data(self) -> dict[int, FirmwareVersion]: + """Fetch the firmware Elgato currently ships, per board type.""" + try: + return await self.catalog.versions(refresh=True) + except ElgatoConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="firmware_communication_error", + ) from err + except ElgatoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="firmware_unknown_error", + ) from err diff --git a/homeassistant/components/elgato/helpers.py b/homeassistant/components/elgato/helpers.py index 12753e76212d..5e81bbc99ae7 100644 --- a/homeassistant/components/elgato/helpers.py +++ b/homeassistant/components/elgato/helpers.py @@ -34,20 +34,29 @@ def color_temperature_range(data: ElgatoData) -> tuple[int, int]: return COLOR_TEMPERATURE_RANGE -def elgato_exception_handler[_ElgatoEntityT: ElgatoEntity, **_P]( +def elgato_device_action[_ElgatoEntityT: ElgatoEntity, **_P]( func: Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, None]]: - """Decorate Elgato calls to handle Elgato exceptions. + """Decorate anything that asks something of an Elgato device. - A decorator that wraps the passed in function, catches Elgato errors, - and raises a translated ``HomeAssistantError``. + Three things every such call wants, in this order. + + It waits its turn, because a firmware install has the device to itself: + it answers nothing while it erases a flash slot, and enough traffic in + that window takes its HTTP server down and restarts the light. + + Elgato errors become a translated ``HomeAssistantError``. + + And the device is asked for its new state afterwards, outside the lock, + because that is another request and it has to queue like the rest. """ async def handler( self: _ElgatoEntityT, *args: _P.args, **kwargs: _P.kwargs ) -> None: try: - await func(self, *args, **kwargs) + async with self.coordinator.device_lock: + await func(self, *args, **kwargs) except ElgatoConnectionError as error: self.coordinator.last_update_success = False self.coordinator.async_update_listeners() @@ -61,4 +70,6 @@ def elgato_exception_handler[_ElgatoEntityT: ElgatoEntity, **_P]( translation_key="unknown_error", ) from error + await self.coordinator.async_request_refresh() + return handler diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index d223a24b6844..0f505a4d81e7 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -15,7 +15,7 @@ from homeassistant.util import color as color_util from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import color_temperature_range, elgato_exception_handler, supports_color +from .helpers import color_temperature_range, elgato_device_action, supports_color PARALLEL_UPDATES = 1 @@ -87,14 +87,13 @@ class ElgatoLight(ElgatoEntity, LightEntity): """Return the state of the light.""" return self.coordinator.data.state.on - @elgato_exception_handler + @elgato_device_action @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" await self.coordinator.client.light(on=False) - await self.coordinator.async_refresh() - @elgato_exception_handler + @elgato_device_action @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -135,9 +134,8 @@ class ElgatoLight(ElgatoEntity, LightEntity): saturation=saturation, temperature=temperature, ) - await self.coordinator.async_refresh() - @elgato_exception_handler + @elgato_device_action async def async_identify(self) -> None: """Identify the light, will make it blink.""" await self.coordinator.client.identify() diff --git a/homeassistant/components/elgato/number.py b/homeassistant/components/elgato/number.py index f6eebb4667f5..84f0f15dfa09 100644 --- a/homeassistant/components/elgato/number.py +++ b/homeassistant/components/elgato/number.py @@ -17,7 +17,7 @@ from homeassistant.util.color import ( from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import color_temperature_range, elgato_exception_handler +from .helpers import color_temperature_range, elgato_device_action PARALLEL_UPDATES = 1 @@ -122,9 +122,8 @@ class ElgatoNumberEntity(ElgatoEntity, NumberEntity): # 6535 K, above a maximum that cannot then be set again. return min(max(value, self.native_min_value), self.native_max_value) - @elgato_exception_handler + @elgato_device_action @override async def async_set_native_value(self, value: float) -> None: """Change the number value.""" await self.entity_description.set_fn(self.coordinator.client, value) - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/select.py b/homeassistant/components/elgato/select.py index fc7e7b0cf8d3..da4433fad5d1 100644 --- a/homeassistant/components/elgato/select.py +++ b/homeassistant/components/elgato/select.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import elgato_exception_handler +from .helpers import elgato_device_action PARALLEL_UPDATES = 1 @@ -92,9 +92,8 @@ class ElgatoSelectEntity(ElgatoEntity, SelectEntity): """Return the selected option.""" return self.entity_description.current_fn(self.coordinator.data) - @elgato_exception_handler + @elgato_device_action @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_fn(self.coordinator.client, option) - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index 6f813761f544..25de84d5c025 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -86,6 +86,15 @@ "communication_error": { "message": "An error occurred while communicating with the Elgato device." }, + "firmware_communication_error": { + "message": "An error occurred while downloading the firmware from Elgato." + }, + "firmware_install_error": { + "message": "The Elgato device did not accept the firmware: {error}" + }, + "firmware_unknown_error": { + "message": "An unknown error occurred while downloading the firmware from Elgato." + }, "unknown_error": { "message": "An unknown error occurred while communicating with the Elgato device." } diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index cf7f6ed42d02..cd2ebb260dda 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import elgato_exception_handler +from .helpers import elgato_device_action PARALLEL_UPDATES = 1 @@ -91,16 +91,14 @@ class ElgatoSwitchEntity(ElgatoEntity, SwitchEntity): """Return state of the switch.""" return self.entity_description.is_on_fn(self.coordinator.data) - @elgato_exception_handler + @elgato_device_action @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self.entity_description.set_fn(self.coordinator.client, True) - await self.coordinator.async_request_refresh() - @elgato_exception_handler + @elgato_device_action @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_description.set_fn(self.coordinator.client, False) - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/update.py b/homeassistant/components/elgato/update.py new file mode 100644 index 000000000000..d4aa29b2e3d8 --- /dev/null +++ b/homeassistant/components/elgato/update.py @@ -0,0 +1,276 @@ +"""Support for Elgato firmware updates.""" + +from datetime import datetime +from typing import Any, override + +from elgato import ( + ElgatoConnectionError, + ElgatoError, + ElgatoFirmwareError, + FirmwareImage, + FirmwareVersion, +) + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.const import EntityCategory +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later + +from . import ELGATO_KEY +from .const import DOMAIN +from .coordinator import ( + ElgatoConfigEntry, + ElgatoDataUpdateCoordinator, + ElgatoFirmwareCoordinator, +) +from .entity import ElgatoEntity +from .helpers import elgato_device_action + +PARALLEL_UPDATES = 1 + +# A device takes about a minute to come back after it swaps boot slots. This +# is the point at which one that never does stops being called installing. +REBOOT_TIMEOUT = 300 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ElgatoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Elgato firmware update based on a config entry.""" + async_add_entities([ElgatoUpdateEntity(entry.runtime_data, hass.data[ELGATO_KEY])]) + + +class ElgatoUpdateEntity(ElgatoEntity, UpdateEntity): + """Representation of the firmware on an Elgato Light. + + Elgato bumps the build number on every release but not always the version + in front of it, so two builds of 1.0.4 are a thing. Both numbers go into + the version string, which is what puts them in order. + """ + + _attr_device_class = UpdateDeviceClass.FIRMWARE + # Whether an install is running, and which build it is waiting to see. + # They are not the same thing: the download has no target build yet. + _installing: bool = False + _installing_build: int | None = None + _installing_timeout: CALLBACK_TYPE | None = None + _attr_entity_category = EntityCategory.CONFIG + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + def __init__( + self, + coordinator: ElgatoDataUpdateCoordinator, + firmware: ElgatoFirmwareCoordinator, + ) -> None: + """Initiate the Elgato firmware update.""" + super().__init__(coordinator) + + self.firmware = firmware + self._attr_unique_id = coordinator.data.info.serial_number + + @override + async def async_added_to_hass(self) -> None: + """Follow the firmware coordinator as well as the device one.""" + await super().async_added_to_hass() + self.async_on_remove( + self.firmware.async_add_listener(self.async_write_ha_state) + ) + # Otherwise the reboot timer outlives the entity it belongs to. + self.async_on_remove(self._installing_finished) + + @property + @override + def available(self) -> bool: + """Return if both the device and Elgato could be reached.""" + return super().available and self.firmware.last_update_success + + @override + async def async_update(self) -> None: + """Update the entity. + + Asking for an update check has to reach the catalog; the device + coordinator alone knows nothing about what Elgato ships. + """ + await super().async_update() + await self.firmware.async_request_refresh() + + @property + @override + def installed_version(self) -> str: + """Return the firmware currently on the device.""" + info = self.coordinator.data.info + return f"{info.firmware_version}.{info.firmware_build_number}" + + @property + @override + def in_progress(self) -> bool: + """Return if an install is still going on.""" + return self._installing + + @property + @override + def latest_version(self) -> str | None: + """Return the firmware Elgato currently ships for this device. + + The catalog covers every model, so a board Elgato ships nothing for + simply has no entry and this entity has nothing to compare against. + """ + if (latest := self._latest) is None: + return None + return f"{latest.version}.{latest.build_number}" + + @property + def _latest(self) -> FirmwareVersion | None: + """Return the entry in the catalog for the board of this device.""" + if not (catalog := self.firmware.data): + return None + return catalog.get(self.coordinator.data.info.hardware_board_type) + + @override + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the firmware Elgato ships for this device. + + A device answers that it accepted the reboot and then takes about a + minute to come back. This entity keeps saying it is installing until + the device reports the build it was given, so the old version does + not sit there looking finished while the light is still dark. + """ + # Before the download, not after: fetching the image is part of the + # install, and until this says so a second call walks straight past + # the guard that is meant to stop it. + self._installing = True + self._attr_update_percentage = None + self.async_write_ha_state() + + try: + # Downloading talks to Elgato, so it happens without the device + # lock. Holding it would park every light command behind a + # request to someone else's servers, for their timeout. + image = await self._download() + await self._upload(image) + except BaseException: + self._installing_finished() + raise + finally: + self.async_write_ha_state() + + self._installing_build = image.build_number + # A device that never comes back on the new firmware would otherwise + # leave this saying it is installing for good. + self._installing_timeout = async_call_later( + self.hass, REBOOT_TIMEOUT, self._installing_timed_out + ) + + @elgato_device_action + async def _upload(self, image: FirmwareImage) -> None: + """Hand the firmware to the device, which has it to itself.""" + try: + await self.coordinator.client.update_firmware( + image, on_progress=self._handle_progress + ) + except ElgatoFirmwareError as err: + # A device turns firmware away for reasons someone can act on: + # too little battery left, an image for another model. Say which. + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_install_error", + translation_placeholders={"error": str(err)}, + ) from err + + async def _download(self) -> FirmwareImage: + """Fetch the firmware image from Elgato. + + This is the half of the install that happens off the local network, + so it reports on the coordinator that covers it and says Elgato in + the message. Letting the handler around async_install see these would + mark the device coordinator failed and blame the light, over a + problem that is entirely at Elgato's end. + """ + try: + board_type = self.coordinator.data.info.hardware_board_type + return await self.firmware.catalog.download(board_type) + except ElgatoConnectionError as err: + self.firmware.async_set_update_error(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_communication_error", + ) from err + except ElgatoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_unknown_error", + ) from err + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Notice the device coming back on its new firmware.""" + if self.coordinator.last_update_success: + self._sync_device_firmware() + + if ( + self._installing_build is not None + and self.coordinator.data.info.firmware_build_number + >= self._installing_build + ): + self._installing_finished() + + super()._handle_coordinator_update() + + @callback + def _sync_device_firmware(self) -> None: + """Tell the device registry what the device is running now. + + DeviceInfo is read when an entity is added and not again, so without + this the device page keeps the firmware it had at setup. Which is the + version someone reads right after installing a new one. + """ + info = self.coordinator.data.info + version = f"{info.firmware_version} ({info.firmware_build_number})" + + registry = dr.async_get(self.hass) + device = registry.async_get_device_by_identifier( + (DOMAIN, info.serial_number), self.coordinator.config_entry.entry_id + ) + if device is not None and device.sw_version != version: + registry.async_update_device(device.id, sw_version=version) + + @callback + def _installing_finished(self) -> None: + """Stop reporting an install, however it ended.""" + self._installing = False + self._installing_build = None + self._attr_update_percentage = None + if self._installing_timeout is not None: + self._installing_timeout() + self._installing_timeout = None + + @callback + def _installing_timed_out(self, _now: datetime) -> None: + """Give up on a device that never came back. + + This entity changed its own mind, so it publishes that itself rather + than waiting for a coordinator update to come along and do it. + """ + self._installing_timeout = None + self._installing_finished() + self.async_write_ha_state() + + @callback + def _handle_progress(self, sent: int, total: int) -> None: + """Report how much of the firmware the device has taken.""" + self._attr_update_percentage = round(sent / total * 100) + self.async_write_ha_state() diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index afa89f8eb277..fa4379f26652 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -3,7 +3,15 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from elgato import BatteryInfo, ElgatoNoBatteryError, Info, Settings, State +from elgato import ( + BatteryInfo, + ElgatoNoBatteryError, + FirmwareImage, + FirmwareVersion, + Info, + Settings, + State, +) import pytest from homeassistant.components.elgato.const import DOMAIN @@ -92,6 +100,31 @@ def mock_elgato(device_fixtures: str, state_variant: str) -> Generator[MagicMock yield elgato +@pytest.fixture(autouse=True) +def mock_firmware_catalog() -> Generator[MagicMock]: + """Return a mocked Elgato firmware catalog. + + The catalog reads Elgato's servers, so this is autouse: no test gets to + reach them. The builds here are ahead of what the device fixtures + report, which leaves an update waiting by default. + """ + with patch( + "homeassistant.components.elgato.coordinator.FirmwareCatalog", autospec=True + ) as catalog_mock: + catalog = catalog_mock.return_value + catalog.versions.return_value = { + 53: FirmwareVersion(board_type=53, build_number=222, version="1.0.3"), + 202: FirmwareVersion(board_type=202, build_number=240, version="1.0.4"), + } + catalog.download.return_value = FirmwareImage( + board_type=53, + build_number=222, + version="1.0.3", + data=b"\x00" * 8192, + ) + yield catalog + + @pytest.fixture async def init_integration( hass: HomeAssistant, diff --git a/tests/components/elgato/snapshots/test_update.ambr b/tests/components/elgato/snapshots/test_update.ambr new file mode 100644 index 000000000000..53c40e491894 --- /dev/null +++ b/tests/components/elgato/snapshots/test_update.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_update[key-light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + : '/api/brands/integration/elgato/icon.png', + : 'Frenck Firmware', + : False, + : '1.0.3.192', + : '1.0.3.222', + : None, + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.frenck_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update[key-light].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.frenck_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'CN11A1A00001', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[key-light].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '53', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'CN11A1A00001', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light', + 'model_id': None, + 'name': 'Frenck', + 'name_by_user': None, + 'serial_number': 'CN11A1A00001', + 'sw_version': '1.0.3 (192)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/test_init.py b/tests/components/elgato/test_init.py index a6ff923beedd..c7d2140b5459 100644 --- a/tests/components/elgato/test_init.py +++ b/tests/components/elgato/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from elgato import ElgatoConnectionError +from elgato import ElgatoConnectionError, ElgatoError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -43,3 +43,19 @@ async def test_config_entry_not_ready( assert len(mock_elgato.state.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_unknown_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_elgato: MagicMock, +) -> None: + """Test the Elgato configuration entry failing on something else.""" + mock_elgato.state.side_effect = ElgatoError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_elgato.state.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/elgato/test_update.py b/tests/components/elgato/test_update.py new file mode 100644 index 000000000000..3b217cfb2f6d --- /dev/null +++ b/tests/components/elgato/test_update.py @@ -0,0 +1,630 @@ +"""Tests for the Elgato update platform.""" + +import asyncio +from collections.abc import Callable +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock + +from elgato import ( + ElgatoConnectionError, + ElgatoError, + ElgatoFirmwareError, + FirmwareImage, + FirmwareVersion, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.elgato import ELGATO_KEY +from homeassistant.components.elgato.const import ( + DOMAIN, + FIRMWARE_SCAN_INTERVAL, + SCAN_INTERVAL, +) +from homeassistant.components.elgato.update import REBOOT_TIMEOUT +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_UPDATE_PERCENTAGE, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_MAC, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_fire_time_changed + +ENTITY_ID = "update.frenck_firmware" + +pytestmark = [ + pytest.mark.parametrize("device_fixtures", ["key-light"]), + pytest.mark.usefixtures("device_fixtures", "init_integration"), +] + + +async def test_update( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Elgato firmware update entity.""" + assert (state := hass.states.get(ENTITY_ID)) + assert state == snapshot + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(ENTITY_ID)) + assert entry == snapshot + + assert entry.device_id + assert (device_entry := device_registry.async_get(entry.device_id)) + assert device_entry == snapshot + + +async def test_up_to_date( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a device already running what Elgato ships. + + The device fixture reports build 192, so the catalog is pulled back to + match it. + """ + mock_firmware_catalog.versions.return_value = { + 53: FirmwareVersion(board_type=53, build_number=192, version="1.0.3") + } + freezer.tick(FIRMWARE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + +async def test_elgato_ships_nothing_for_this_board( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a board Elgato publishes no firmware for. + + Nothing to compare against is not an error, it just leaves the entity + with no opinion. + """ + mock_firmware_catalog.versions.return_value = {} + freezer.tick(FIRMWARE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + +async def test_install( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_firmware_catalog: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test installing the firmware Elgato ships.""" + reported: list[int | None] = [] + in_progress_while_downloading = None + + async def download(board_type: int) -> FirmwareImage: + """Stand in for fetching the image off Elgato's servers.""" + nonlocal in_progress_while_downloading + in_progress_while_downloading = hass.states.get(ENTITY_ID).attributes[ + ATTR_IN_PROGRESS + ] + return FirmwareImage( + board_type=board_type, + build_number=222, + version="1.0.3", + data=b"\x00" * 8192, + ) + + async def install( + image: FirmwareImage, + *, + on_progress: Callable[[int, int], None] | None = None, + ) -> None: + """Stand in for a device taking a firmware image.""" + assert on_progress is not None + for sent in (4096, 8192): + on_progress(sent, len(image.data)) + reported.append( + hass.states.get(ENTITY_ID).attributes[ATTR_UPDATE_PERCENTAGE] + ) + + mock_firmware_catalog.download.side_effect = download + mock_elgato.update_firmware.side_effect = install + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + # Fetching the image is part of the install, so the entity says so + # before it starts rather than after. + assert in_progress_while_downloading is True + + mock_firmware_catalog.download.assert_called_once_with(53) + mock_elgato.update_firmware.assert_called_once() + assert reported == [50, 100] + + # Still installing: the device took the firmware and is restarting. + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is True + + # It comes back on the build it was given. + mock_elgato.info.return_value.firmware_build_number = 222 + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is False + assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None + assert state.state == STATE_OFF + + +async def test_install_that_never_comes_back( + hass: HomeAssistant, + mock_elgato: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a device that takes the firmware and never reports it. + + Without a way out, the entity would sit there saying it is installing + for as long as Home Assistant runs. + """ + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is True + + freezer.tick(timedelta(seconds=REBOOT_TIMEOUT + 1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is False + + +async def test_install_on_a_device_that_stays_away( + hass: HomeAssistant, + mock_elgato: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a device that takes the firmware and never answers again. + + It does not sit there claiming to install. An entity whose device is + gone is unavailable, and that is what it says. + """ + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_elgato.state.side_effect = ElgatoConnectionError + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNAVAILABLE + + +async def test_a_second_install_is_turned_away( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test two installs at once do not both reach the device.""" + + async def slow(image: FirmwareImage, **kwargs: Any) -> None: + """Take long enough for the second call to arrive.""" + await asyncio.sleep(0) + + mock_elgato.update_firmware.side_effect = slow + + results = await asyncio.gather( + *[ + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + for _ in range(2) + ], + return_exceptions=True, + ) + + assert sum(isinstance(result, HomeAssistantError) for result in results) == 1 + assert mock_elgato.update_firmware.call_count == 1 + + +async def test_catalog_refresh_during_an_install( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_firmware_catalog: MagicMock, +) -> None: + """Test the catalog refreshing while an install is running. + + What Elgato ships says nothing about whether this device is done, so a + refresh in the middle must not report the install as finished. + """ + + async def install(image: FirmwareImage, **kwargs: Any) -> None: + """Let Elgato publish something while the device is busy.""" + await hass.data[ELGATO_KEY].async_refresh() + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is True + + mock_elgato.update_firmware.side_effect = install + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert mock_elgato.update_firmware.call_count == 1 + + +async def test_download_does_not_hold_the_device( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_firmware_catalog: MagicMock, +) -> None: + """Test fetching the image leaves the device free. + + Downloading talks to Elgato. If it held the device lock, a slow or + unreachable Elgato would park every light command behind it for the + length of their timeout. + """ + coordinator = hass.config_entries.async_entries(DOMAIN)[0].runtime_data + locked_while_downloading = None + locked_while_uploading = None + + async def download(board_type: int) -> FirmwareImage: + nonlocal locked_while_downloading + locked_while_downloading = coordinator.device_lock.locked() + return FirmwareImage( + board_type=board_type, + build_number=222, + version="1.0.3", + data=b"\x00" * 8192, + ) + + async def install(image: FirmwareImage, **kwargs: Any) -> None: + nonlocal locked_while_uploading + locked_while_uploading = coordinator.device_lock.locked() + + mock_firmware_catalog.download.side_effect = download + mock_elgato.update_firmware.side_effect = install + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert locked_while_downloading is False + assert locked_while_uploading is True + + +async def test_device_page_follows_the_firmware( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_elgato: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the device page shows the firmware after an install. + + DeviceInfo is read when an entity is added and not again, so the version + someone reads right after installing would otherwise be the old one. + """ + device = device_registry.async_get_device_by_identifier( + (DOMAIN, "CN11A1A00001"), + hass.config_entries.async_entries(DOMAIN)[0].entry_id, + ) + assert device is not None + assert device.sw_version == "1.0.3 (192)" + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_elgato.info.return_value.firmware_build_number = 222 + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, "CN11A1A00001"), + hass.config_entries.async_entries(DOMAIN)[0].entry_id, + ) + assert device is not None + assert device.sw_version == "1.0.3 (222)" + + +async def test_install_error( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test a device refusing the firmware it was handed.""" + mock_elgato.update_firmware.side_effect = ElgatoError + + with pytest.raises( + HomeAssistantError, + match="An unknown error occurred while communicating with the Elgato device", + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is False + + +@pytest.mark.parametrize( + "side_effect", + [ElgatoConnectionError, ElgatoError], +) +async def test_elgato_unreachable( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, + freezer: FrozenDateTimeFactory, + side_effect: type[Exception], +) -> None: + """Test Elgato's servers being unreachable. + + The light is on the local network and Elgato is not, so a bad day at + their end costs the latest version and nothing else. The light and its + other entities carry on. + """ + mock_firmware_catalog.versions.side_effect = side_effect + freezer.tick(FIRMWARE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNAVAILABLE + + assert (light := hass.states.get("light.frenck")) + assert light.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("side_effect", "message", "still_reachable"), + [ + ( + ElgatoConnectionError, + "An error occurred while downloading the firmware from Elgato", + False, + ), + ( + ElgatoFirmwareError, + "An unknown error occurred while downloading the firmware from Elgato", + True, + ), + ], +) +async def test_download_failure_leaves_the_light_alone( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_firmware_catalog: MagicMock, + side_effect: type[Exception], + message: str, + still_reachable: bool, +) -> None: + """Test Elgato failing to hand over the image. + + Only reaching Elgato says anything about this entity; an image that + arrives and fails to verify means Elgato answered, just badly. + """ + mock_firmware_catalog.download.side_effect = side_effect + + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_elgato.update_firmware.assert_not_called() + + assert (light := hass.states.get("light.frenck")) + assert light.state != STATE_UNAVAILABLE + + assert (state := hass.states.get(ENTITY_ID)) + assert (state.state != STATE_UNAVAILABLE) is still_reachable + + +async def test_install_rejected_by_the_device( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test a device turning the firmware away for a reason worth reading.""" + mock_elgato.update_firmware.side_effect = ElgatoFirmwareError( + "Battery is at 11%, connect the device to power before updating its firmware" + ) + + with pytest.raises(HomeAssistantError, match="Battery is at 11%"): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + +async def test_install_keeps_the_device_to_itself( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test a poll cannot land in the middle of an install. + + A device stops answering while it erases a flash slot, and enough traffic + during that window takes its HTTP server down and restarts the light. + """ + coordinator = hass.config_entries.async_entries(DOMAIN)[0].runtime_data + refresh: asyncio.Task[None] | None = None + polls_during_install = 0 + + async def install(image: FirmwareImage, **kwargs: Any) -> None: + """Ask for a refresh while the device is busy taking firmware.""" + nonlocal refresh, polls_during_install + before = mock_elgato.state.call_count + refresh = hass.async_create_task(coordinator.async_refresh()) + for _ in range(5): + await asyncio.sleep(0) + polls_during_install = mock_elgato.state.call_count - before + + mock_elgato.update_firmware.side_effect = install + polls_before = mock_elgato.state.call_count + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert refresh is not None + await refresh + + assert polls_during_install == 0 + # And it is not blocked forever; the poll lands once the install is done. + assert mock_elgato.state.call_count > polls_before + + +async def test_manual_update_check( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, +) -> None: + """Test asking for an update check reaches Elgato. + + The device coordinator knows what the light runs; only the catalog knows + what Elgato ships, and that is the half being asked about. + """ + await async_setup_component(hass, HA_DOMAIN, {}) + checks_before = mock_firmware_catalog.versions.call_count + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert mock_firmware_catalog.versions.call_count > checks_before + + +async def test_install_keeps_the_device_from_everyone( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test a light command cannot land in the middle of an install either. + + Polling is not the only thing that talks to the device; every button, + switch and light action does too. + """ + turn_on: asyncio.Task[None] | None = None + commands_during_install = 0 + + async def install(image: FirmwareImage, **kwargs: Any) -> None: + """Ask the light to turn on while the device is taking firmware.""" + nonlocal turn_on, commands_during_install + before = mock_elgato.light.call_count + turn_on = hass.async_create_task( + hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.frenck"}, + blocking=True, + ) + ) + for _ in range(5): + await asyncio.sleep(0) + commands_during_install = mock_elgato.light.call_count - before + + mock_elgato.update_firmware.side_effect = install + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert turn_on is not None + await turn_on + + assert commands_during_install == 0 + assert mock_elgato.light.call_count == 1 + + +async def test_one_catalog_for_every_device( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a second device does not fetch the catalog all over again. + + Elgato publishes one catalog covering every model, so it is read once + and shared, not once per config entry. + """ + calls_for_one_device = mock_firmware_catalog.versions.call_count + + second = MockConfigEntry( + title="CN11A1A00002", + domain=DOMAIN, + data={CONF_HOST: "127.0.0.2", CONF_MAC: "AA:BB:CC:DD:EE:00"}, + unique_id="CN11A1A00002", + ) + second.add_to_hass(hass) + await hass.config_entries.async_setup(second.entry_id) + await hass.async_block_till_done() + + assert mock_firmware_catalog.versions.call_count == calls_for_one_device