From 1a78e18eeb1ebd40fe678dcfef3ab34495db49cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 3 Mar 2022 17:27:09 +0100 Subject: [PATCH] Add update integration (#66552) Co-authored-by: Paulus Schoutsen --- .core_files.yaml | 1 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/default_config/manifest.json | 3 +- homeassistant/components/update/__init__.py | 273 ++++++++++++++ homeassistant/components/update/manifest.json | 10 + homeassistant/components/update/strings.json | 3 + .../components/update/translations/en.json | 3 + mypy.ini | 11 + tests/components/update/__init__.py | 1 + tests/components/update/test_init.py | 347 ++++++++++++++++++ 11 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/update/__init__.py create mode 100644 homeassistant/components/update/manifest.json create mode 100644 homeassistant/components/update/strings.json create mode 100644 homeassistant/components/update/translations/en.json create mode 100644 tests/components/update/__init__.py create mode 100644 tests/components/update/test_init.py diff --git a/.core_files.yaml b/.core_files.yaml index ebc3ff376f8..160a0d80d9a 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -94,6 +94,7 @@ components: &components - homeassistant/components/tag/* - homeassistant/components/template/* - homeassistant/components/timer/* + - homeassistant/components/update/* - homeassistant/components/usb/* - homeassistant/components/webhook/* - homeassistant/components/websocket_api/* diff --git a/.strict-typing b/.strict-typing index 73975e2d1f7..a5a2127eb68 100644 --- a/.strict-typing +++ b/.strict-typing @@ -204,6 +204,7 @@ homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* +homeassistant.components.update.* homeassistant.components.uptime.* homeassistant.components.uptimerobot.* homeassistant.components.usb.* diff --git a/CODEOWNERS b/CODEOWNERS index ce1e69244d0..82930f875f4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1053,6 +1053,8 @@ tests/components/upb/* @gwww homeassistant/components/upc_connect/* @pvizeli @fabaff homeassistant/components/upcloud/* @scop tests/components/upcloud/* @scop +homeassistant/components/update/* @home-assistant/core +tests/components/update/* @home-assistant/core homeassistant/components/updater/* @home-assistant/core tests/components/updater/* @home-assistant/core homeassistant/components/upnp/* @StevenLooman @ehendrix23 diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 1ab827529c6..7059df580d9 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -31,10 +31,11 @@ "tag", "timer", "usb", + "update", "webhook", "zeroconf", "zone" ], "codeowners": ["@home-assistant/core"], "quality_scale": "internal" -} +} \ No newline at end of file diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py new file mode 100644 index 00000000000..66f99b83117 --- /dev/null +++ b/homeassistant/components/update/__init__.py @@ -0,0 +1,273 @@ +"""Support for Update.""" +from __future__ import annotations + +import asyncio +import dataclasses +import logging +from typing import Any, Protocol + +import async_timeout +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import integration_platform, storage +from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "update" +INFO_CALLBACK_TIMEOUT = 5 +STORAGE_VERSION = 1 + + +class IntegrationUpdateFailed(HomeAssistantError): + """Error to indicate an update has failed.""" + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Update integration.""" + hass.data[DOMAIN] = UpdateManager(hass=hass) + websocket_api.async_register_command(hass, handle_info) + websocket_api.async_register_command(hass, handle_update) + websocket_api.async_register_command(hass, handle_skip) + return True + + +@websocket_api.websocket_command({vol.Required("type"): "update/info"}) +@websocket_api.async_response +async def handle_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get pending updates from all platforms.""" + manager: UpdateManager = hass.data[DOMAIN] + updates = await manager.gather_updates() + connection.send_result(msg["id"], updates) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "update/skip", + vol.Required("domain"): str, + vol.Required("identifier"): str, + vol.Required("version"): str, + } +) +@websocket_api.async_response +async def handle_skip( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Skip an update.""" + manager: UpdateManager = hass.data[DOMAIN] + + if not await manager.domain_is_valid(msg["domain"]): + connection.send_error( + msg["id"], websocket_api.ERR_NOT_FOUND, "Domain not supported" + ) + return + + manager.skip_update(msg["domain"], msg["identifier"], msg["version"]) + connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "update/update", + vol.Required("domain"): str, + vol.Required("identifier"): str, + vol.Required("version"): str, + vol.Optional("backup"): bool, + } +) +@websocket_api.async_response +async def handle_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle an update.""" + manager: UpdateManager = hass.data[DOMAIN] + + if not await manager.domain_is_valid(msg["domain"]): + connection.send_error( + msg["id"], + websocket_api.ERR_NOT_FOUND, + f"{msg['domain']} is not a supported domain", + ) + return + + try: + await manager.perform_update( + domain=msg["domain"], + identifier=msg["identifier"], + version=msg["version"], + backup=msg.get("backup"), + ) + except IntegrationUpdateFailed as err: + connection.send_error( + msg["id"], + "update_failed", + str(err), + ) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Update of %s to version %s failed", + msg["identifier"], + msg["version"], + ) + connection.send_error( + msg["id"], + "update_failed", + "Unknown Error", + ) + else: + connection.send_result(msg["id"]) + + +class UpdatePlatformProtocol(Protocol): + """Define the format that update platforms can have.""" + + async def async_list_updates(self, hass: HomeAssistant) -> list[UpdateDescription]: + """List all updates available in the integration.""" + + async def async_perform_update( + self, + hass: HomeAssistant, + identifier: str, + version: str, + **kwargs: Any, + ) -> None: + """Perform an update.""" + + +@dataclasses.dataclass() +class UpdateDescription: + """Describe an update update.""" + + identifier: str + name: str + current_version: str + available_version: str + changelog_content: str | None = None + changelog_url: str | None = None + icon_url: str | None = None + supports_backup: bool = False + + +class UpdateManager: + """Update manager for the update integration.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the update manager.""" + self._hass = hass + self._store = storage.Store( + hass=hass, + version=STORAGE_VERSION, + key=DOMAIN, + ) + self._skip: set[str] = set() + self._platforms: dict[str, UpdatePlatformProtocol] = {} + self._loaded = False + + async def add_platform( + self, + hass: HomeAssistant, + integration_domain: str, + platform: UpdatePlatformProtocol, + ) -> None: + """Add a platform to the update manager.""" + self._platforms[integration_domain] = platform + + async def _load(self) -> None: + """Load platforms and data from storage.""" + await integration_platform.async_process_integration_platforms( + self._hass, DOMAIN, self.add_platform + ) + from_storage = await self._store.async_load() + if isinstance(from_storage, dict): + self._skip = set(from_storage["skipped"]) + + self._loaded = True + + async def gather_updates(self) -> list[dict[str, Any]]: + """Gather updates.""" + if not self._loaded: + await self._load() + + updates: dict[str, list[UpdateDescription] | None] = {} + + for domain, update_descriptions in zip( + self._platforms, + await asyncio.gather( + *( + self._get_integration_info(integration_domain, registration) + for integration_domain, registration in self._platforms.items() + ) + ), + ): + updates[domain] = update_descriptions + + return [ + { + "domain": integration_domain, + **dataclasses.asdict(description), + } + for integration_domain, update_descriptions in updates.items() + if update_descriptions is not None + for description in update_descriptions + if f"{integration_domain}_{description.identifier}_{description.available_version}" + not in self._skip + ] + + async def domain_is_valid(self, domain: str) -> bool: + """Return if the domain is valid.""" + if not self._loaded: + await self._load() + return domain in self._platforms + + @callback + def _data_to_save(self) -> dict[str, Any]: + """Schedule storing the data.""" + return {"skipped": list(self._skip)} + + async def perform_update( + self, + domain: str, + identifier: str, + version: str, + **kwargs: Any, + ) -> None: + """Perform an update.""" + await self._platforms[domain].async_perform_update( + hass=self._hass, + identifier=identifier, + version=version, + **kwargs, + ) + + @callback + def skip_update(self, domain: str, identifier: str, version: str) -> None: + """Skip an update.""" + self._skip.add(f"{domain}_{identifier}_{version}") + self._store.async_delay_save(self._data_to_save, 60) + + async def _get_integration_info( + self, + integration_domain: str, + platform: UpdatePlatformProtocol, + ) -> list[UpdateDescription] | None: + """Get integration update details.""" + + try: + async with async_timeout.timeout(INFO_CALLBACK_TIMEOUT): + return await platform.async_list_updates(hass=self._hass) + except asyncio.TimeoutError: + _LOGGER.warning("Timeout while getting updates from %s", integration_domain) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error fetching info from %s", integration_domain) + return None diff --git a/homeassistant/components/update/manifest.json b/homeassistant/components/update/manifest.json new file mode 100644 index 00000000000..a005381fb5f --- /dev/null +++ b/homeassistant/components/update/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "update", + "name": "Update", + "documentation": "https://www.home-assistant.io/integrations/update", + "codeowners": [ + "@home-assistant/core" + ], + "quality_scale": "internal", + "iot_class": "calculated" +} \ No newline at end of file diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json new file mode 100644 index 00000000000..95b82de3b4d --- /dev/null +++ b/homeassistant/components/update/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Update" +} \ No newline at end of file diff --git a/homeassistant/components/update/translations/en.json b/homeassistant/components/update/translations/en.json new file mode 100644 index 00000000000..95b82de3b4d --- /dev/null +++ b/homeassistant/components/update/translations/en.json @@ -0,0 +1,3 @@ +{ + "title": "Update" +} \ No newline at end of file diff --git a/mypy.ini b/mypy.ini index e35fb90f0ea..045e9a4eee8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2045,6 +2045,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.update.*] +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.uptime.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/update/__init__.py b/tests/components/update/__init__.py new file mode 100644 index 00000000000..f3e55ca4ed3 --- /dev/null +++ b/tests/components/update/__init__.py @@ -0,0 +1 @@ +"""Tests for the Update integration.""" diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py new file mode 100644 index 00000000000..f91df08bf52 --- /dev/null +++ b/tests/components/update/test_init.py @@ -0,0 +1,347 @@ +"""Tests for the Update integration init.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import Mock, patch + +from aiohttp import ClientWebSocketResponse +import pytest + +from homeassistant.components.update import ( + DOMAIN, + IntegrationUpdateFailed, + UpdateDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import mock_platform + + +async def setup_mock_domain( + hass: HomeAssistant, + async_list_updates: Callable[[HomeAssistant], Awaitable[list[UpdateDescription]]] + | None = None, + async_perform_update: Callable[[HomeAssistant, str, str], Awaitable[bool]] + | None = None, +) -> None: + """Set up a mock domain.""" + + async def _mock_async_list_updates(hass: HomeAssistant) -> list[UpdateDescription]: + return [ + UpdateDescription( + identifier="lorem_ipsum", + name="Lorem Ipsum", + current_version="1.0.0", + available_version="1.0.1", + ) + ] + + async def _mock_async_perform_update( + hass: HomeAssistant, + identifier: str, + version: str, + **kwargs: Any, + ) -> bool: + return True + + mock_platform( + hass, + "some_domain.update", + Mock( + async_list_updates=async_list_updates or _mock_async_list_updates, + async_perform_update=async_perform_update or _mock_async_perform_update, + ), + ) + + assert await async_setup_component(hass, "some_domain", {}) + + +async def gather_update_info( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> list[dict]: + """Gather all info.""" + client = await hass_ws_client(hass) + await client.send_json({"id": 1, "type": "update/info"}) + resp = await client.receive_json() + return resp["result"] + + +async def test_update_updates( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test getting updates.""" + await setup_mock_domain(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + + with patch( + "homeassistant.components.update.storage.Store.async_load", + return_value={"skipped": []}, + ): + data = await gather_update_info(hass, hass_ws_client) + + assert len(data) == 1 + data = data[0] == { + "domain": "some_domain", + "identifier": "lorem_ipsum", + "name": "Lorem Ipsum", + "current_version": "1.0.0", + "available_version": "1.0.1", + "changelog_url": None, + "icon_url": None, + } + + +async def test_update_updates_with_timeout_error( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test timeout while getting updates.""" + + async def mock_async_list_updates(hass: HomeAssistant) -> list[UpdateDescription]: + raise asyncio.TimeoutError() + + await setup_mock_domain(hass, async_list_updates=mock_async_list_updates) + + assert await async_setup_component(hass, DOMAIN, {}) + + data = await gather_update_info(hass, hass_ws_client) + + assert len(data) == 0 + + +async def test_update_updates_with_exception( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test exception while getting updates.""" + + async def mock_async_list_updates(hass: HomeAssistant) -> list[UpdateDescription]: + raise Exception() + + await setup_mock_domain(hass, async_list_updates=mock_async_list_updates) + + assert await async_setup_component(hass, DOMAIN, {}) + data = await gather_update_info(hass, hass_ws_client) + + assert len(data) == 0 + + +async def test_update_update( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test performing an update.""" + await setup_mock_domain(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + data = await gather_update_info(hass, hass_ws_client) + + assert len(data) == 1 + update = data[0] + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "update/update", + "domain": update["domain"], + "identifier": update["identifier"], + "version": update["available_version"], + } + ) + resp = await client.receive_json() + assert resp["success"] + + +async def test_skip_update( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test skipping updates.""" + await setup_mock_domain(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + data = await gather_update_info(hass, hass_ws_client) + + assert len(data) == 1 + update = data[0] + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "update/skip", + "domain": update["domain"], + "identifier": update["identifier"], + "version": update["available_version"], + } + ) + resp = await client.receive_json() + assert resp["success"] + + data = await gather_update_info(hass, hass_ws_client) + assert len(data) == 0 + + +async def test_skip_non_existing_update( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test skipping non-existing updates.""" + await setup_mock_domain(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + data = await gather_update_info(hass, hass_ws_client) + + assert len(data) == 1 + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "update/skip", + "domain": "non_existing", + "identifier": "non_existing", + "version": "non_existing", + } + ) + resp = await client.receive_json() + assert not resp["success"] + + data = await gather_update_info(hass, hass_ws_client) + assert len(data) == 1 + + +async def test_update_update_non_existing( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test that we fail when trying to update something that does not exist.""" + await setup_mock_domain(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + data = await gather_update_info(hass, hass_ws_client) + + assert len(data) == 1 + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "update/update", + "domain": "does_not_exist", + "identifier": "does_not_exist", + "version": "non_existing", + } + ) + resp = await client.receive_json() + assert not resp["success"] + assert resp["error"]["code"] == "not_found" + + +async def test_update_update_failed( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test that we correctly handle failed updates.""" + + async def mock_async_perform_update( + hass: HomeAssistant, + identifier: str, + version: str, + **kwargs, + ) -> bool: + raise IntegrationUpdateFailed("Test update failed") + + await setup_mock_domain(hass, async_perform_update=mock_async_perform_update) + + assert await async_setup_component(hass, DOMAIN, {}) + data = await gather_update_info(hass, hass_ws_client) + + assert len(data) == 1 + update = data[0] + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "update/update", + "domain": update["domain"], + "identifier": update["identifier"], + "version": update["available_version"], + } + ) + resp = await client.receive_json() + assert not resp["success"] + assert resp["error"]["code"] == "update_failed" + assert resp["error"]["message"] == "Test update failed" + + +async def test_update_update_failed_generic( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we correctly handle failed updates.""" + + async def mock_async_perform_update( + hass: HomeAssistant, + identifier: str, + version: str, + **kwargs, + ) -> bool: + raise TypeError("Test update failed") + + await setup_mock_domain(hass, async_perform_update=mock_async_perform_update) + + assert await async_setup_component(hass, DOMAIN, {}) + data = await gather_update_info(hass, hass_ws_client) + + assert len(data) == 1 + update = data[0] + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "update/update", + "domain": update["domain"], + "identifier": update["identifier"], + "version": update["available_version"], + } + ) + resp = await client.receive_json() + assert not resp["success"] + assert resp["error"]["code"] == "update_failed" + assert resp["error"]["message"] == "Unknown Error" + assert "Test update failed" in caplog.text + + +async def test_update_before_info( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test that we fail when trying to update something that does not exist.""" + await setup_mock_domain(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "update/update", + "domain": "does_not_exist", + "identifier": "does_not_exist", + "version": "non_existing", + } + ) + resp = await client.receive_json() + assert not resp["success"] + assert resp["error"]["code"] == "not_found"