Add an Elgato firmware update entity (#180786)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Franck Nijhof
2026-08-30 21:30:15 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 9d37238adf
commit 004423f562
15 changed files with 1176 additions and 38 deletions
+25 -2
View File
@@ -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
+2 -2
View File
@@ -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."""
+4
View File
@@ -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"
+54 -10
View File
@@ -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
+16 -5
View File
@@ -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
+4 -6
View File
@@ -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()
+2 -3
View File
@@ -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()
+2 -3
View File
@@ -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()
@@ -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."
}
+3 -5
View File
@@ -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()
+276
View File
@@ -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()
+34 -1
View File
@@ -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,
@@ -0,0 +1,98 @@
# serializer version: 1
# name: test_update[key-light]
StateSnapshot({
'attributes': ReadOnlyDict({
<UpdateEntityStateAttribute.AUTO_UPDATE: 'auto_update'>: False,
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'firmware',
<UpdateEntityStateAttribute.DISPLAY_PRECISION: 'display_precision'>: 0,
<EntityStateAttribute.ENTITY_PICTURE: 'entity_picture'>: '/api/brands/integration/elgato/icon.png',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Frenck Firmware',
<UpdateEntityStateAttribute.IN_PROGRESS: 'in_progress'>: False,
<UpdateEntityStateAttribute.INSTALLED_VERSION: 'installed_version'>: '1.0.3.192',
<UpdateEntityStateAttribute.LATEST_VERSION: 'latest_version'>: '1.0.3.222',
<UpdateEntityStateAttribute.RELEASE_SUMMARY: 'release_summary'>: None,
<UpdateEntityStateAttribute.RELEASE_URL: 'release_url'>: None,
<UpdateEntityStateAttribute.SKIPPED_VERSION: 'skipped_version'>: None,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <UpdateEntityFeature: 5>,
<UpdateEntityStateAttribute.TITLE: 'title'>: None,
<UpdateEntityStateAttribute.UPDATE_PERCENTAGE: 'update_percentage'>: None,
}),
'context': <ANY>,
'entity_id': 'update.frenck_firmware',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_update[key-light].1
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'update.frenck_firmware',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Firmware',
'options': dict({
}),
'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>,
'original_icon': None,
'original_name': 'Firmware',
'platform': 'elgato',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <UpdateEntityFeature: 5>,
'translation_key': None,
'unique_id': 'CN11A1A00001',
'unit_of_measurement': None,
})
# ---
# name: test_update[key-light].2
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': '53',
'id': <ANY>,
'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,
})
# ---
+17 -1
View File
@@ -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
+630
View File
@@ -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