Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make repairs out of select supervisor issues #90893

Merged
merged 4 commits into from
Apr 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions homeassistant/components/hassio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
DATA_KEY_HOST,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DATA_KEY_SUPERVISOR_ISSUES,
DOMAIN,
SupervisorEntityModel,
)
Expand Down Expand Up @@ -126,7 +127,6 @@
DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs"
DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats"
DATA_SUPERVISOR_ISSUES = "supervisor_issues"
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)

ADDONS_COORDINATOR = "hassio_addons_coordinator"
Expand Down Expand Up @@ -611,7 +611,7 @@ async def _async_setup_hardware_integration(hass):
)

# Start listening for problems with supervisor and making issues
hass.data[DATA_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio)
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio)
await issues.setup()

return True
Expand Down
7 changes: 7 additions & 0 deletions homeassistant/components/hassio/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
ATTR_HEALTHY = "healthy"
ATTR_HOMEASSISTANT = "homeassistant"
ATTR_INPUT = "input"
ATTR_ISSUES = "issues"
ATTR_METHOD = "method"
ATTR_PANELS = "panels"
ATTR_PASSWORD = "password"
ATTR_RESULT = "result"
ATTR_SUGGESTIONS = "suggestions"
ATTR_SUPPORTED = "supported"
ATTR_TIMEOUT = "timeout"
ATTR_TITLE = "title"
Expand Down Expand Up @@ -49,6 +51,8 @@
EVENT_SUPERVISOR_UPDATE = "supervisor_update"
EVENT_HEALTH_CHANGED = "health_changed"
EVENT_SUPPORTED_CHANGED = "supported_changed"
EVENT_ISSUE_CHANGED = "issue_changed"
EVENT_ISSUE_REMOVED = "issue_removed"

UPDATE_KEY_SUPERVISOR = "supervisor"

Expand All @@ -69,6 +73,9 @@
DATA_KEY_SUPERVISOR = "supervisor"
DATA_KEY_CORE = "core"
DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"

PLACEHOLDER_KEY_REFERENCE = "reference"


class SupervisorEntityModel(str, Enum):
Expand Down
31 changes: 31 additions & 0 deletions homeassistant/components/hassio/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from http import HTTPStatus
import logging
import os
from typing import Any

import aiohttp

Expand Down Expand Up @@ -249,6 +250,18 @@ async def async_update_core(
)


@bind_hass
@_api_bool
async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> bool:
"""Apply a suggestion from supervisor's resolution center.

The caller of the function should handle HassioAPIError.
"""
hassio = hass.data[DOMAIN]
command = f"/resolution/suggestion/{suggestion_uuid}"
return await hassio.send_command(command, timeout=None)


class HassIO:
"""Small API wrapper for Hass.io."""

Expand Down Expand Up @@ -416,6 +429,16 @@ def get_resolution_info(self):
"""
return self.send_command("/resolution/info", method="get")

@api_data
def get_suggestions_for_issue(self, issue_id: str) -> dict[str, Any]:
"""Return suggestions for issue from Supervisor resolution center.

This method returns a coroutine.
"""
return self.send_command(
f"/resolution/issue/{issue_id}/suggestions", method="get"
)

@_api_bool
async def update_hass_api(self, http_config, refresh_token):
"""Update Home Assistant API data on Hass.io."""
Expand Down Expand Up @@ -454,6 +477,14 @@ def update_diagnostics(self, diagnostics: bool):
"/supervisor/options", payload={"diagnostics": diagnostics}
)

@_api_bool
def apply_suggestion(self, suggestion_uuid: str):
"""Apply a suggestion from supervisor's resolution center.

This method returns a coroutine.
"""
return self.send_command(f"/resolution/suggestion/{suggestion_uuid}")

async def send_command(
self,
command,
Expand Down
190 changes: 179 additions & 11 deletions homeassistant/components/hassio/issues.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Supervisor events monitor."""
from __future__ import annotations

from typing import Any
import asyncio
from dataclasses import dataclass, field
import logging
from typing import Any, TypedDict

from typing_extensions import NotRequired

