From 9a150c2234507be2969483b2c4721f5dc7667dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 30 Mar 2022 02:38:56 +0200 Subject: [PATCH] Add release_notes method to update entities (#68842) --- homeassistant/components/demo/update.py | 12 +++ homeassistant/components/update/__init__.py | 55 +++++++++++++ homeassistant/components/update/const.py | 1 + tests/components/update/test_init.py | 82 +++++++++++++++++++ .../custom_components/test/update.py | 11 +++ 5 files changed, 161 insertions(+) diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py index d3b3aacfa68..e0f5814c63d 100644 --- a/homeassistant/components/demo/update.py +++ b/homeassistant/components/demo/update.py @@ -71,6 +71,7 @@ async def async_setup_platform( latest_version="1.94.2", support_progress=True, release_summary="Added support for effects", + support_release_notes=True, release_url="https://www.example.com/release/1.93.3", device_class=UpdateDeviceClass.FIRMWARE, ), @@ -109,6 +110,7 @@ class DemoUpdate(UpdateEntity): release_url: str | None = None, support_progress: bool = False, support_install: bool = True, + support_release_notes: bool = False, device_class: UpdateDeviceClass | None = None, ) -> None: """Initialize the Demo select entity.""" @@ -133,6 +135,9 @@ class DemoUpdate(UpdateEntity): if support_progress: self._attr_supported_features |= UpdateEntityFeature.PROGRESS + if support_release_notes: + self._attr_supported_features |= UpdateEntityFeature.RELEASE_NOTES + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: @@ -148,3 +153,10 @@ class DemoUpdate(UpdateEntity): version if version is not None else self.latest_version ) self.async_write_ha_state() + + def release_notes(self) -> str | None: + """Return the release notes.""" + return ( + "Long release notes.\n\n**With** " + f"markdown support!\n\n***\n\n{self.release_summary}" + ) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index b514553208e..8b95415c010 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -9,6 +9,7 @@ from typing import Any, Final, final import voluptuous as vol from homeassistant.backports.enum import StrEnum +from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall @@ -93,6 +94,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: {}, UpdateEntity.async_skip.__name__, ) + websocket_api.async_register_command(hass, websocket_release_notes) return True @@ -268,6 +270,22 @@ class UpdateEntity(RestoreEntity): """ raise NotImplementedError() + async def async_release_notes(self) -> str | None: + """Return full release notes. + + This is suitable for a long changelog that does not fit in the release_summary property. + The returned string can contain markdown. + """ + return await self.hass.async_add_executor_job(self.release_notes) + + def release_notes(self) -> str | None: + """Return full release notes. + + This is suitable for a long changelog that does not fit in the release_summary property. + The returned string can contain markdown. + """ + raise NotImplementedError() + @property @final def state(self) -> str | None: @@ -343,3 +361,40 @@ class UpdateEntity(RestoreEntity): state = await self.async_get_last_state() if state is not None and state.attributes.get(ATTR_SKIPPED_VERSION) is not None: self.__skipped_version = state.attributes[ATTR_SKIPPED_VERSION] + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "update/release_notes", + vol.Required("entity_id"): cv.entity_id, + } +) +@websocket_api.async_response +async def websocket_release_notes( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Get the full release notes for a entity.""" + component = hass.data[DOMAIN] + entity: UpdateEntity | None = component.get_entity(msg["entity_id"]) + + if entity is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Entity not found" + ) + return + + if not entity.supported_features & UpdateEntityFeature.RELEASE_NOTES: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + "Entity does not support release notes", + ) + return + + connection.send_result( + msg["id"], + await entity.async_release_notes(), + ) diff --git a/homeassistant/components/update/const.py b/homeassistant/components/update/const.py index 2ddf08a20ff..7c70572f458 100644 --- a/homeassistant/components/update/const.py +++ b/homeassistant/components/update/const.py @@ -14,6 +14,7 @@ class UpdateEntityFeature(IntEnum): SPECIFIC_VERSION = 2 PROGRESS = 4 BACKUP = 8 + RELEASE_NOTES = 16 SERVICE_INSTALL: Final = "install" diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 7d175337d13..3fbe80dbdbf 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -1,6 +1,8 @@ """The tests for the Update component.""" +from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch +from aiohttp import ClientWebSocketResponse import pytest from homeassistant.components.update import ( @@ -587,3 +589,83 @@ async def test_restore_state( assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" assert state.attributes[ATTR_SKIPPED_VERSION] == "1.0.1" + + +async def test_release_notes( + hass: HomeAssistant, + enable_custom_integrations: None, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test getting the release notes over the websocket connection.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.update_with_release_notes", + } + ) + result = await client.receive_json() + assert result["result"] == "Release notes" + + +async def test_release_notes_entity_not_found( + hass: HomeAssistant, + enable_custom_integrations: None, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test getting the release notes for not found entity.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.entity_not_found", + } + ) + result = await client.receive_json() + assert result["error"]["code"] == "not_found" + assert result["error"]["message"] == "Entity not found" + + +async def test_release_notes_entity_does_not_support_release_notes( + hass: HomeAssistant, + enable_custom_integrations: None, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test getting the release notes for entity that does not support release notes.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.update_available", + } + ) + result = await client.receive_json() + assert result["error"]["code"] == "not_supported" + assert result["error"]["message"] == "Entity does not support release notes" diff --git a/tests/testing_config/custom_components/test/update.py b/tests/testing_config/custom_components/test/update.py index 83b2c065b2e..ce8cd3869f5 100644 --- a/tests/testing_config/custom_components/test/update.py +++ b/tests/testing_config/custom_components/test/update.py @@ -62,6 +62,10 @@ class MockUpdateEntity(MockEntity, UpdateEntity): self._values["current_version"] = self.latest_version _LOGGER.info("Installed latest update") + def release_notes(self) -> str | None: + """Return the release notes of the latest version.""" + return "Release notes" + def init(empty=False): """Initialize the platform with entities.""" @@ -124,6 +128,13 @@ def init(empty=False): current_version="1.0.0", latest_version="1.0.1", ), + MockUpdateEntity( + name="Update with release notes", + unique_id="with_release_notes", + current_version="1.0.0", + latest_version="1.0.1", + supported_features=UpdateEntityFeature.RELEASE_NOTES, + ), ] )