mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
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:
co-authored by
Copilot Autofix powered by AI
parent
9d37238adf
commit
004423f562
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user