from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
Expand All @@ -14,6 +19,8 @@
from .const import (
ATTR_DATA,
ATTR_HEALTHY,
ATTR_ISSUES,
ATTR_SUGGESTIONS,
ATTR_SUPPORTED,
ATTR_UNHEALTHY,
ATTR_UNHEALTHY_REASONS,
Expand All @@ -23,19 +30,26 @@
ATTR_WS_EVENT,
DOMAIN,
EVENT_HEALTH_CHANGED,
EVENT_ISSUE_CHANGED,
EVENT_ISSUE_REMOVED,
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
EVENT_SUPPORTED_CHANGED,
PLACEHOLDER_KEY_REFERENCE,
UPDATE_KEY_SUPERVISOR,
)
from .handler import HassIO
from .handler import HassIO, HassioAPIError

ISSUE_KEY_UNHEALTHY = "unhealthy"
ISSUE_KEY_UNSUPPORTED = "unsupported"
ISSUE_ID_UNHEALTHY = "unhealthy_system"
ISSUE_ID_UNSUPPORTED = "unsupported_system"

INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy"
INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported"

PLACEHOLDER_KEY_REASON = "reason"

UNSUPPORTED_REASONS = {
"apparmor",
"connectivity_check",
Expand Down Expand Up @@ -69,6 +83,88 @@
"untrusted",
}

# Keys (type + context) of issues that when found should be made into a repair
ISSUE_KEYS_FOR_REPAIRS = {
"issue_system_multiple_data_disks",
"issue_system_reboot_required",
}

_LOGGER = logging.getLogger(__name__)


class SuggestionDataType(TypedDict):
"""Suggestion dictionary as received from supervisor."""

uuid: str
type: str
context: str
reference: str | None


@dataclass(slots=True, frozen=True)
class Suggestion:
"""Suggestion from Supervisor which resolves an issue."""

uuid: str
type_: str
context: str
reference: str | None = None

@property
def key(self) -> str:
"""Get key for suggestion (combination of context and type)."""
return f"{self.context}_{self.type_}"

@staticmethod
def from_dict(data: SuggestionDataType) -> Suggestion:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method can preferably be a classmethod as it returns an instance of the class.

"""Convert from dictionary representation."""
return Suggestion(
uuid=data["uuid"],
type_=data["type"],
context=data["context"],
reference=data["reference"],
)


class IssueDataType(TypedDict):
"""Issue dictionary as received from supervisor."""

uuid: str
type: str
context: str
reference: str | None
suggestions: NotRequired[list[SuggestionDataType]]


@dataclass(slots=True, frozen=True)
class Issue:
"""Issue from Supervisor."""

uuid: str
type_: str
context: str
reference: str | None = None
suggestions: list[Suggestion] = field(default_factory=list, compare=False)

@property
def key(self) -> str:
"""Get key for issue (combination of context and type)."""
return f"issue_{self.context}_{self.type_}"

@staticmethod
def from_dict(data: IssueDataType) -> Issue:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

"""Convert from dictionary representation."""
suggestions: list[SuggestionDataType] = data.get("suggestions", [])
return Issue(
uuid=data["uuid"],
type_=data["type"],
context=data["context"],
reference=data["reference"],
suggestions=[
Suggestion.from_dict(suggestion) for suggestion in suggestions
],
)


class SupervisorIssues:
"""Create issues from supervisor events."""
Expand All @@ -79,6 +175,7 @@ def __init__(self, hass: HomeAssistant, client: HassIO) -> None:
self._client = client
self._unsupported_reasons: set[str] = set()
self._unhealthy_reasons: set[str] = set()
self._issues: dict[str, Issue] = {}

@property
def unhealthy_reasons(self) -> set[str]:
Expand All @@ -87,14 +184,14 @@ def unhealthy_reasons(self) -> set[str]:

@unhealthy_reasons.setter
def unhealthy_reasons(self, reasons: set[str]) -> None:
"""Set unhealthy reasons. Create or delete issues as necessary."""
"""Set unhealthy reasons. Create or delete repairs as necessary."""
for unhealthy in reasons - self.unhealthy_reasons:
if unhealthy in UNHEALTHY_REASONS:
translation_key = f"unhealthy_{unhealthy}"
translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}"
translation_placeholders = None
else:
translation_key = "unhealthy"
translation_placeholders = {"reason": unhealthy}
translation_key = ISSUE_KEY_UNHEALTHY
translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy}

