Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Component to allow for providing device or service updates."""
2 
3 from __future__ import annotations
4 
5 from datetime import timedelta
6 from enum import StrEnum
7 from functools import lru_cache
8 import logging
9 from typing import Any, Final, final
10 
11 from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
12 from propcache import cached_property
13 import voluptuous as vol
14 
15 from homeassistant.components import websocket_api
16 from homeassistant.config_entries import ConfigEntry
17 from homeassistant.const import ATTR_ENTITY_PICTURE, STATE_OFF, STATE_ON, EntityCategory
18 from homeassistant.core import HomeAssistant, ServiceCall
19 from homeassistant.exceptions import HomeAssistantError
20 from homeassistant.helpers import config_validation as cv
21 from homeassistant.helpers.entity import ABCCachedProperties, EntityDescription
22 from homeassistant.helpers.entity_component import EntityComponent
23 from homeassistant.helpers.restore_state import RestoreEntity
24 from homeassistant.helpers.typing import ConfigType
25 from homeassistant.util.hass_dict import HassKey
26 
27 from .const import (
28  ATTR_AUTO_UPDATE,
29  ATTR_BACKUP,
30  ATTR_DISPLAY_PRECISION,
31  ATTR_IN_PROGRESS,
32  ATTR_INSTALLED_VERSION,
33  ATTR_LATEST_VERSION,
34  ATTR_RELEASE_SUMMARY,
35  ATTR_RELEASE_URL,
36  ATTR_SKIPPED_VERSION,
37  ATTR_TITLE,
38  ATTR_UPDATE_PERCENTAGE,
39  ATTR_VERSION,
40  DOMAIN,
41  SERVICE_INSTALL,
42  SERVICE_SKIP,
43  UpdateEntityFeature,
44 )
45 
46 _LOGGER = logging.getLogger(__name__)
47 
48 DATA_COMPONENT: HassKey[EntityComponent[UpdateEntity]] = HassKey(DOMAIN)
49 ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
50 PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
51 PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
52 SCAN_INTERVAL = timedelta(minutes=15)
53 
54 
55 class UpdateDeviceClass(StrEnum):
56  """Device class for update."""
57 
58  FIRMWARE = "firmware"
59 
60 
61 DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(UpdateDeviceClass))
62 
63 
64 __all__ = [
65  "ATTR_BACKUP",
66  "ATTR_INSTALLED_VERSION",
67  "ATTR_LATEST_VERSION",
68  "ATTR_VERSION",
69  "DEVICE_CLASSES_SCHEMA",
70  "DOMAIN",
71  "PLATFORM_SCHEMA_BASE",
72  "PLATFORM_SCHEMA",
73  "SERVICE_INSTALL",
74  "SERVICE_SKIP",
75  "UpdateDeviceClass",
76  "UpdateEntity",
77  "UpdateEntityDescription",
78  "UpdateEntityFeature",
79 ]
80 
81 # mypy: disallow-any-generics
82 
83 
84 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
85  """Set up Select entities."""
86  component = hass.data[DATA_COMPONENT] = EntityComponent[UpdateEntity](
87  _LOGGER, DOMAIN, hass, SCAN_INTERVAL
88  )
89  await component.async_setup(config)
90 
91  component.async_register_entity_service(
92  SERVICE_INSTALL,
93  {
94  vol.Optional(ATTR_VERSION): cv.string,
95  vol.Optional(ATTR_BACKUP, default=False): cv.boolean,
96  },
97  async_install,
98  [UpdateEntityFeature.INSTALL],
99  )
100 
101  component.async_register_entity_service(
102  SERVICE_SKIP,
103  None,
104  async_skip,
105  )
106  component.async_register_entity_service(
107  "clear_skipped",
108  None,
109  async_clear_skipped,
110  )
111 
112  websocket_api.async_register_command(hass, websocket_release_notes)
113 
114  return True
115 
116 
117 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
118  """Set up a config entry."""
119  return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
120 
121 
122 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
123  """Unload a config entry."""
124  return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
125 
126 
127 async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None:
128  """Service call wrapper to validate the call."""
129  # If version is not specified, but no update is available.
130  if (version := service_call.data.get(ATTR_VERSION)) is None and (
131  entity.installed_version == entity.latest_version
132  or entity.latest_version is None
133  ):
134  raise HomeAssistantError(f"No update available for {entity.entity_id}")
135 
136  # If version is specified, but not supported by the entity.
137  if (
138  version is not None
139  and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features_compat
140  ):
141  raise HomeAssistantError(
142  f"Installing a specific version is not supported for {entity.entity_id}"
143  )
144 
145  # If backup is requested, but not supported by the entity.
146  if (
147  backup := service_call.data[ATTR_BACKUP]
148  ) and UpdateEntityFeature.BACKUP not in entity.supported_features_compat:
149  raise HomeAssistantError(f"Backup is not supported for {entity.entity_id}")
150 
151  # Update is already in progress.
152  if entity.in_progress is not False:
153  raise HomeAssistantError(
154  f"Update installation already in progress for {entity.entity_id}"
155  )
156 
157  await entity.async_install_with_progress(version, backup)
158 
159 
160 async def async_skip(entity: UpdateEntity, service_call: ServiceCall) -> None:
161  """Service call wrapper to validate the call."""
162  if entity.auto_update:
163  raise HomeAssistantError(
164  f"Skipping update is not supported for {entity.entity_id}"
165  )
166  await entity.async_skip()
167 
168 
169 async def async_clear_skipped(entity: UpdateEntity, service_call: ServiceCall) -> None:
170  """Service call wrapper to validate the call."""
171  if entity.auto_update:
172  raise HomeAssistantError(
173  f"Clearing skipped update is not supported for {entity.entity_id}"
174  )
175  await entity.async_clear_skipped()
176 
177 
178 class UpdateEntityDescription(EntityDescription, frozen_or_thawed=True):
179  """A class that describes update entities."""
180 
181  device_class: UpdateDeviceClass | None = None
182  display_precision: int = 0
183  entity_category: EntityCategory | None = EntityCategory.CONFIG
184 
185 
186 @lru_cache(maxsize=256)
187 def _version_is_newer(latest_version: str, installed_version: str) -> bool:
188  """Return True if latest_version is newer than installed_version."""
189  return AwesomeVersion(latest_version) > installed_version
190 
191 
192 CACHED_PROPERTIES_WITH_ATTR_ = {
193  "auto_update",
194  "installed_version",
195  "device_class",
196  "display_precision",
197  "in_progress",
198  "latest_version",
199  "release_summary",
200  "release_url",
201  "supported_features",
202  "title",
203  "update_percentage",
204 }
205 
206 
208  RestoreEntity,
209  metaclass=ABCCachedProperties,
210  cached_properties=CACHED_PROPERTIES_WITH_ATTR_,
211 ):
212  """Representation of an update entity."""
213 
214  _entity_component_unrecorded_attributes = frozenset(
215  {
216  ATTR_DISPLAY_PRECISION,
217  ATTR_ENTITY_PICTURE,
218  ATTR_IN_PROGRESS,
219  ATTR_RELEASE_SUMMARY,
220  ATTR_UPDATE_PERCENTAGE,
221  }
222  )
223 
224  entity_description: UpdateEntityDescription
225  _attr_auto_update: bool = False
226  _attr_installed_version: str | None = None
227  _attr_device_class: UpdateDeviceClass | None
228  _attr_display_precision: int
229  _attr_in_progress: bool | int = False
230  _attr_latest_version: str | None = None
231  _attr_release_summary: str | None = None
232  _attr_release_url: str | None = None
233  _attr_state: None = None
234  _attr_supported_features: UpdateEntityFeature = UpdateEntityFeature(0)
235  _attr_title: str | None = None
236  _attr_update_percentage: int | float | None = None
237  __skipped_version: str | None = None
238  __in_progress: bool = False
239 
240  @cached_property
241  def auto_update(self) -> bool:
242  """Indicate if the device or service has auto update enabled."""
243  return self._attr_auto_update
244 
245  @cached_property
246  def installed_version(self) -> str | None:
247  """Version installed and in use."""
248  return self._attr_installed_version
249 
250  def _default_to_device_class_name(self) -> bool:
251  """Return True if an unnamed entity should be named by its device class.
252 
253  For updates this is True if the entity has a device class.
254  """
255  return self.device_classdevice_classdevice_class is not None
256 
257  @cached_property
258  def device_class(self) -> UpdateDeviceClass | None:
259  """Return the class of this entity."""
260  if hasattr(self, "_attr_device_class"):
261  return self._attr_device_class
262  if hasattr(self, "entity_description"):
263  return self.entity_description.device_class
264  return None
265 
266  @cached_property
267  def display_precision(self) -> int:
268  """Return number of decimal digits for display of update progress."""
269  if hasattr(self, "_attr_display_precision"):
270  return self._attr_display_precision
271  if hasattr(self, "entity_description"):
272  return self.entity_description.display_precision
273  return 0
274 
275  @property
276  def entity_category(self) -> EntityCategory | None:
277  """Return the category of the entity, if any."""
278  if hasattr(self, "_attr_entity_category"):
279  return self._attr_entity_category
280  if hasattr(self, "entity_description"):
281  return self.entity_description.entity_category
282  if UpdateEntityFeature.INSTALL in self.supported_features_compatsupported_features_compat:
283  return EntityCategory.CONFIG
284  return EntityCategory.DIAGNOSTIC
285 
286  @property
287  def entity_picture(self) -> str | None:
288  """Return the entity picture to use in the frontend.
289 
290  Update entities return the brand icon based on the integration
291  domain by default.
292  """
293  return (
294  f"https://brands.home-assistant.io/_/{self.platform.platform_name}/icon.png"
295  )
296 
297  @cached_property
298  def in_progress(self) -> bool | int | None:
299  """Update installation progress.
300 
301  Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used.
302 
303  Should return a boolean (True if in progress, False if not).
304  """
305  return self._attr_in_progress_attr_in_progress
306 
307  @cached_property
308  def latest_version(self) -> str | None:
309  """Latest version available for install."""
310  return self._attr_latest_version
311 
312  @cached_property
313  def release_summary(self) -> str | None:
314  """Summary of the release notes or changelog.
315 
316  This is not suitable for long changelogs, but merely suitable
317  for a short excerpt update description of max 255 characters.
318  """
319  return self._attr_release_summary
320 
321  @cached_property
322  def release_url(self) -> str | None:
323  """URL to the full release notes of the latest version available."""
324  return self._attr_release_url
325 
326  @cached_property
327  def supported_features(self) -> UpdateEntityFeature:
328  """Flag supported features."""
329  return self._attr_supported_features
330 
331  @cached_property
332  def title(self) -> str | None:
333  """Title of the software.
334 
335  This helps to differentiate between the device or entity name
336  versus the title of the software installed.
337  """
338  return self._attr_title
339 
340  @property
341  def supported_features_compat(self) -> UpdateEntityFeature:
342  """Return the supported features as UpdateEntityFeature.
343 
344  Remove this compatibility shim in 2025.1 or later.
345  """
346  features = self.supported_featuressupported_featuressupported_features
347  if type(features) is int: # noqa: E721
348  new_features = UpdateEntityFeature(features)
349  self._report_deprecated_supported_features_values_report_deprecated_supported_features_values(new_features)
350  return new_features
351  return features
352 
353  @cached_property
354  def update_percentage(self) -> int | float | None:
355  """Update installation progress.
356 
357  Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used.
358 
359  Can either return a number to indicate the progress from 0 to 100% or None.
360  """
361  return self._attr_update_percentage
362 
363  @final
364  async def async_skip(self) -> None:
365  """Skip the current offered version to update."""
366  if (latest_version := self.latest_versionlatest_version) is None:
367  raise HomeAssistantError(f"Cannot skip an unknown version for {self.name}")
368  if self.installed_versioninstalled_versioninstalled_version == latest_version:
369  raise HomeAssistantError(f"No update available to skip for {self.name}")
370  self.__skipped_version__skipped_version = latest_version
371  self.async_write_ha_stateasync_write_ha_state()
372 
373  @final
374  async def async_clear_skipped(self) -> None:
375  """Clear the skipped version."""
376  self.__skipped_version__skipped_version = None
377  self.async_write_ha_stateasync_write_ha_state()
378 
379  async def async_install(
380  self, version: str | None, backup: bool, **kwargs: Any
381  ) -> None:
382  """Install an update.
383 
384  Version can be specified to install a specific version. When `None`, the
385  latest version needs to be installed.
386 
387  The backup parameter indicates a backup should be taken before
388  installing the update.
389  """
390  await self.hasshass.async_add_executor_job(self.installinstall, version, backup)
391 
392  def install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
393  """Install an update.
394 
395  Version can be specified to install a specific version. When `None`, the
396  latest version needs to be installed.
397 
398  The backup parameter indicates a backup should be taken before
399  installing the update.
400  """
401  raise NotImplementedError
402 
403  async def async_release_notes(self) -> str | None:
404  """Return full release notes.
405 
406  This is suitable for a long changelog that does not fit in the release_summary
407  property. The returned string can contain markdown.
408  """
409  return await self.hasshass.async_add_executor_job(self.release_notesrelease_notes)
410 
411  def release_notes(self) -> str | None:
412  """Return full release notes.
413 
414  This is suitable for a long changelog that does not fit in the release_summary
415  property. The returned string can contain markdown.
416  """
417  raise NotImplementedError
418 
419  def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
420  """Return True if latest_version is newer than installed_version."""
421  # We don't inline the `_version_is_newer` function because of caching
422  return _version_is_newer(latest_version, installed_version)
423 
424  @property
425  @final
426  def state(self) -> str | None:
427  """Return the entity state."""
428  if (installed_version := self.installed_versioninstalled_versioninstalled_version) is None or (
429  latest_version := self.latest_versionlatest_version
430  ) is None:
431  return None
432 
433  if latest_version == self.__skipped_version__skipped_version:
434  return STATE_OFF
435  if latest_version == installed_version:
436  return STATE_OFF
437 
438  try:
439  newer = self.version_is_newerversion_is_newer(latest_version, installed_version)
440  except AwesomeVersionCompareException:
441  # Can't compare versions, already tried exact match
442  return STATE_ON
443  return STATE_ON if newer else STATE_OFF
444 
445  @final
446  @property
447  def state_attributes(self) -> dict[str, Any] | None:
448  """Return state attributes."""
449  if (release_summary := self.release_summaryrelease_summary) is not None:
450  release_summary = release_summary[:255]
451 
452  # If entity supports progress, return the in_progress value.
453  # Otherwise, we use the internal progress value.
454  if UpdateEntityFeature.PROGRESS in self.supported_features_compatsupported_features_compat:
455  in_progress = self.in_progressin_progress
456  update_percentage = self.update_percentageupdate_percentage if in_progress else None
457  if type(in_progress) is not bool and isinstance(in_progress, int):
458  update_percentage = in_progress
459  in_progress = True
460  else:
461  in_progress = self.__in_progress__in_progress
462  update_percentage = None
463 
464  installed_version = self.installed_versioninstalled_versioninstalled_version
465  latest_version = self.latest_versionlatest_version
466  skipped_version = self.__skipped_version__skipped_version
467  # Clear skipped version in case it matches the current installed
468  # version or the latest version diverged.
469  if (installed_version is not None and skipped_version == installed_version) or (
470  latest_version is not None and skipped_version != latest_version
471  ):
472  skipped_version = None
473  self.__skipped_version__skipped_version = None
474 
475  return {
476  ATTR_AUTO_UPDATE: self.auto_updateauto_update,
477  ATTR_DISPLAY_PRECISION: self.display_precisiondisplay_precision,
478  ATTR_INSTALLED_VERSION: installed_version,
479  ATTR_IN_PROGRESS: in_progress,
480  ATTR_LATEST_VERSION: latest_version,
481  ATTR_RELEASE_SUMMARY: release_summary,
482  ATTR_RELEASE_URL: self.release_urlrelease_url,
483  ATTR_SKIPPED_VERSION: skipped_version,
484  ATTR_TITLE: self.titletitle,
485  ATTR_UPDATE_PERCENTAGE: update_percentage,
486  }
487 
488  @final
490  self, version: str | None, backup: bool
491  ) -> None:
492  """Install update and handle progress if needed.
493 
494  Handles setting the in_progress state in case the entity doesn't
495  support it natively.
496  """
497  if UpdateEntityFeature.PROGRESS not in self.supported_features_compatsupported_features_compat:
498  self.__in_progress__in_progress = True
499  self.async_write_ha_stateasync_write_ha_state()
500 
501  try:
502  await self.async_installasync_install(version, backup)
503  finally:
504  # No matter what happens, we always stop progress in the end
505  self._attr_in_progress_attr_in_progress = False
506  self.__in_progress__in_progress = False
507  self.async_write_ha_stateasync_write_ha_state()
508 
509  async def async_internal_added_to_hass(self) -> None:
510  """Call when the update entity is added to hass.
511 
512  It is used to restore the skipped version, if any.
513  """
514  await super().async_internal_added_to_hass()
515  state = await self.async_get_last_stateasync_get_last_state()
516  if state is not None and state.attributes.get(ATTR_SKIPPED_VERSION) is not None:
517  self.__skipped_version__skipped_version = state.attributes[ATTR_SKIPPED_VERSION]
518 
519 
520 @websocket_api.require_admin
521 @websocket_api.websocket_command( { vol.Required("type"): "update/release_notes",
522  vol.Required("entity_id"): cv.entity_id,
523  }
524 )
525 @websocket_api.async_response
526 async def websocket_release_notes(
527  hass: HomeAssistant,
529  msg: dict[str, Any],
530 ) -> None:
531  """Get the full release notes for a entity."""
532  entity = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])
533 
534  if entity is None:
535  connection.send_error(
536  msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found"
537  )
538  return
539 
540  if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features_compat:
541  connection.send_error(
542  msg["id"],
543  websocket_api.ERR_NOT_SUPPORTED,
544  "Entity does not support release notes",
545  )
546  return
547 
548  connection.send_result(
549  msg["id"],
550  await entity.async_release_notes(),
551  )
552 
UpdateEntityFeature supported_features_compat(self)
Definition: __init__.py:341
None async_install(self, str|None version, bool backup, **Any kwargs)
Definition: __init__.py:381
UpdateEntityFeature supported_features(self)
Definition: __init__.py:327
None async_install_with_progress(self, str|None version, bool backup)
Definition: __init__.py:491
dict[str, Any]|None state_attributes(self)
Definition: __init__.py:447
UpdateDeviceClass|None device_class(self)
Definition: __init__.py:258
bool version_is_newer(self, str latest_version, str installed_version)
Definition: __init__.py:419
EntityCategory|None entity_category(self)
Definition: __init__.py:276
None install(self, str|None version, bool backup, **Any kwargs)
Definition: __init__.py:392
None _report_deprecated_supported_features_values(self, IntFlag replacement)
Definition: entity.py:1645
int|None supported_features(self)
Definition: entity.py:861
CalendarEntity get_entity(HomeAssistant hass, str entity_id)
Definition: trigger.py:96
None async_skip(UpdateEntity entity, ServiceCall service_call)
Definition: __init__.py:160
bool _version_is_newer(str latest_version, str installed_version)
Definition: __init__.py:187
None async_clear_skipped(UpdateEntity entity, ServiceCall service_call)
Definition: __init__.py:169
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:117
None websocket_release_notes(HomeAssistant hass, websocket_api.connection.ActiveConnection connection, dict[str, Any] msg)
Definition: __init__.py:532
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:122
None async_install(UpdateEntity entity, ServiceCall service_call)
Definition: __init__.py:127
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:84