Skip to content

Commit

Permalink
Resolution center MVP (home-assistant#74243)
Browse files Browse the repository at this point in the history
Co-authored-by: Paulus Schoutsen <[email protected]>
  • Loading branch information
emontnemery and balloob authored Jul 8, 2022
1 parent 405d323 commit 0e3f7bc
Show file tree
Hide file tree
Showing 15 changed files with 935 additions and 0 deletions.
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ homeassistant.components.recollect_waste.*
homeassistant.components.recorder.*
homeassistant.components.remote.*
homeassistant.components.renault.*
homeassistant.components.resolution_center.*
homeassistant.components.ridwell.*
homeassistant.components.rituals_perfume_genie.*
homeassistant.components.roku.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/renault/ @epenet
/tests/components/renault/ @epenet
/homeassistant/components/repetier/ @MTrab @ShadowBr0ther
/homeassistant/components/resolution_center/ @home-assistant/core
/tests/components/resolution_center/ @home-assistant/core
/homeassistant/components/rflink/ @javicalle
/tests/components/rflink/ @javicalle
/homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221
Expand Down
20 changes: 20 additions & 0 deletions homeassistant/components/resolution_center/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""The resolution center integration."""
from __future__ import annotations

from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType

from . import websocket_api
from .const import DOMAIN
from .issue_handler import async_create_issue, async_delete_issue
from .issue_registry import async_load as async_load_issue_registry

__all__ = ["DOMAIN", "async_create_issue", "async_delete_issue"]


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Resolution Center."""
websocket_api.async_setup(hass)
await async_load_issue_registry(hass)

return True
3 changes: 3 additions & 0 deletions homeassistant/components/resolution_center/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Resolution Center integration."""

DOMAIN = "resolution_center"
62 changes: 62 additions & 0 deletions homeassistant/components/resolution_center/issue_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""The resolution center integration."""
from __future__ import annotations

from awesomeversion import AwesomeVersion, AwesomeVersionStrategy

from homeassistant.core import HomeAssistant, callback

from .issue_registry import async_get as async_get_issue_registry
from .models import IssueSeverity


@callback
def async_create_issue(
hass: HomeAssistant,
domain: str,
issue_id: str,
*,
breaks_in_ha_version: str | None = None,
learn_more_url: str | None = None,
severity: IssueSeverity,
translation_key: str,
translation_placeholders: dict[str, str] | None = None,
) -> None:
"""Create an issue, or replace an existing one."""
# Verify the breaks_in_ha_version is a valid version string
if breaks_in_ha_version:
AwesomeVersion(
breaks_in_ha_version,
ensure_strategy=AwesomeVersionStrategy.CALVER,
find_first_match=False,
)

issue_registry = async_get_issue_registry(hass)
issue_registry.async_get_or_create(
domain,
issue_id,
breaks_in_ha_version=breaks_in_ha_version,
learn_more_url=learn_more_url,
severity=severity,
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)


@callback
def async_delete_issue(hass: HomeAssistant, domain: str, issue_id: str) -> None:
"""Delete an issue.
It is not an error to delete an issue that does not exist.
"""
issue_registry = async_get_issue_registry(hass)
issue_registry.async_delete(domain, issue_id)


@callback
def async_dismiss_issue(hass: HomeAssistant, domain: str, issue_id: str) -> None:
"""Dismiss an issue.
Will raise if the issue does not exist.
"""
issue_registry = async_get_issue_registry(hass)
issue_registry.async_dismiss(domain, issue_id)
164 changes: 164 additions & 0 deletions homeassistant/components/resolution_center/issue_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Persistently store issues raised by integrations."""
from __future__ import annotations

import dataclasses
from typing import cast

from homeassistant.const import __version__ as ha_version
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store

from .models import IssueSeverity

DATA_REGISTRY = "issue_registry"
STORAGE_KEY = "resolution_center.issue_registry"
STORAGE_VERSION = 1
SAVE_DELAY = 10
SAVED_FIELDS = ("dismissed_version", "domain", "issue_id")


@dataclasses.dataclass(frozen=True)
class IssueEntry:
"""Issue Registry Entry."""

active: bool
breaks_in_ha_version: str | None
dismissed_version: str | None
domain: str
issue_id: str
learn_more_url: str | None
severity: IssueSeverity | None
translation_key: str | None
translation_placeholders: dict[str, str] | None


class IssueRegistry:
"""Class to hold a registry of issues."""

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the issue registry."""
self.hass = hass
self.issues: dict[tuple[str, str], IssueEntry] = {}
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY, atomic_writes=True)

@callback
def async_get_issue(self, domain: str, issue_id: str) -> IssueEntry | None:
"""Get issue by id."""
return self.issues.get((domain, issue_id))

@callback
def async_get_or_create(
self,
domain: str,
issue_id: str,
*,
breaks_in_ha_version: str | None = None,
learn_more_url: str | None = None,
severity: IssueSeverity,
translation_key: str,
translation_placeholders: dict[str, str] | None = None,
) -> IssueEntry:
"""Get issue. Create if it doesn't exist."""