async_create_issue(
self._hass,
Expand All @@ -119,14 +216,14 @@ def unsupported_reasons(self) -> set[str]:

@unsupported_reasons.setter
def unsupported_reasons(self, reasons: set[str]) -> None:
"""Set unsupported reasons. Create or delete issues as necessary."""
"""Set unsupported reasons. Create or delete repairs as necessary."""
for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons:
if unsupported in UNSUPPORTED_REASONS:
translation_key = f"unsupported_{unsupported}"
translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}"
translation_placeholders = None
else:
translation_key = "unsupported"
translation_placeholders = {"reason": unsupported}
translation_key = ISSUE_KEY_UNSUPPORTED
translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported}

async_create_issue(
self._hass,
Expand All @@ -144,6 +241,60 @@ def unsupported_reasons(self, reasons: set[str]) -> None:

self._unsupported_reasons = reasons

def add_issue(self, issue: Issue) -> None:
"""Add or update an issue in the list. Create or update a repair if necessary."""
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
async_create_issue(
self._hass,
DOMAIN,
issue.uuid,
is_fixable=bool(issue.suggestions),
severity=IssueSeverity.WARNING,
translation_key=issue.key,
translation_placeholders={PLACEHOLDER_KEY_REFERENCE: issue.reference}
if issue.reference
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ternary operator expressions that span more than one line are hard to read.

else None,
)

self._issues[issue.uuid] = issue

async def add_issue_from_data(self, data: IssueDataType) -> None:
"""Add issue from data to list after getting latest suggestions."""
try:
suggestions = (await self._client.get_suggestions_for_issue(data["uuid"]))[
ATTR_SUGGESTIONS
]
self.add_issue(
Issue(
uuid=data["uuid"],
type_=data["type"],
context=data["context"],
reference=data["reference"],
suggestions=[
Suggestion.from_dict(suggestion) for suggestion in suggestions
],
)
)
except HassioAPIError:
_LOGGER.error(
"Could not get suggestions for supervisor issue %s, skipping it",
data["uuid"],
)

def remove_issue(self, issue: Issue) -> None:
"""Remove an issue from the list. Delete a repair if necessary."""
if issue.uuid not in self._issues:
return

if issue.key in ISSUE_KEYS_FOR_REPAIRS:
async_delete_issue(self._hass, DOMAIN, issue.uuid)

del self._issues[issue.uuid]

def get_issue(self, issue_id: str) -> Issue | None:
"""Get issue from key."""
return self._issues.get(issue_id)

async def setup(self) -> None:
"""Create supervisor events listener."""
await self.update()
Expand All @@ -153,11 +304,22 @@ async def setup(self) -> None:
)

async def update(self) -> None:
"""Update issuess from Supervisor resolution center."""
"""Update issues from Supervisor resolution center."""
data = await self._client.get_resolution_info()
self.unhealthy_reasons = set(data[ATTR_UNHEALTHY])
self.unsupported_reasons = set(data[ATTR_UNSUPPORTED])

# Remove any cached issues that weren't returned
for issue_id in set(self._issues.keys()) - {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying a dict to a set uses the keys by default. We can remove .keys().

issue["uuid"] for issue in data[ATTR_ISSUES]
}:
self.remove_issue(self._issues[issue_id])

# Add/update any issues that came back
await asyncio.gather(
*[self.add_issue_from_data(issue) for issue in data[ATTR_ISSUES]]
)

@callback
def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None:
"""Create issues from supervisor events."""
Expand All @@ -183,3 +345,9 @@ def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None:
if event[ATTR_DATA][ATTR_SUPPORTED]
else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS])
)

elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED:
self.add_issue(Issue.from_dict(event[ATTR_DATA]))

elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED:
self.remove_issue(Issue.from_dict(event[ATTR_DATA]))
Loading