Home Assistant Unofficial Reference 2024.12.1
update.py
Go to the documentation of this file.
1 """Representation of Z-Wave updates."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections import Counter
7 from collections.abc import Callable
8 from dataclasses import dataclass
9 from datetime import datetime, timedelta
10 from typing import Any, Final
11 
12 from awesomeversion import AwesomeVersion
13 from zwave_js_server.client import Client as ZwaveClient
14 from zwave_js_server.const import NodeStatus
15 from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand
16 from zwave_js_server.model.driver import Driver
17 from zwave_js_server.model.node import Node as ZwaveNode
18 from zwave_js_server.model.node.firmware import (
19  NodeFirmwareUpdateInfo,
20  NodeFirmwareUpdateProgress,
21  NodeFirmwareUpdateResult,
22 )
23 
25  ATTR_LATEST_VERSION,
26  UpdateDeviceClass,
27  UpdateEntity,
28  UpdateEntityFeature,
29 )
30 from homeassistant.config_entries import ConfigEntry
31 from homeassistant.const import EntityCategory
32 from homeassistant.core import CoreState, HomeAssistant, callback
33 from homeassistant.exceptions import HomeAssistantError
34 from homeassistant.helpers.dispatcher import async_dispatcher_connect
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 from homeassistant.helpers.event import async_call_later
37 from homeassistant.helpers.restore_state import ExtraStoredData
38 
39 from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DATA_CLIENT, DOMAIN, LOGGER
40 from .helpers import get_device_info, get_valueless_base_unique_id
41 
42 PARALLEL_UPDATES = 1
43 
44 UPDATE_DELAY_STRING = "delay"
45 UPDATE_DELAY_INTERVAL = 5 # In minutes
46 ATTR_LATEST_VERSION_FIRMWARE = "latest_version_firmware"
47 
48 
49 @dataclass
51  """Extra stored data for Z-Wave node firmware update entity."""
52 
53  latest_version_firmware: NodeFirmwareUpdateInfo | None
54 
55  def as_dict(self) -> dict[str, Any]:
56  """Return a dict representation of the extra data."""
57  return {
58  ATTR_LATEST_VERSION_FIRMWARE: self.latest_version_firmware.to_dict()
59  if self.latest_version_firmware
60  else None
61  }
62 
63  @classmethod
64  def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData:
65  """Initialize the extra data from a dict."""
66  # If there was no firmware info stored, or if it's stale info, we don't restore
67  # anything.
68  if (
69  not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE])
70  or "normalizedVersion" not in firmware_dict
71  ):
72  return cls(None)
73 
74  return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict))
75 
76 
78  hass: HomeAssistant,
79  config_entry: ConfigEntry,
80  async_add_entities: AddEntitiesCallback,
81 ) -> None:
82  """Set up Z-Wave update entity from config entry."""
83  client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
84  cnt: Counter = Counter()
85 
86  @callback
87  def async_add_firmware_update_entity(node: ZwaveNode) -> None:
88  """Add firmware update entity."""
89  # We need to delay the first update of each entity to avoid flooding the network
90  # so we maintain a counter to schedule first update in UPDATE_DELAY_INTERVAL
91  # minute increments.
92  cnt[UPDATE_DELAY_STRING] += 1
93  delay = timedelta(minutes=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL))
94  driver = client.driver
95  assert driver is not None # Driver is ready before platforms are loaded.
96  async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, delay)])
97 
98  config_entry.async_on_unload(
100  hass,
101  f"{DOMAIN}_{config_entry.entry_id}_add_firmware_update_entity",
102  async_add_firmware_update_entity,
103  )
104  )
105 
106 
108  """Representation of a firmware update entity."""
109 
110  _attr_entity_category = EntityCategory.CONFIG
111  _attr_device_class = UpdateDeviceClass.FIRMWARE
112  _attr_supported_features = (
113  UpdateEntityFeature.INSTALL
114  | UpdateEntityFeature.RELEASE_NOTES
115  | UpdateEntityFeature.PROGRESS
116  )
117  _attr_has_entity_name = True
118  _attr_should_poll = False
119 
120  def __init__(self, driver: Driver, node: ZwaveNode, delay: timedelta) -> None:
121  """Initialize a Z-Wave device firmware update entity."""
122  self.driverdriver = driver
123  self.nodenode = node
124  self._latest_version_firmware_latest_version_firmware: NodeFirmwareUpdateInfo | None = None
125  self._status_unsub_status_unsub: Callable[[], None] | None = None
126  self._poll_unsub_poll_unsub: Callable[[], None] | None = None
127  self._progress_unsub_progress_unsub: Callable[[], None] | None = None
128  self._finished_unsub_finished_unsub: Callable[[], None] | None = None
129  self._finished_event_finished_event = asyncio.Event()
130  self._result_result: NodeFirmwareUpdateResult | None = None
131  self._delay: Final[timedelta] = delay
132 
133  # Entity class attributes
134  self._attr_name_attr_name = "Firmware"
135  self._base_unique_id_base_unique_id = get_valueless_base_unique_id(driver, node)
136  self._attr_unique_id_attr_unique_id = f"{self._base_unique_id}.firmware_update"
137  self._attr_installed_version_attr_installed_version = node.firmware_version
138  # device may not be precreated in main handler yet
139  self._attr_device_info_attr_device_info = get_device_info(driver, node)
140 
141  @property
142  def extra_restore_state_data(self) -> ZWaveNodeFirmwareUpdateExtraStoredData:
143  """Return ZWave Node Firmware Update specific state data to be restored."""
144  return ZWaveNodeFirmwareUpdateExtraStoredData(self._latest_version_firmware_latest_version_firmware)
145 
146  @callback
147  def _update_on_status_change(self, _: dict[str, Any]) -> None:
148  """Update the entity when node is awake."""
149  self._status_unsub_status_unsub = None
150  self.hasshass.async_create_task(self._async_update_async_update())
151 
152  @callback
153  def _update_progress(self, event: dict[str, Any]) -> None:
154  """Update install progress on event."""
155  progress: NodeFirmwareUpdateProgress = event["firmware_update_progress"]
156  if not self._latest_version_firmware_latest_version_firmware:
157  return
158  self._attr_in_progress_attr_in_progress_attr_in_progress = True
159  self._attr_update_percentage_attr_update_percentage = int(progress.progress)
160  self.async_write_ha_stateasync_write_ha_state()
161 
162  @callback
163  def _update_finished(self, event: dict[str, Any]) -> None:
164  """Update install progress on event."""
165  result: NodeFirmwareUpdateResult = event["firmware_update_finished"]
166  self._result_result = result
167  self._finished_event_finished_event.set()
168 
169  @callback
171  self, write_state: bool = True
172  ) -> None:
173  """Unsubscribe from firmware events and reset update install progress."""
174  if self._progress_unsub_progress_unsub:
175  self._progress_unsub_progress_unsub()
176  self._progress_unsub_progress_unsub = None
177 
178  if self._finished_unsub_finished_unsub:
179  self._finished_unsub_finished_unsub()
180  self._finished_unsub_finished_unsub = None
181 
182  self._result_result = None
183  self._finished_event_finished_event.clear()
184  self._attr_in_progress_attr_in_progress_attr_in_progress = False
185  self._attr_update_percentage_attr_update_percentage = None
186  if write_state:
187  self.async_write_ha_stateasync_write_ha_state()
188 
189  async def _async_update(self, _: HomeAssistant | datetime | None = None) -> None:
190  """Update the entity."""
191  if self._poll_unsub_poll_unsub:
192  self._poll_unsub_poll_unsub()
193  self._poll_unsub_poll_unsub = None
194 
195  # If hass hasn't started yet, push the next update to the next day so that we
196  # can preserve the offsets we've created between each node
197  if self.hasshass.state is not CoreState.running:
198  self._poll_unsub_poll_unsub = async_call_later(
199  self.hasshass, timedelta(days=1), self._async_update_async_update
200  )
201  return
202 
203  # If device is asleep/dead, wait for it to wake up/become alive before
204  # attempting an update
205  for status, event_name in (
206  (NodeStatus.ASLEEP, "wake up"),
207  (NodeStatus.DEAD, "alive"),
208  ):
209  if self.nodenode.status == status:
210  if not self._status_unsub_status_unsub:
211  self._status_unsub_status_unsub = self.nodenode.once(
212  event_name, self._update_on_status_change_update_on_status_change
213  )
214  return
215 
216  try:
217  # Retrieve all firmware updates including non-stable ones but filter
218  # non-stable channels out
219  available_firmware_updates = [
220  update
221  for update in await self.driverdriver.controller.async_get_available_firmware_updates(
222  self.nodenode, API_KEY_FIRMWARE_UPDATE_SERVICE, True
223  )
224  if update.channel == "stable"
225  ]
226  except FailedZWaveCommand as err:
227  LOGGER.debug(
228  "Failed to get firmware updates for node %s: %s",
229  self.nodenode.node_id,
230  err,
231  )
232  else:
233  # If we have an available firmware update that is a higher version than
234  # what's on the node, we should advertise it, otherwise the installed
235  # version is the latest.
236  if (
237  available_firmware_updates
238  and (
239  latest_firmware := max(
240  available_firmware_updates,
241  key=lambda x: AwesomeVersion(x.version),
242  )
243  )
244  and AwesomeVersion(latest_firmware.version)
245  > AwesomeVersion(self.nodenode.firmware_version)
246  ):
247  self._latest_version_firmware_latest_version_firmware = latest_firmware
248  self._attr_latest_version_attr_latest_version = latest_firmware.version
249  self.async_write_ha_stateasync_write_ha_state()
250  elif self._attr_latest_version_attr_latest_version != self._attr_installed_version_attr_installed_version:
251  self._attr_latest_version_attr_latest_version = self._attr_installed_version_attr_installed_version
252  self.async_write_ha_stateasync_write_ha_state()
253  finally:
254  self._poll_unsub_poll_unsub = async_call_later(
255  self.hasshass, timedelta(days=1), self._async_update_async_update
256  )
257 
258  async def async_release_notes(self) -> str | None:
259  """Get release notes."""
260  if self._latest_version_firmware_latest_version_firmware is None:
261  return None
262  return self._latest_version_firmware_latest_version_firmware.changelog
263 
264  async def async_install(
265  self, version: str | None, backup: bool, **kwargs: Any
266  ) -> None:
267  """Install an update."""
268  firmware = self._latest_version_firmware_latest_version_firmware
269  assert firmware
270  self._unsub_firmware_events_and_reset_progress_unsub_firmware_events_and_reset_progress(False)
271  self._attr_in_progress_attr_in_progress_attr_in_progress = True
272  self._attr_update_percentage_attr_update_percentage = None
273  self.async_write_ha_stateasync_write_ha_state()
274 
275  self._progress_unsub_progress_unsub = self.nodenode.on(
276  "firmware update progress", self._update_progress_update_progress
277  )
278  self._finished_unsub_finished_unsub = self.nodenode.on(
279  "firmware update finished", self._update_finished_update_finished
280  )
281 
282  try:
283  await self.driverdriver.controller.async_firmware_update_ota(self.nodenode, firmware)
284  except BaseZwaveJSServerError as err:
285  self._unsub_firmware_events_and_reset_progress_unsub_firmware_events_and_reset_progress()
286  raise HomeAssistantError(err) from err
287 
288  # We need to block until we receive the `firmware update finished` event
289  await self._finished_event_finished_event.wait()
290  assert self._result_result is not None
291 
292  # If the update was not successful, we should throw an error
293  # to let the user know
294  if not self._result_result.success:
295  error_msg = self._result_result.status.name.replace("_", " ").title()
296  self._unsub_firmware_events_and_reset_progress_unsub_firmware_events_and_reset_progress()
297  raise HomeAssistantError(error_msg)
298 
299  # If we get here, all files were installed successfully
300  self._attr_installed_version_attr_installed_version = self._attr_latest_version_attr_latest_version = firmware.version
301  self._latest_version_firmware_latest_version_firmware = None
302  self._unsub_firmware_events_and_reset_progress_unsub_firmware_events_and_reset_progress()
303 
304  async def async_poll_value(self, _: bool) -> None:
305  """Poll a value."""
306  # We log an error instead of raising an exception because this service call occurs
307  # in a separate task since it is called via the dispatcher and we don't want to
308  # raise the exception in that separate task because it is confusing to the user.
309  LOGGER.error(
310  "There is no value to refresh for this entity so the zwave_js.refresh_value"
311  " service won't work for it"
312  )
313 
314  async def async_added_to_hass(self) -> None:
315  """Call when entity is added."""
316  self.async_on_removeasync_on_remove(
318  self.hasshass,
319  f"{DOMAIN}_{self.unique_id}_poll_value",
320  self.async_poll_valueasync_poll_value,
321  )
322  )
323 
324  self.async_on_removeasync_on_remove(
326  self.hasshass,
327  f"{DOMAIN}_{self._base_unique_id}_remove_entity",
328  self.async_removeasync_remove,
329  )
330  )
331 
332  self.async_on_removeasync_on_remove(
334  self.hasshass,
335  f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started",
336  self.async_removeasync_remove,
337  )
338  )
339 
340  # Make sure these variables are set for the elif evaluation
341  state = None
342  latest_version = None
343 
344  # If we have a complete previous state, use that to set the latest version
345  if (
346  (state := await self.async_get_last_stateasync_get_last_state())
347  and (latest_version := state.attributes.get(ATTR_LATEST_VERSION))
348  is not None
349  and (extra_data := await self.async_get_last_extra_dataasync_get_last_extra_data())
350  and (
351  latest_version_firmware
352  := ZWaveNodeFirmwareUpdateExtraStoredData.from_dict(
353  extra_data.as_dict()
354  ).latest_version_firmware
355  )
356  ):
357  self._attr_latest_version_attr_latest_version = latest_version
358  self._latest_version_firmware_latest_version_firmware = latest_version_firmware
359  # If we have no state or latest version to restore, or the latest version is
360  # the same as the installed version, we can set the latest
361  # version to installed so that the entity starts as off. If we have partial
362  # restore data due to an upgrade to an HA version where this feature is released
363  # from one that is not the entity will start in an unknown state until we can
364  # correct on next update
365  elif (
366  not state
367  or not latest_version
368  or latest_version == self._attr_installed_version_attr_installed_version
369  ):
370  self._attr_latest_version_attr_latest_version = self._attr_installed_version_attr_installed_version
371 
372  # Spread updates out in 5 minute increments to avoid flooding the network
373  self.async_on_removeasync_on_remove(
374  async_call_later(self.hasshass, self._delay, self._async_update_async_update)
375  )
376 
377  async def async_will_remove_from_hass(self) -> None:
378  """Call when entity will be removed."""
379  if self._status_unsub_status_unsub:
380  self._status_unsub_status_unsub()
381  self._status_unsub_status_unsub = None
382 
383  if self._poll_unsub_poll_unsub:
384  self._poll_unsub_poll_unsub()
385  self._poll_unsub_poll_unsub = None
386 
387  self._unsub_firmware_events_and_reset_progress_unsub_firmware_events_and_reset_progress(False)
ZWaveNodeFirmwareUpdateExtraStoredData from_dict(cls, dict[str, Any] data)
Definition: update.py:64
None _async_update(self, HomeAssistant|datetime|None _=None)
Definition: update.py:189
None async_install(self, str|None version, bool backup, **Any kwargs)
Definition: update.py:266
None __init__(self, Driver driver, ZwaveNode node, timedelta delay)
Definition: update.py:120
None _unsub_firmware_events_and_reset_progress(self, bool write_state=True)
Definition: update.py:172
ZWaveNodeFirmwareUpdateExtraStoredData extra_restore_state_data(self)
Definition: update.py:142
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
None async_remove(self, *bool force_remove=False)
Definition: entity.py:1387
ExtraStoredData|None async_get_last_extra_data(self)
DeviceInfo get_device_info(str coordinates, str name)
Definition: __init__.py:156
str get_valueless_base_unique_id(Driver driver, ZwaveNode node)
Definition: helpers.py:202
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: update.py:81
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597