Files
core/homeassistant/components/zwave_js/update.py
2025-08-11 14:09:04 +02:00

429 lines
16 KiB
Python

"""Representation of Z-Wave updates."""
from __future__ import annotations
import asyncio
from collections import Counter
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Final, cast
from awesomeversion import AwesomeVersion
from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.firmware import (
FirmwareUpdateInfo,
FirmwareUpdateProgress,
FirmwareUpdateResult,
)
from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.node.firmware import NodeFirmwareUpdateInfo
from homeassistant.components.update import (
ATTR_LATEST_VERSION,
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.const import EntityCategory
from homeassistant.core import CoreState, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import ExtraStoredData
from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DOMAIN, LOGGER
from .helpers import get_device_info, get_valueless_base_unique_id
from .models import ZwaveJSConfigEntry
PARALLEL_UPDATES = 1
UPDATE_DELAY_STRING = "delay"
UPDATE_DELAY_INTERVAL = 15 # In seconds
ATTR_LATEST_VERSION_FIRMWARE = "latest_version_firmware"
@dataclass(frozen=True, kw_only=True)
class ZWaveUpdateEntityDescription(UpdateEntityDescription):
"""Class describing Z-Wave update entity."""
install_method: Callable[
[ZWaveFirmwareUpdateEntity, FirmwareUpdateInfo],
Awaitable[FirmwareUpdateResult],
]
progress_method: Callable[[ZWaveFirmwareUpdateEntity], Callable[[], None]]
finished_method: Callable[[ZWaveFirmwareUpdateEntity], Callable[[], None]]
CONTROLLER_UPDATE_ENTITY_DESCRIPTION = ZWaveUpdateEntityDescription(
key="controller_firmware_update",
install_method=(
lambda entity, firmware_update_info: entity.driver.async_firmware_update_otw(
update_info=firmware_update_info
)
),
progress_method=lambda entity: entity.driver.on(
"firmware update progress", entity.update_progress
),
finished_method=lambda entity: entity.driver.on(
"firmware update finished", entity.update_finished
),
)
NODE_UPDATE_ENTITY_DESCRIPTION = ZWaveUpdateEntityDescription(
key="node_firmware_update",
install_method=(
lambda entity,
firmware_update_info: entity.driver.controller.async_firmware_update_ota(
entity.node, cast(NodeFirmwareUpdateInfo, firmware_update_info)
)
),
progress_method=lambda entity: entity.node.on(
"firmware update progress", entity.update_progress
),
finished_method=lambda entity: entity.node.on(
"firmware update finished", entity.update_finished
),
)
@dataclass
class ZWaveFirmwareUpdateExtraStoredData(ExtraStoredData):
"""Extra stored data for Z-Wave node firmware update entity."""
latest_version_firmware: FirmwareUpdateInfo | None
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the extra data."""
return {
ATTR_LATEST_VERSION_FIRMWARE: self.latest_version_firmware.to_dict()
if self.latest_version_firmware
else None
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ZWaveFirmwareUpdateExtraStoredData:
"""Initialize the extra data from a dict."""
# If there was no firmware info stored, or if it's stale info, we don't restore
# anything.
if (
not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE])
or "normalizedVersion" not in firmware_dict
):
return cls(None)
return cls(FirmwareUpdateInfo.from_dict(firmware_dict))
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ZwaveJSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave update entity from config entry."""
client = config_entry.runtime_data.client
cnt: Counter = Counter()
@callback
def async_add_firmware_update_entity(node: ZwaveNode) -> None:
"""Add firmware update entity."""
# Delay the first update of each entity to avoid spamming the firmware server.
# Maintain a counter to schedule first update in UPDATE_DELAY_INTERVAL
# second increments.
cnt[UPDATE_DELAY_STRING] += 1
delay = timedelta(seconds=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL))
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
if node.is_controller_node:
# If the node is a controller, we create a controller firmware update entity
entity = ZWaveFirmwareUpdateEntity(
driver,
node,
delay=delay,
entity_description=CONTROLLER_UPDATE_ENTITY_DESCRIPTION,
)
else:
# If the node is not a controller, we create a node firmware update entity
entity = ZWaveFirmwareUpdateEntity(
driver,
node,
delay=delay,
entity_description=NODE_UPDATE_ENTITY_DESCRIPTION,
)
async_add_entities([entity])
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{config_entry.entry_id}_add_firmware_update_entity",
async_add_firmware_update_entity,
)
)
class ZWaveFirmwareUpdateEntity(UpdateEntity):
"""Representation of a firmware update entity."""
driver: Driver
entity_description: ZWaveUpdateEntityDescription
node: ZwaveNode
_attr_entity_category = EntityCategory.CONFIG
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = (
UpdateEntityFeature.INSTALL
| UpdateEntityFeature.RELEASE_NOTES
| UpdateEntityFeature.PROGRESS
)
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
driver: Driver,
node: ZwaveNode,
delay: timedelta,
entity_description: ZWaveUpdateEntityDescription,
) -> None:
"""Initialize a Z-Wave device firmware update entity."""
self.driver = driver
self.entity_description = entity_description
self.node = node
self._latest_version_firmware: FirmwareUpdateInfo | None = None
self._poll_unsub: Callable[[], None] | None = None
self._progress_unsub: Callable[[], None] | None = None
self._finished_unsub: Callable[[], None] | None = None
self._finished_event = asyncio.Event()
self._result: FirmwareUpdateResult | None = None
self._delay: Final[timedelta] = delay
# Entity class attributes
self._attr_name = "Firmware"
self._base_unique_id = get_valueless_base_unique_id(driver, node)
self._attr_unique_id = f"{self._base_unique_id}.firmware_update"
self._attr_installed_version = node.firmware_version
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
@property
def extra_restore_state_data(self) -> ZWaveFirmwareUpdateExtraStoredData:
"""Return ZWave Node Firmware Update specific state data to be restored."""
return ZWaveFirmwareUpdateExtraStoredData(self._latest_version_firmware)
@callback
def update_progress(self, event: dict[str, Any]) -> None:
"""Update install progress on event."""
progress: FirmwareUpdateProgress = event["firmware_update_progress"]
if not self._latest_version_firmware:
return
self._attr_in_progress = True
self._attr_update_percentage = int(progress.progress)
self.async_write_ha_state()
@callback
def update_finished(self, event: dict[str, Any]) -> None:
"""Update install progress on event."""
result: FirmwareUpdateResult = event["firmware_update_finished"]
self._result = result
self._finished_event.set()
@callback
def _unsub_firmware_events_and_reset_progress(
self, write_state: bool = True
) -> None:
"""Unsubscribe from firmware events and reset update install progress."""
if self._progress_unsub:
self._progress_unsub()
self._progress_unsub = None
if self._finished_unsub:
self._finished_unsub()
self._finished_unsub = None
self._result = None
self._finished_event.clear()
self._attr_in_progress = False
self._attr_update_percentage = None
if write_state:
self.async_write_ha_state()
async def _async_update(self, _: HomeAssistant | datetime | None = None) -> None:
"""Update the entity."""
if self._poll_unsub:
self._poll_unsub()
self._poll_unsub = None
# If hass hasn't started yet, push the next update to the next day so that we
# can preserve the offsets we've created between each node
if self.hass.state is not CoreState.running:
self._poll_unsub = async_call_later(
self.hass, timedelta(days=1), self._async_update
)
return
try:
# Retrieve all firmware updates including non-stable ones but filter
# non-stable channels out
available_firmware_updates = [
update
for update in await self.driver.controller.async_get_available_firmware_updates(
self.node, API_KEY_FIRMWARE_UPDATE_SERVICE, True
)
if update.channel == "stable"
]
except FailedZWaveCommand as err:
LOGGER.debug(
"Failed to get firmware updates for node %s: %s",
self.node.node_id,
err,
)
else:
# If we have an available firmware update that is a higher version than
# what's on the node, we should advertise it, otherwise the installed
# version is the latest.
if (
available_firmware_updates
and (
latest_firmware := max(
available_firmware_updates,
key=lambda x: AwesomeVersion(x.version),
)
)
and AwesomeVersion(latest_firmware.version)
> AwesomeVersion(self.node.firmware_version)
):
self._latest_version_firmware = latest_firmware
self._attr_latest_version = latest_firmware.version
self.async_write_ha_state()
elif self._attr_latest_version != self._attr_installed_version:
self._attr_latest_version = self._attr_installed_version
self.async_write_ha_state()
finally:
self._poll_unsub = async_call_later(
self.hass, timedelta(days=1), self._async_update
)
async def async_release_notes(self) -> str | None:
"""Get release notes."""
if self._latest_version_firmware is None:
return None
return self._latest_version_firmware.changelog
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
firmware = self._latest_version_firmware
assert firmware
self._unsub_firmware_events_and_reset_progress(False)
self._attr_in_progress = True
self._attr_update_percentage = None
self.async_write_ha_state()
self._progress_unsub = self.entity_description.progress_method(self)
self._finished_unsub = self.entity_description.finished_method(self)
try:
await self.entity_description.install_method(self, firmware)
except BaseZwaveJSServerError as err:
self._unsub_firmware_events_and_reset_progress()
raise HomeAssistantError(err) from err
# We need to block until we receive the `firmware update finished` event
await self._finished_event.wait()
assert self._result is not None
# If the update was not successful, we should throw an error
# to let the user know
if not self._result.success:
error_msg = self._result.status.name.replace("_", " ").title()
self._unsub_firmware_events_and_reset_progress()
raise HomeAssistantError(error_msg)
# If we get here, all files were installed successfully
self._attr_installed_version = self._attr_latest_version = firmware.version
self._latest_version_firmware = None
self._unsub_firmware_events_and_reset_progress()
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
# We log an error instead of raising an exception because this service call occurs
# in a separate task since it is called via the dispatcher and we don't want to
# raise the exception in that separate task because it is confusing to the user.
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value"
" service won't work for it"
)
async def async_added_to_hass(self) -> None:
"""Call when entity is added."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_poll_value",
self.async_poll_value,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity",
self.async_remove,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started",
self.async_remove,
)
)
# Make sure these variables are set for the elif evaluation
state = None
latest_version = None
# If we have a complete previous state, use that to set the latest version
if (
(state := await self.async_get_last_state())
and (latest_version := state.attributes.get(ATTR_LATEST_VERSION))
is not None
and (extra_data := await self.async_get_last_extra_data())
and (
latest_version_firmware := ZWaveFirmwareUpdateExtraStoredData.from_dict(
extra_data.as_dict()
).latest_version_firmware
)
):
self._attr_latest_version = latest_version
self._latest_version_firmware = latest_version_firmware
# If we have no state or latest version to restore, or the latest version is
# the same as the installed version, we can set the latest
# version to installed so that the entity starts as off. If we have partial
# restore data due to an upgrade to an HA version where this feature is released
# from one that is not the entity will start in an unknown state until we can
# correct on next update
elif (
not state
or not latest_version
or latest_version == self._attr_installed_version
):
self._attr_latest_version = self._attr_installed_version
# Spread updates out in 15 second increments
# to avoid spamming the firmware server
self.async_on_remove(
async_call_later(self.hass, self._delay, self._async_update)
)
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed."""
if self._poll_unsub:
self._poll_unsub()
self._poll_unsub = None
self._unsub_firmware_events_and_reset_progress(False)