Home Assistant Unofficial Reference 2024.12.1
coordinator.py
Go to the documentation of this file.
1 """Data for Hass.io."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections import defaultdict
7 import logging
8 from typing import TYPE_CHECKING, Any
9 
10 from aiohasupervisor import SupervisorError
11 from aiohasupervisor.models import StoreInfo
12 
13 from homeassistant.config_entries import ConfigEntry
14 from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
15 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
16 from homeassistant.helpers import device_registry as dr
17 from homeassistant.helpers.debounce import Debouncer
18 from homeassistant.helpers.device_registry import DeviceInfo
19 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
20 from homeassistant.loader import bind_hass
21 
22 from .const import (
23  ATTR_AUTO_UPDATE,
24  ATTR_CHANGELOG,
25  ATTR_REPOSITORY,
26  ATTR_SLUG,
27  ATTR_STARTED,
28  ATTR_STATE,
29  ATTR_URL,
30  ATTR_VERSION,
31  CONTAINER_CHANGELOG,
32  CONTAINER_INFO,
33  CONTAINER_STATS,
34  CORE_CONTAINER,
35  DATA_ADDONS_CHANGELOGS,
36  DATA_ADDONS_INFO,
37  DATA_ADDONS_STATS,
38  DATA_CORE_INFO,
39  DATA_CORE_STATS,
40  DATA_HOST_INFO,
41  DATA_INFO,
42  DATA_KEY_ADDONS,
43  DATA_KEY_CORE,
44  DATA_KEY_HOST,
45  DATA_KEY_OS,
46  DATA_KEY_SUPERVISOR,
47  DATA_KEY_SUPERVISOR_ISSUES,
48  DATA_NETWORK_INFO,
49  DATA_OS_INFO,
50  DATA_STORE,
51  DATA_SUPERVISOR_INFO,
52  DATA_SUPERVISOR_STATS,
53  DOMAIN,
54  HASSIO_UPDATE_INTERVAL,
55  REQUEST_REFRESH_DELAY,
56  SUPERVISOR_CONTAINER,
57  SupervisorEntityModel,
58 )
59 from .handler import HassIO, HassioAPIError, get_supervisor_client
60 
61 if TYPE_CHECKING:
62  from .issues import SupervisorIssues
63 
64 _LOGGER = logging.getLogger(__name__)
65 
66 
67 @callback
68 @bind_hass
69 def get_info(hass: HomeAssistant) -> dict[str, Any] | None:
70  """Return generic information from Supervisor.
71 
72  Async friendly.
73  """
74  return hass.data.get(DATA_INFO)
75 
76 
77 @callback
78 @bind_hass
79 def get_host_info(hass: HomeAssistant) -> dict[str, Any] | None:
80  """Return generic host information.
81 
82  Async friendly.
83  """
84  return hass.data.get(DATA_HOST_INFO)
85 
86 
87 @callback
88 @bind_hass
89 def get_store(hass: HomeAssistant) -> dict[str, Any] | None:
90  """Return store information.
91 
92  Async friendly.
93  """
94  return hass.data.get(DATA_STORE)
95 
96 
97 @callback
98 @bind_hass
99 def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None:
100  """Return Supervisor information.
101 
102  Async friendly.
103  """
104  return hass.data.get(DATA_SUPERVISOR_INFO)
105 
106 
107 @callback
108 @bind_hass
109 def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None:
110  """Return Host Network information.
111 
112  Async friendly.
113  """
114  return hass.data.get(DATA_NETWORK_INFO)
115 
116 
117 @callback
118 @bind_hass
119 def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None:
120  """Return Addons info.
121 
122  Async friendly.
123  """
124  return hass.data.get(DATA_ADDONS_INFO)
125 
126 
127 @callback
128 @bind_hass
129 def get_addons_stats(hass: HomeAssistant) -> dict[str, Any]:
130  """Return Addons stats.
131 
132  Async friendly.
133  """
134  return hass.data.get(DATA_ADDONS_STATS) or {}
135 
136 
137 @callback
138 @bind_hass
139 def get_core_stats(hass: HomeAssistant) -> dict[str, Any]:
140  """Return core stats.
141 
142  Async friendly.
143  """
144  return hass.data.get(DATA_CORE_STATS) or {}
145 
146 
147 @callback
148 @bind_hass
149 def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]:
150  """Return supervisor stats.
151 
152  Async friendly.
153  """
154  return hass.data.get(DATA_SUPERVISOR_STATS) or {}
155 
156 
157 @callback
158 @bind_hass
159 def get_addons_changelogs(hass: HomeAssistant):
160  """Return Addons changelogs.
161 
162  Async friendly.
163  """
164  return hass.data.get(DATA_ADDONS_CHANGELOGS)
165 
166 
167 @callback
168 @bind_hass
169 def get_os_info(hass: HomeAssistant) -> dict[str, Any] | None:
170  """Return OS information.
171 
172  Async friendly.
173  """
174  return hass.data.get(DATA_OS_INFO)
175 
176 
177 @callback
178 @bind_hass
179 def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None:
180  """Return Home Assistant Core information from Supervisor.
181 
182  Async friendly.
183  """
184  return hass.data.get(DATA_CORE_INFO)
185 
186 
187 @callback
188 @bind_hass
189 def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
190  """Return Supervisor issues info.
191 
192  Async friendly.
193  """
194  return hass.data.get(DATA_KEY_SUPERVISOR_ISSUES)
195 
196 
197 @callback
199  entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[dict[str, Any]]
200 ) -> None:
201  """Register addons in the device registry."""
202  for addon in addons:
203  params = DeviceInfo(
204  identifiers={(DOMAIN, addon[ATTR_SLUG])},
205  model=SupervisorEntityModel.ADDON,
206  sw_version=addon[ATTR_VERSION],
207  name=addon[ATTR_NAME],
208  entry_type=dr.DeviceEntryType.SERVICE,
209  configuration_url=f"homeassistant://hassio/addon/{addon[ATTR_SLUG]}",
210  )
211  if manufacturer := addon.get(ATTR_REPOSITORY) or addon.get(ATTR_URL):
212  params[ATTR_MANUFACTURER] = manufacturer
213  dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
214 
215 
216 @callback
218  entry_id: str, dev_reg: dr.DeviceRegistry, os_dict: dict[str, Any]
219 ) -> None:
220  """Register OS in the device registry."""
221  params = DeviceInfo(
222  identifiers={(DOMAIN, "OS")},
223  manufacturer="Home Assistant",
224  model=SupervisorEntityModel.OS,
225  sw_version=os_dict[ATTR_VERSION],
226  name="Home Assistant Operating System",
227  entry_type=dr.DeviceEntryType.SERVICE,
228  )
229  dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
230 
231 
232 @callback
234  entry_id: str,
235  dev_reg: dr.DeviceRegistry,
236 ) -> None:
237  """Register host in the device registry."""
238  params = DeviceInfo(
239  identifiers={(DOMAIN, "host")},
240  manufacturer="Home Assistant",
241  model=SupervisorEntityModel.HOST,
242  name="Home Assistant Host",
243  entry_type=dr.DeviceEntryType.SERVICE,
244  )
245  dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
246 
247 
248 @callback
250  entry_id: str,
251  dev_reg: dr.DeviceRegistry,
252  core_dict: dict[str, Any],
253 ) -> None:
254  """Register OS in the device registry."""
255  params = DeviceInfo(
256  identifiers={(DOMAIN, "core")},
257  manufacturer="Home Assistant",
258  model=SupervisorEntityModel.CORE,
259  sw_version=core_dict[ATTR_VERSION],
260  name="Home Assistant Core",
261  entry_type=dr.DeviceEntryType.SERVICE,
262  )
263  dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
264 
265 
266 @callback
268  entry_id: str,
269  dev_reg: dr.DeviceRegistry,
270  supervisor_dict: dict[str, Any],
271 ) -> None:
272  """Register OS in the device registry."""
273  params = DeviceInfo(
274  identifiers={(DOMAIN, "supervisor")},
275  manufacturer="Home Assistant",
276  model=SupervisorEntityModel.SUPERVIOSR,
277  sw_version=supervisor_dict[ATTR_VERSION],
278  name="Home Assistant Supervisor",
279  entry_type=dr.DeviceEntryType.SERVICE,
280  )
281  dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
282 
283 
284 @callback
286  dev_reg: dr.DeviceRegistry, addons: set[str]
287 ) -> None:
288  """Remove addons from the device registry."""
289  for addon_slug in addons:
290  if dev := dev_reg.async_get_device(identifiers={(DOMAIN, addon_slug)}):
291  dev_reg.async_remove_device(dev.id)
292 
293 
295  """Class to retrieve Hass.io status."""
296 
297  def __init__(
298  self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry
299  ) -> None:
300  """Initialize coordinator."""
301  super().__init__(
302  hass,
303  _LOGGER,
304  name=DOMAIN,
305  update_interval=HASSIO_UPDATE_INTERVAL,
306  # We don't want an immediate refresh since we want to avoid
307  # fetching the container stats right away and avoid hammering
308  # the Supervisor API on startup
309  request_refresh_debouncer=Debouncer(
310  hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
311  ),
312  )
313  self.hassio: HassIO = hass.data[DOMAIN]
314  self.datadatadata = {}
315  self.entry_identry_id = config_entry.entry_id
316  self.dev_regdev_reg = dev_reg
317  self.is_hass_osis_hass_os = (get_info(self.hasshass) or {}).get("hassos") is not None
318  self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict(
319  lambda: defaultdict(set)
320  )
321  self.supervisor_clientsupervisor_client = get_supervisor_client(hass)
322 
323  async def _async_update_data(self) -> dict[str, Any]:
324  """Update data via library."""
325  is_first_update = not self.datadatadata
326 
327  try:
328  await self.force_data_refreshforce_data_refresh(is_first_update)
329  except HassioAPIError as err:
330  raise UpdateFailed(f"Error on Supervisor API: {err}") from err
331 
332  new_data: dict[str, Any] = {}
333  supervisor_info = get_supervisor_info(self.hasshass) or {}
334  addons_info = get_addons_info(self.hasshass) or {}
335  addons_stats = get_addons_stats(self.hasshass)
336  addons_changelogs = get_addons_changelogs(self.hasshass)
337  store_data = get_store(self.hasshass)
338 
339  if store_data:
340  repositories = {
341  repo.slug: repo.name
342  for repo in StoreInfo.from_dict(store_data).repositories
343  }
344  else:
345  repositories = {}
346 
347  new_data[DATA_KEY_ADDONS] = {
348  addon[ATTR_SLUG]: {
349  **addon,
350  **((addons_stats or {}).get(addon[ATTR_SLUG]) or {}),
351  ATTR_AUTO_UPDATE: (addons_info.get(addon[ATTR_SLUG]) or {}).get(
352  ATTR_AUTO_UPDATE, False
353  ),
354  ATTR_CHANGELOG: (addons_changelogs or {}).get(addon[ATTR_SLUG]),
355  ATTR_REPOSITORY: repositories.get(
356  addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "")
357  ),
358  }
359  for addon in supervisor_info.get("addons", [])
360  }
361  if self.is_hass_osis_hass_os:
362  new_data[DATA_KEY_OS] = get_os_info(self.hasshass)
363 
364  new_data[DATA_KEY_CORE] = {
365  **(get_core_info(self.hasshass) or {}),
366  **get_core_stats(self.hasshass),
367  }
368  new_data[DATA_KEY_SUPERVISOR] = {
369  **supervisor_info,
370  **get_supervisor_stats(self.hasshass),
371  }
372  new_data[DATA_KEY_HOST] = get_host_info(self.hasshass) or {}
373 
374  # If this is the initial refresh, register all addons and return the dict
375  if is_first_update:
377  self.entry_identry_id, self.dev_regdev_reg, new_data[DATA_KEY_ADDONS].values()
378  )
380  self.entry_identry_id, self.dev_regdev_reg, new_data[DATA_KEY_CORE]
381  )
383  self.entry_identry_id, self.dev_regdev_reg, new_data[DATA_KEY_SUPERVISOR]
384  )
385  async_register_host_in_dev_reg(self.entry_identry_id, self.dev_regdev_reg)
386  if self.is_hass_osis_hass_os:
388  self.entry_identry_id, self.dev_regdev_reg, new_data[DATA_KEY_OS]
389  )
390 
391  # Remove add-ons that are no longer installed from device registry
392  supervisor_addon_devices = {
393  list(device.identifiers)[0][1]
394  for device in self.dev_regdev_reg.devices.get_devices_for_config_entry_id(
395  self.entry_identry_id
396  )
397  if device.model == SupervisorEntityModel.ADDON
398  }
399  if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
400  async_remove_addons_from_dev_reg(self.dev_regdev_reg, stale_addons)
401 
402  if not self.is_hass_osis_hass_os and (
403  dev := self.dev_regdev_reg.async_get_device(identifiers={(DOMAIN, "OS")})
404  ):
405  # Remove the OS device if it exists and the installation is not hassos
406  self.dev_regdev_reg.async_remove_device(dev.id)
407 
408  # If there are new add-ons, we should reload the config entry so we can
409  # create new devices and entities. We can return an empty dict because
410  # coordinator will be recreated.
411  if self.datadatadata and set(new_data[DATA_KEY_ADDONS]) - set(
412  self.datadatadata[DATA_KEY_ADDONS]
413  ):
414  self.hasshass.async_create_task(
415  self.hasshass.config_entries.async_reload(self.entry_identry_id)
416  )
417  return {}
418 
419  return new_data
420 
421  async def force_info_update_supervisor(self) -> None:
422  """Force update of the supervisor info."""
423  self.hasshass.data[DATA_SUPERVISOR_INFO] = await self.hassio.get_supervisor_info()
424  await self.async_refreshasync_refresh()
425 
426  async def force_data_refresh(self, first_update: bool) -> None:
427  """Force update of the addon info."""
428  container_updates = self._container_updates
429 
430  data = self.hasshass.data
431  hassio = self.hassio
432  updates = {
433  DATA_INFO: hassio.get_info(),
434  DATA_CORE_INFO: hassio.get_core_info(),
435  DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(),
436  DATA_OS_INFO: hassio.get_os_info(),
437  }
438  if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
439  updates[DATA_CORE_STATS] = hassio.get_core_stats()
440  if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]:
441  updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats()
442 
443  results = await asyncio.gather(*updates.values())
444  for key, result in zip(updates, results, strict=False):
445  data[key] = result
446 
447  _addon_data = data[DATA_SUPERVISOR_INFO].get("addons", [])
448  all_addons: list[str] = []
449  started_addons: list[str] = []
450  for addon in _addon_data:
451  slug = addon[ATTR_SLUG]
452  all_addons.append(slug)
453  if addon[ATTR_STATE] == ATTR_STARTED:
454  started_addons.append(slug)
455  #
456  # Update add-on info if its the first update or
457  # there is at least one entity that needs the data.
458  #
459  # When entities are added they call async_enable_container_updates
460  # to enable updates for the endpoints they need via
461  # async_added_to_hass. This ensures that we only update
462  # the data for the endpoints that are needed to avoid unnecessary
463  # API calls since otherwise we would fetch stats for all containers
464  # and throw them away.
465  #
466  for data_key, update_func, enabled_key, wanted_addons, needs_first_update in (
467  (
468  DATA_ADDONS_STATS,
469  self._update_addon_stats_update_addon_stats,
470  CONTAINER_STATS,
471  started_addons,
472  False,
473  ),
474  (
475  DATA_ADDONS_CHANGELOGS,
476  self._update_addon_changelog_update_addon_changelog,
477  CONTAINER_CHANGELOG,
478  all_addons,
479  True,
480  ),
481  (
482  DATA_ADDONS_INFO,
483  self._update_addon_info_update_addon_info,
484  CONTAINER_INFO,
485  all_addons,
486  True,
487  ),
488  ):
489  container_data: dict[str, Any] = data.setdefault(data_key, {})
490  container_data.update(
491  dict(
492  await asyncio.gather(
493  *[
494  update_func(slug)
495  for slug in wanted_addons
496  if (first_update and needs_first_update)
497  or enabled_key in container_updates[slug]
498  ]
499  )
500  )
501  )
502 
503  async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
504  """Update single addon stats."""
505  try:
506  stats = await self.supervisor_clientsupervisor_client.addons.addon_stats(slug)
507  except SupervisorError as err:
508  _LOGGER.warning("Could not fetch stats for %s: %s", slug, err)
509  return (slug, None)
510  return (slug, stats.to_dict())
511 
512  async def _update_addon_changelog(self, slug: str) -> tuple[str, str | None]:
513  """Return the changelog for an add-on."""
514  try:
515  changelog = await self.supervisor_clientsupervisor_client.store.addon_changelog(slug)
516  except SupervisorError as err:
517  _LOGGER.warning("Could not fetch changelog for %s: %s", slug, err)
518  return (slug, None)
519  return (slug, changelog)
520 
521  async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]:
522  """Return the info for an add-on."""
523  try:
524  info = await self.supervisor_clientsupervisor_client.addons.addon_info(slug)
525  except SupervisorError as err:
526  _LOGGER.warning("Could not fetch info for %s: %s", slug, err)
527  return (slug, None)
528  # Translate to legacy hassio names for compatibility
529  info_dict = info.to_dict()
530  info_dict["hassio_api"] = info_dict.pop("supervisor_api")
531  info_dict["hassio_role"] = info_dict.pop("supervisor_role")
532  return (slug, info_dict)
533 
534  @callback
536  self, slug: str, entity_id: str, types: set[str]
537  ) -> CALLBACK_TYPE:
538  """Enable updates for an add-on."""
539  enabled_updates = self._container_updates[slug]
540  for key in types:
541  enabled_updates[key].add(entity_id)
542 
543  @callback
544  def _remove() -> None:
545  for key in types:
546  enabled_updates[key].remove(entity_id)
547 
548  return _remove
549 
550  async def _async_refresh(
551  self,
552  log_failures: bool = True,
553  raise_on_auth_failed: bool = False,
554  scheduled: bool = False,
555  raise_on_entry_error: bool = False,
556  ) -> None:
557  """Refresh data."""
558  if not scheduled and not raise_on_auth_failed:
559  # Force refreshing updates for non-scheduled updates
560  # If `raise_on_auth_failed` is set, it means this is
561  # the first refresh and we do not want to delay
562  # startup or cause a timeout so we only refresh the
563  # updates if this is not a scheduled refresh and
564  # we are not doing the first refresh.
565  try:
566  await self.supervisor_clientsupervisor_client.refresh_updates()
567  except SupervisorError as err:
568  _LOGGER.warning("Error on Supervisor API: %s", err)
569 
570  await super()._async_refresh(
571  log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
572  )
tuple[str, dict[str, Any]|None] _update_addon_info(self, str slug)
Definition: coordinator.py:521
CALLBACK_TYPE async_enable_container_updates(self, str slug, str entity_id, set[str] types)
Definition: coordinator.py:537
tuple[str, dict[str, Any]|None] _update_addon_stats(self, str slug)
Definition: coordinator.py:503
None _async_refresh(self, bool log_failures=True, bool raise_on_auth_failed=False, bool scheduled=False, bool raise_on_entry_error=False)
Definition: coordinator.py:556
None __init__(self, HomeAssistant hass, ConfigEntry config_entry, dr.DeviceRegistry dev_reg)
Definition: coordinator.py:299
bool add(self, _T matcher)
Definition: match.py:185
bool remove(self, _T matcher)
Definition: match.py:214
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
dict[str, dict[str, Any]]|None get_addons_info(HomeAssistant hass)
Definition: coordinator.py:119
None async_register_host_in_dev_reg(str entry_id, dr.DeviceRegistry dev_reg)
Definition: coordinator.py:236
None async_register_core_in_dev_reg(str entry_id, dr.DeviceRegistry dev_reg, dict[str, Any] core_dict)
Definition: coordinator.py:253
dict[str, Any]|None get_supervisor_info(HomeAssistant hass)
Definition: coordinator.py:99
dict[str, Any]|None get_store(HomeAssistant hass)
Definition: coordinator.py:89
dict[str, Any] get_addons_stats(HomeAssistant hass)
Definition: coordinator.py:129
dict[str, Any]|None get_core_info(HomeAssistant hass)
Definition: coordinator.py:179
None async_register_supervisor_in_dev_reg(str entry_id, dr.DeviceRegistry dev_reg, dict[str, Any] supervisor_dict)
Definition: coordinator.py:271
dict[str, Any]|None get_info(HomeAssistant hass)
Definition: coordinator.py:69
dict[str, Any]|None get_host_info(HomeAssistant hass)
Definition: coordinator.py:79
dict[str, Any]|None get_network_info(HomeAssistant hass)
Definition: coordinator.py:109
def get_addons_changelogs(HomeAssistant hass)
Definition: coordinator.py:159
SupervisorIssues|None get_issues_info(HomeAssistant hass)
Definition: coordinator.py:189
None async_remove_addons_from_dev_reg(dr.DeviceRegistry dev_reg, set[str] addons)
Definition: coordinator.py:287
dict[str, Any]|None get_os_info(HomeAssistant hass)
Definition: coordinator.py:169
dict[str, Any] get_core_stats(HomeAssistant hass)
Definition: coordinator.py:139
None async_register_addons_in_dev_reg(str entry_id, dr.DeviceRegistry dev_reg, list[dict[str, Any]] addons)
Definition: coordinator.py:200
None async_register_os_in_dev_reg(str entry_id, dr.DeviceRegistry dev_reg, dict[str, Any] os_dict)
Definition: coordinator.py:219
dict[str, Any] get_supervisor_stats(HomeAssistant hass)
Definition: coordinator.py:149
SupervisorClient get_supervisor_client(HomeAssistant hass)
Definition: handler.py:344