Home Assistant Unofficial Reference 2024.12.1
issues.py
Go to the documentation of this file.
1 """Supervisor events monitor."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from dataclasses import dataclass, field
7 from datetime import datetime
8 import logging
9 from typing import Any, NotRequired, TypedDict
10 from uuid import UUID
11 
12 from aiohasupervisor import SupervisorError
13 from aiohasupervisor.models import ContextType, Issue as SupervisorIssue
14 
15 from homeassistant.core import HassJob, HomeAssistant, callback
16 from homeassistant.helpers.dispatcher import async_dispatcher_connect
17 from homeassistant.helpers.event import async_call_later
19  IssueSeverity,
20  async_create_issue,
21  async_delete_issue,
22 )
23 
24 from .const import (
25  ATTR_DATA,
26  ATTR_HEALTHY,
27  ATTR_SUPPORTED,
28  ATTR_UNHEALTHY_REASONS,
29  ATTR_UNSUPPORTED_REASONS,
30  ATTR_UPDATE_KEY,
31  ATTR_WS_EVENT,
32  DOMAIN,
33  EVENT_HEALTH_CHANGED,
34  EVENT_ISSUE_CHANGED,
35  EVENT_ISSUE_REMOVED,
36  EVENT_SUPERVISOR_EVENT,
37  EVENT_SUPERVISOR_UPDATE,
38  EVENT_SUPPORTED_CHANGED,
39  ISSUE_KEY_ADDON_BOOT_FAIL,
40  ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
41  ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
42  ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
43  PLACEHOLDER_KEY_ADDON,
44  PLACEHOLDER_KEY_ADDON_URL,
45  PLACEHOLDER_KEY_REFERENCE,
46  REQUEST_REFRESH_DELAY,
47  UPDATE_KEY_SUPERVISOR,
48 )
49 from .coordinator import get_addons_info
50 from .handler import HassIO, get_supervisor_client
51 
52 ISSUE_KEY_UNHEALTHY = "unhealthy"
53 ISSUE_KEY_UNSUPPORTED = "unsupported"
54 ISSUE_ID_UNHEALTHY = "unhealthy_system"
55 ISSUE_ID_UNSUPPORTED = "unsupported_system"
56 
57 INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy"
58 INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported"
59 
60 PLACEHOLDER_KEY_REASON = "reason"
61 
62 UNSUPPORTED_REASONS = {
63  "apparmor",
64  "connectivity_check",
65  "content_trust",
66  "dbus",
67  "dns_server",
68  "docker_configuration",
69  "docker_version",
70  "cgroup_version",
71  "job_conditions",
72  "lxc",
73  "network_manager",
74  "os",
75  "os_agent",
76  "restart_policy",
77  "software",
78  "source_mods",
79  "supervisor_version",
80  "systemd",
81  "systemd_journal",
82  "systemd_resolved",
83 }
84 # Some unsupported reasons also mark the system as unhealthy. If the unsupported reason
85 # provides no additional information beyond the unhealthy one then skip that repair.
86 UNSUPPORTED_SKIP_REPAIR = {"privileged"}
87 UNHEALTHY_REASONS = {
88  "docker",
89  "supervisor",
90  "setup",
91  "privileged",
92  "untrusted",
93 }
94 
95 # Keys (type + context) of issues that when found should be made into a repair
96 ISSUE_KEYS_FOR_REPAIRS = {
97  ISSUE_KEY_ADDON_BOOT_FAIL,
98  "issue_mount_mount_failed",
99  "issue_system_multiple_data_disks",
100  "issue_system_reboot_required",
101  ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
102  ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
103  ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
104 }
105 
106 _LOGGER = logging.getLogger(__name__)
107 
108 
109 class SuggestionDataType(TypedDict):
110  """Suggestion dictionary as received from supervisor."""
111 
112  uuid: str
113  type: str
114  context: str
115  reference: str | None
116 
117 
118 @dataclass(slots=True, frozen=True)
120  """Suggestion from Supervisor which resolves an issue."""
121 
122  uuid: UUID
123  type: str
124  context: ContextType
125  reference: str | None = None
126 
127  @property
128  def key(self) -> str:
129  """Get key for suggestion (combination of context and type)."""
130  return f"{self.context}_{self.type}"
131 
132  @classmethod
133  def from_dict(cls, data: SuggestionDataType) -> Suggestion:
134  """Convert from dictionary representation."""
135  return cls(
136  uuid=UUID(data["uuid"]),
137  type=data["type"],
138  context=ContextType(data["context"]),
139  reference=data["reference"],
140  )
141 
142 
143 class IssueDataType(TypedDict):
144  """Issue dictionary as received from supervisor."""
145 
146  uuid: str
147  type: str
148  context: str
149  reference: str | None
150  suggestions: NotRequired[list[SuggestionDataType]]
151 
152 
153 @dataclass(slots=True, frozen=True)
154 class Issue:
155  """Issue from Supervisor."""
156 
157  uuid: UUID
158  type: str
159  context: ContextType
160  reference: str | None = None
161  suggestions: list[Suggestion] = field(default_factory=list, compare=False)
162 
163  @property
164  def key(self) -> str:
165  """Get key for issue (combination of context and type)."""
166  return f"issue_{self.context}_{self.type}"
167 
168  @classmethod
169  def from_dict(cls, data: IssueDataType) -> Issue:
170  """Convert from dictionary representation."""
171  suggestions: list[SuggestionDataType] = data.get("suggestions", [])
172  return cls(
173  uuid=UUID(data["uuid"]),
174  type=data["type"],
175  context=ContextType(data["context"]),
176  reference=data["reference"],
177  suggestions=[
178  Suggestion.from_dict(suggestion) for suggestion in suggestions
179  ],
180  )
181 
182 
184  """Create issues from supervisor events."""
185 
186  def __init__(self, hass: HomeAssistant, client: HassIO) -> None:
187  """Initialize supervisor issues."""
188  self._hass_hass = hass
189  self._client_client = client
190  self._unsupported_reasons_unsupported_reasons: set[str] = set()
191  self._unhealthy_reasons_unhealthy_reasons: set[str] = set()
192  self._issues: dict[UUID, Issue] = {}
193  self._supervisor_client_supervisor_client = get_supervisor_client(hass)
194 
195  @property
196  def unhealthy_reasons(self) -> set[str]:
197  """Get unhealthy reasons. Returns empty set if system is healthy."""
198  return self._unhealthy_reasons_unhealthy_reasons
199 
200  @unhealthy_reasons.setter
201  def unhealthy_reasons(self, reasons: set[str]) -> None:
202  """Set unhealthy reasons. Create or delete repairs as necessary."""
203  for unhealthy in reasons - self.unhealthy_reasonsunhealthy_reasonsunhealthy_reasonsunhealthy_reasons:
204  if unhealthy in UNHEALTHY_REASONS:
205  translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}"
206  translation_placeholders = None
207  else:
208  translation_key = ISSUE_KEY_UNHEALTHY
209  translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy}
210 
212  self._hass_hass,
213  DOMAIN,
214  f"{ISSUE_ID_UNHEALTHY}_{unhealthy}",
215  is_fixable=False,
216  learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}",
217  severity=IssueSeverity.CRITICAL,
218  translation_key=translation_key,
219  translation_placeholders=translation_placeholders,
220  )
221 
222  for fixed in self.unhealthy_reasonsunhealthy_reasonsunhealthy_reasonsunhealthy_reasons - reasons:
223  async_delete_issue(self._hass_hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}")
224 
225  self._unhealthy_reasons_unhealthy_reasons = reasons
226 
227  @property
228  def unsupported_reasons(self) -> set[str]:
229  """Get unsupported reasons. Returns empty set if system is supported."""
230  return self._unsupported_reasons_unsupported_reasons
231 
232  @unsupported_reasons.setter
233  def unsupported_reasons(self, reasons: set[str]) -> None:
234  """Set unsupported reasons. Create or delete repairs as necessary."""
235  for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasonsunsupported_reasonsunsupported_reasonsunsupported_reasons:
236  if unsupported in UNSUPPORTED_REASONS:
237  translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}"
238  translation_placeholders = None
239  else:
240  translation_key = ISSUE_KEY_UNSUPPORTED
241  translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported}
242 
244  self._hass_hass,
245  DOMAIN,
246  f"{ISSUE_ID_UNSUPPORTED}_{unsupported}",
247  is_fixable=False,
248  learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}",
249  severity=IssueSeverity.WARNING,
250  translation_key=translation_key,
251  translation_placeholders=translation_placeholders,
252  )
253 
254  for fixed in self.unsupported_reasonsunsupported_reasonsunsupported_reasonsunsupported_reasons - (reasons - UNSUPPORTED_SKIP_REPAIR):
255  async_delete_issue(self._hass_hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}")
256 
257  self._unsupported_reasons_unsupported_reasons = reasons
258 
259  @property
260  def issues(self) -> set[Issue]:
261  """Get issues."""
262  return set(self._issues.values())
263 
264  def add_issue(self, issue: Issue) -> None:
265  """Add or update an issue in the list. Create or update a repair if necessary."""
266  if issue.key in ISSUE_KEYS_FOR_REPAIRS:
267  placeholders: dict[str, str] | None = None
268  if issue.reference:
269  placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference}
270 
271  if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING:
272  placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
273  f"/hassio/addon/{issue.reference}"
274  )
275  addons = get_addons_info(self._hass_hass)
276  if addons and issue.reference in addons:
277  placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][
278  "name"
279  ]
280  else:
281  placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
282 
284  self._hass_hass,
285  DOMAIN,
286  issue.uuid.hex,
287  is_fixable=bool(issue.suggestions),
288  severity=IssueSeverity.WARNING,
289  translation_key=issue.key,
290  translation_placeholders=placeholders,
291  )
292 
293  self._issues[issue.uuid] = issue
294 
295  async def add_issue_from_data(self, data: SupervisorIssue) -> None:
296  """Add issue from data to list after getting latest suggestions."""
297  try:
298  suggestions = (
299  await self._supervisor_client_supervisor_client.resolution.suggestions_for_issue(
300  data.uuid
301  )
302  )
303  except SupervisorError:
304  _LOGGER.error(
305  "Could not get suggestions for supervisor issue %s, skipping it",
306  data.uuid.hex,
307  )
308  return
309  self.add_issueadd_issue(
310  Issue(
311  uuid=data.uuid,
312  type=str(data.type),
313  context=data.context,
314  reference=data.reference,
315  suggestions=[
316  Suggestion(
317  uuid=suggestion.uuid,
318  type=str(suggestion.type),
319  context=suggestion.context,
320  reference=suggestion.reference,
321  )
322  for suggestion in suggestions
323  ],
324  )
325  )
326 
327  def remove_issue(self, issue: Issue) -> None:
328  """Remove an issue from the list. Delete a repair if necessary."""
329  if issue.uuid not in self._issues:
330  return
331 
332  if issue.key in ISSUE_KEYS_FOR_REPAIRS:
333  async_delete_issue(self._hass_hass, DOMAIN, issue.uuid.hex)
334 
335  del self._issues[issue.uuid]
336 
337  def get_issue(self, issue_id: str) -> Issue | None:
338  """Get issue from key."""
339  return self._issues.get(UUID(issue_id))
340 
341  async def setup(self) -> None:
342  """Create supervisor events listener."""
343  await self._update_update()
344 
346  self._hass_hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues_supervisor_events_to_issues
347  )
348 
349  async def _update(self, _: datetime | None = None) -> None:
350  """Update issues from Supervisor resolution center."""
351  try:
352  data = await self._supervisor_client_supervisor_client.resolution.info()
353  except SupervisorError as err:
354  _LOGGER.error("Failed to update supervisor issues: %r", err)
356  self._hass_hass,
357  REQUEST_REFRESH_DELAY,
358  HassJob(self._update_update, cancel_on_shutdown=True),
359  )
360  return
361  self.unhealthy_reasonsunhealthy_reasonsunhealthy_reasonsunhealthy_reasons = set(data.unhealthy)
362  self.unsupported_reasonsunsupported_reasonsunsupported_reasonsunsupported_reasons = set(data.unsupported)
363 
364  # Remove any cached issues that weren't returned
365  for issue_id in set(self._issues) - {issue.uuid for issue in data.issues}:
366  self.remove_issueremove_issue(self._issues[issue_id])
367 
368  # Add/update any issues that came back
369  await asyncio.gather(
370  *[self.add_issue_from_dataadd_issue_from_data(issue) for issue in data.issues]
371  )
372 
373  @callback
374  def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None:
375  """Create issues from supervisor events."""
376  if ATTR_WS_EVENT not in event:
377  return
378 
379  if (
380  event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
381  and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
382  ):
383  self._hass_hass.async_create_task(self._update_update())
384 
385  elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED:
387  set()
388  if event[ATTR_DATA][ATTR_HEALTHY]
389  else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS])
390  )
391 
392  elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED:
394  set()
395  if event[ATTR_DATA][ATTR_SUPPORTED]
396  else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS])
397  )
398 
399  elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED:
400  self.add_issueadd_issue(Issue.from_dict(event[ATTR_DATA]))
401 
402  elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED:
403  self.remove_issueremove_issue(Issue.from_dict(event[ATTR_DATA]))
Issue from_dict(cls, IssueDataType data)
Definition: issues.py:169
Suggestion from_dict(cls, SuggestionDataType data)
Definition: issues.py:133
None add_issue_from_data(self, SupervisorIssue data)
Definition: issues.py:295
None unhealthy_reasons(self, set[str] reasons)
Definition: issues.py:201
None __init__(self, HomeAssistant hass, HassIO client)
Definition: issues.py:186
None unsupported_reasons(self, set[str] reasons)
Definition: issues.py:233
None _supervisor_events_to_issues(self, dict[str, Any] event)
Definition: issues.py:374
None _update(self, datetime|None _=None)
Definition: issues.py:349
Issue|None get_issue(self, str issue_id)
Definition: issues.py:337
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
SupervisorClient get_supervisor_client(HomeAssistant hass)
Definition: handler.py:344
None async_create_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:69
None async_delete_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:85
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