if (issue := self.async_get_issue(domain, issue_id)) is None:
issue = IssueEntry(
active=True,
breaks_in_ha_version=breaks_in_ha_version,
dismissed_version=None,
domain=domain,
issue_id=issue_id,
learn_more_url=learn_more_url,
severity=severity,
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)
self.issues[(domain, issue_id)] = issue
self.async_schedule_save()
else:
issue = self.issues[(domain, issue_id)] = dataclasses.replace(
issue,
active=True,
breaks_in_ha_version=breaks_in_ha_version,
learn_more_url=learn_more_url,
severity=severity,
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)

return issue

@callback
def async_delete(self, domain: str, issue_id: str) -> None:
"""Delete issue."""
if self.issues.pop((domain, issue_id), None) is None:
return

self.async_schedule_save()

@callback
def async_dismiss(self, domain: str, issue_id: str) -> IssueEntry:
"""Dismiss issue."""
old = self.issues[(domain, issue_id)]
if old.dismissed_version == ha_version:
return old

issue = self.issues[(domain, issue_id)] = dataclasses.replace(
old,
dismissed_version=ha_version,
)

self.async_schedule_save()

return issue

async def async_load(self) -> None:
"""Load the issue registry."""
data = await self._store.async_load()

issues: dict[tuple[str, str], IssueEntry] = {}

if isinstance(data, dict):
for issue in data["issues"]:
issues[(issue["domain"], issue["issue_id"])] = IssueEntry(
active=False,
breaks_in_ha_version=None,
dismissed_version=issue["dismissed_version"],
domain=issue["domain"],
issue_id=issue["issue_id"],
learn_more_url=None,
severity=None,
translation_key=None,
translation_placeholders=None,
)

self.issues = issues

@callback
def async_schedule_save(self) -> None:
"""Schedule saving the issue registry."""
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)

@callback
def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]:
"""Return data of issue registry to store in a file."""
data = {}

data["issues"] = [
{field: getattr(entry, field) for field in SAVED_FIELDS}
for entry in self.issues.values()
]

return data


@callback
def async_get(hass: HomeAssistant) -> IssueRegistry:
"""Get issue registry."""
return cast(IssueRegistry, hass.data[DATA_REGISTRY])


async def async_load(hass: HomeAssistant) -> None:
"""Load issue registry."""
assert DATA_REGISTRY not in hass.data
hass.data[DATA_REGISTRY] = IssueRegistry(hass)
await hass.data[DATA_REGISTRY].async_load()
7 changes: 7 additions & 0 deletions homeassistant/components/resolution_center/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"domain": "resolution_center",
"name": "Resolution Center",
"config_flow": false,
"documentation": "https://www.home-assistant.io/integrations/resolution_center",
"codeowners": ["@home-assistant/core"]
}
12 changes: 12 additions & 0 deletions homeassistant/components/resolution_center/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Models for Resolution Center."""
from __future__ import annotations

from homeassistant.backports.enum import StrEnum


class IssueSeverity(StrEnum):
"""Issue severity."""

CRITICAL = "critical"
ERROR = "error"
WARNING = "warning"
62 changes: 62 additions & 0 deletions homeassistant/components/resolution_center/websocket_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""The resolution center websocket API."""
from __future__ import annotations

import dataclasses
from typing import Any

import voluptuous as vol

from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback

from .issue_handler import async_dismiss_issue
from .issue_registry import async_get as async_get_issue_registry


@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the resolution center websocket API."""
websocket_api.async_register_command(hass, ws_dismiss_issue)
websocket_api.async_register_command(hass, ws_list_issues)


@callback
@websocket_api.websocket_command(
{
vol.Required("type"): "resolution_center/dismiss_issue",
vol.Required("domain"): str,
vol.Required("issue_id"): str,
}
)
def ws_dismiss_issue(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Fix an issue."""
async_dismiss_issue(hass, msg["domain"], msg["issue_id"])

connection.send_result(msg["id"])


@websocket_api.websocket_command(
{
vol.Required("type"): "resolution_center/list_issues",
}
)
@callback
def ws_list_issues(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Return a list of issues."""

def ws_dict(kv_pairs: list[tuple[Any, Any]]) -> dict[Any, Any]:
result = {k: v for k, v in kv_pairs if k != "active"}
result["dismissed"] = result["dismissed_version"] is not None
return result

issue_registry = async_get_issue_registry(hass)
issues = [
dataclasses.asdict(issue, dict_factory=ws_dict)
for issue in issue_registry.issues.values()
]

connection.send_result(msg["id"], {"issues": issues})
11 changes: 11 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1864,6 +1864,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true

[mypy-homeassistant.components.resolution_center.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true

[mypy-homeassistant.components.ridwell.*]
check_untyped_defs = true
disallow_incomplete_defs = true
Expand Down
1 change: 1 addition & 0 deletions script/hassfest/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"proxy",
"python_script",
"raspberry_pi",
"resolution_center",
"safe_mode",
"script",
"search",
Expand Down
1 change: 1 addition & 0 deletions tests/components/resolution_center/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the resolution center integration."""
Loading

0 comments on commit 0e3f7bc

Please sign in to comment.