From 073fb40b79cf8aa06790fdceb23b6857db888c99 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 21 Mar 2022 11:02:48 +0100 Subject: [PATCH] Add update entity platform (#68248) Co-authored-by: Glenn Waters --- .core_files.yaml | 1 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/update.py | 153 +++++ homeassistant/components/update/__init__.py | 353 +++++++++++ homeassistant/components/update/const.py | 30 + homeassistant/components/update/manifest.json | 7 + homeassistant/components/update/services.yaml | 27 + .../components/update/significant_change.py | 30 + homeassistant/components/update/strings.json | 3 + homeassistant/const.py | 1 + mypy.ini | 11 + tests/components/demo/test_update.py | 157 +++++ tests/components/update/__init__.py | 1 + tests/components/update/test_init.py | 583 ++++++++++++++++++ .../update/test_significant_change.py | 90 +++ .../custom_components/test/update.py | 138 +++++ 18 files changed, 1589 insertions(+) create mode 100644 homeassistant/components/demo/update.py create mode 100644 homeassistant/components/update/__init__.py create mode 100644 homeassistant/components/update/const.py create mode 100644 homeassistant/components/update/manifest.json create mode 100644 homeassistant/components/update/services.yaml create mode 100644 homeassistant/components/update/significant_change.py create mode 100644 homeassistant/components/update/strings.json create mode 100644 tests/components/demo/test_update.py create mode 100644 tests/components/update/__init__.py create mode 100644 tests/components/update/test_init.py create mode 100644 tests/components/update/test_significant_change.py create mode 100644 tests/testing_config/custom_components/test/update.py diff --git a/.core_files.yaml b/.core_files.yaml index f154af625603af..654730e56135a5 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -39,6 +39,7 @@ base_platforms: &base_platforms - homeassistant/components/stt/** - homeassistant/components/switch/** - homeassistant/components/tts/** + - homeassistant/components/update/** - homeassistant/components/vacuum/** - homeassistant/components/water_heater/** - homeassistant/components/weather/** diff --git a/.strict-typing b/.strict-typing index c9f3a97804795a..7b1fc47b3a35af 100644 --- a/.strict-typing +++ b/.strict-typing @@ -207,6 +207,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 d013572790f784..24a7793f6a64d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1059,6 +1059,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/demo/__init__.py b/homeassistant/components/demo/__init__.py index abee8310e171ef..2afb58aff702c7 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -40,6 +40,7 @@ "sensor", "siren", "switch", + "update", "vacuum", "water_heater", ] diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py new file mode 100644 index 00000000000000..a48c4a3cab26a6 --- /dev/null +++ b/homeassistant/components/demo/update.py @@ -0,0 +1,153 @@ +"""Demo platform that offers fake update entities.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.update import UpdateDeviceClass, UpdateEntity +from homeassistant.components.update.const import UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + +FAKE_INSTALL_SLEEP_TIME = 0.5 + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up demo update entities.""" + async_add_entities( + [ + DemoUpdate( + unique_id="update_no_install", + name="Demo Update No Install", + title="Awesomesoft Inc.", + current_version="1.0.0", + latest_version="1.0.1", + release_summary="Awesome update, fixing everything!", + release_url="https://www.example.com/release/1.0.1", + support_install=False, + ), + DemoUpdate( + unique_id="update_2_date", + name="Demo No Update", + title="AdGuard Home", + current_version="1.0.0", + latest_version="1.0.0", + ), + DemoUpdate( + unique_id="update_addon", + name="Demo add-on", + title="AdGuard Home", + current_version="1.0.0", + latest_version="1.0.1", + release_summary="Awesome update, fixing everything!", + release_url="https://www.example.com/release/1.0.1", + ), + DemoUpdate( + unique_id="update_light_bulb", + name="Demo Living Room Bulb Update", + title="Philips Lamps Firmware", + current_version="1.93.3", + latest_version="1.94.2", + release_summary="Added support for effects", + release_url="https://www.example.com/release/1.93.3", + device_class=UpdateDeviceClass.FIRMWARE, + ), + DemoUpdate( + unique_id="update_support_progress", + name="Demo Update with Progress", + title="Philips Lamps Firmware", + current_version="1.93.3", + latest_version="1.94.2", + support_progress=True, + release_summary="Added support for effects", + release_url="https://www.example.com/release/1.93.3", + device_class=UpdateDeviceClass.FIRMWARE, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +async def _fake_install() -> None: + """Fake install an update.""" + await asyncio.sleep(FAKE_INSTALL_SLEEP_TIME) + + +class DemoUpdate(UpdateEntity): + """Representation of a demo update entity.""" + + _attr_should_poll = False + + def __init__( + self, + *, + unique_id: str, + name: str, + title: str | None, + current_version: str | None, + latest_version: str | None, + release_summary: str | None = None, + release_url: str | None = None, + support_progress: bool = False, + support_install: bool = True, + device_class: UpdateDeviceClass | None = None, + ) -> None: + """Initialize the Demo select entity.""" + self._attr_current_version = current_version + self._attr_device_class = device_class + self._attr_latest_version = latest_version + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_release_summary = release_summary + self._attr_release_url = release_url + self._attr_title = title + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=name, + ) + if support_install: + self._attr_supported_features |= ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.BACKUP + | UpdateEntityFeature.SPECIFIC_VERSION + ) + if support_progress: + self._attr_supported_features |= UpdateEntityFeature.PROGRESS + + async def async_install( + self, + version: str | None = None, + backup: bool | None = None, + **kwargs: Any, + ) -> None: + """Install an update.""" + if self.supported_features & UpdateEntityFeature.PROGRESS: + for progress in range(0, 100, 10): + self._attr_in_progress = progress + self.async_write_ha_state() + await _fake_install() + + self._attr_in_progress = False + self._attr_current_version = ( + version if version is not None else self.latest_version + ) + self.async_write_ha_state() diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py new file mode 100644 index 00000000000000..a19ecc535a28bf --- /dev/null +++ b/homeassistant/components/update/__init__.py @@ -0,0 +1,353 @@ +"""Component to allow for providing device or service updates.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any, Final, final + +import voluptuous as vol + +from homeassistant.backports.enum import StrEnum +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import ( + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import EntityCategory, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_BACKUP, + ATTR_CURRENT_VERSION, + ATTR_IN_PROGRESS, + ATTR_LATEST_VERSION, + ATTR_RELEASE_SUMMARY, + ATTR_RELEASE_URL, + ATTR_SKIPPED_VERSION, + ATTR_TITLE, + ATTR_VERSION, + DOMAIN, + SERVICE_INSTALL, + SERVICE_SKIP, + UpdateEntityFeature, +) + +SCAN_INTERVAL = timedelta(minutes=15) + +ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + + +class UpdateDeviceClass(StrEnum): + """Device class for update.""" + + FIRMWARE = "firmware" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(UpdateDeviceClass)) + + +__all__ = [ + "ATTR_BACKUP", + "ATTR_VERSION", + "DEVICE_CLASSES_SCHEMA", + "DOMAIN", + "PLATFORM_SCHEMA_BASE", + "PLATFORM_SCHEMA", + "SERVICE_INSTALL", + "SERVICE_SKIP", + "UpdateDeviceClass", + "UpdateEntity", + "UpdateEntityDescription", + "UpdateEntityFeature", +] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Select entities.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_INSTALL, + { + vol.Optional(ATTR_VERSION): cv.string, + vol.Optional(ATTR_BACKUP): cv.boolean, + }, + async_install, + [UpdateEntityFeature.INSTALL], + ) + + component.async_register_entity_service( + SERVICE_SKIP, + {}, + UpdateEntity.async_skip.__name__, + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to validate the call.""" + # If version is not specified, but no update is available. + if (version := service_call.data.get(ATTR_VERSION)) is None and ( + entity.current_version == entity.latest_version or entity.latest_version is None + ): + raise HomeAssistantError(f"No update available for {entity.name}") + + # If version is specified, but not supported by the entity. + if ( + version is not None + and not entity.supported_features & UpdateEntityFeature.SPECIFIC_VERSION + ): + raise HomeAssistantError( + f"Installing a specific version is not supported for {entity.name}" + ) + + # If backup is requested, but not supported by the entity. + if ( + backup := service_call.data.get(ATTR_BACKUP) + ) and not entity.supported_features & UpdateEntityFeature.BACKUP: + raise HomeAssistantError(f"Backup is not supported for {entity.name}") + + # Update is already in progress. + if entity.in_progress is not False: + raise HomeAssistantError( + f"Update installation already in progress for {entity.name}" + ) + + await entity.async_install_with_progress(version, backup) + + +@dataclass +class UpdateEntityDescription(EntityDescription): + """A class that describes update entities.""" + + device_class: UpdateDeviceClass | str | None = None + entity_category: EntityCategory | None = EntityCategory.CONFIG + + +class UpdateEntity(RestoreEntity): + """Representation of an update entity.""" + + entity_description: UpdateEntityDescription + _attr_current_version: str | None = None + _attr_device_class: UpdateDeviceClass | str | None + _attr_in_progress: bool | int = False + _attr_latest_version: str | None = None + _attr_release_summary: str | None = None + _attr_release_url: str | None = None + _attr_state: None = None + _attr_supported_features: int = 0 + _attr_title: str | None = None + __skipped_version: str | None = None + __in_progress: bool = False + + @property + def current_version(self) -> str | None: + """Version currently in use.""" + return self._attr_current_version + + @property + def device_class(self) -> UpdateDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + def entity_category(self) -> EntityCategory | str | None: + """Return the category of the entity, if any.""" + if hasattr(self, "_attr_entity_category"): + return self._attr_entity_category + if hasattr(self, "entity_description"): + return self.entity_description.entity_category + return EntityCategory.CONFIG + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress. + + Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used. + + Can either return a boolean (True if in progress, False if not) + or an integer to indicate the progress in from 0 to 100%. + """ + return self._attr_in_progress + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self._attr_latest_version + + @property + def release_summary(self) -> str | None: + """Summary of the release notes or changelog. + + This is not suitable for long changelogs, but merely suitable + for a short excerpt update description of max 255 characters. + """ + return self._attr_release_summary + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + return self._attr_release_url + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._attr_supported_features + + @property + def title(self) -> str | None: + """Title of the software. + + This helps to differentiate between the device or entity name + versus the title of the software installed. + """ + return self._attr_title + + @final + async def async_skip(self) -> None: + """Skip the current offered version to update.""" + if (latest_version := self.latest_version) is None: + raise HomeAssistantError(f"Cannot skip an unknown version for {self.name}") + if self.current_version == latest_version: + raise HomeAssistantError(f"No update available to skip for {self.name}") + self.__skipped_version = latest_version + self.async_write_ha_state() + + async def async_install( + self, + version: str | None = None, + backup: bool | None = None, + **kwargs: Any, + ) -> None: + """Install an update. + + Version can be specified to install a specific version. When `None`, the + latest version needs to be installed. + + The backup parameter indicates a backup should be taken before + installing the update. + """ + await self.hass.async_add_executor_job(self.install, version, backup) + + def install( + self, + version: str | None = None, + backup: bool | None = None, + **kwargs: Any, + ) -> None: + """Install an update. + + Version can be specified to install a specific version. When `None`, the + latest version needs to be installed. + + The backup parameter indicates a backup should be taken before + installing the update. + """ + raise NotImplementedError() + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if (current_version := self.current_version) is None or ( + latest_version := self.latest_version + ) is None: + return None + + if latest_version not in (current_version, self.__skipped_version): + return STATE_ON + return STATE_OFF + + @final + @property + def state_attributes(self) -> dict[str, Any] | None: + """Return state attributes.""" + if (release_summary := self.release_summary) is not None: + release_summary = release_summary[:255] + + # If entity supports progress, return the in_progress value. + # Otherwise, we use the internal progress value. + if self.supported_features & UpdateEntityFeature.PROGRESS: + in_progress = self.in_progress + else: + in_progress = self.__in_progress + + # Clear skipped version in case it matches the current version or + # the latest version diverged. + if ( + self.__skipped_version == self.current_version + or self.__skipped_version != self.latest_version + ): + self.__skipped_version = None + + return { + ATTR_CURRENT_VERSION: self.current_version, + ATTR_IN_PROGRESS: in_progress, + ATTR_LATEST_VERSION: self.latest_version, + ATTR_RELEASE_SUMMARY: release_summary, + ATTR_RELEASE_URL: self.release_url, + ATTR_SKIPPED_VERSION: self.__skipped_version, + ATTR_TITLE: self.title, + } + + @final + async def async_install_with_progress( + self, + version: str | None = None, + backup: bool | None = None, + ) -> None: + """Install update and handle progress if needed. + + Handles setting the in_progress state in case the entity doesn't + support it natively. + """ + if not self.supported_features & UpdateEntityFeature.PROGRESS: + self.__in_progress = True + self.async_write_ha_state() + + try: + await self.async_install(version, backup) + finally: + # No matter what happens, we always stop progress in the end + self._attr_in_progress = False + self.__in_progress = False + self.async_write_ha_state() + + async def async_internal_added_to_hass(self) -> None: + """Call when the update entity is added to hass. + + It is used to restore the skipped version, if any. + """ + await super().async_internal_added_to_hass() + 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] diff --git a/homeassistant/components/update/const.py b/homeassistant/components/update/const.py new file mode 100644 index 00000000000000..2ddf08a20ff22f --- /dev/null +++ b/homeassistant/components/update/const.py @@ -0,0 +1,30 @@ +"""Constants for the update component.""" +from __future__ import annotations + +from enum import IntEnum +from typing import Final + +DOMAIN: Final = "update" + + +class UpdateEntityFeature(IntEnum): + """Supported features of the update entity.""" + + INSTALL = 1 + SPECIFIC_VERSION = 2 + PROGRESS = 4 + BACKUP = 8 + + +SERVICE_INSTALL: Final = "install" +SERVICE_SKIP: Final = "skip" + +ATTR_BACKUP: Final = "backup" +ATTR_CURRENT_VERSION: Final = "current_version" +ATTR_IN_PROGRESS: Final = "in_progress" +ATTR_LATEST_VERSION: Final = "latest_version" +ATTR_RELEASE_SUMMARY: Final = "release_summary" +ATTR_RELEASE_URL: Final = "release_url" +ATTR_SKIPPED_VERSION: Final = "skipped_version" +ATTR_TITLE: Final = "title" +ATTR_VERSION: Final = "version" diff --git a/homeassistant/components/update/manifest.json b/homeassistant/components/update/manifest.json new file mode 100644 index 00000000000000..f5fe74c9d021e5 --- /dev/null +++ b/homeassistant/components/update/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "update", + "name": "Update", + "documentation": "https://www.home-assistant.io/integrations/update", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/update/services.yaml b/homeassistant/components/update/services.yaml new file mode 100644 index 00000000000000..2a3370493cc0f1 --- /dev/null +++ b/homeassistant/components/update/services.yaml @@ -0,0 +1,27 @@ +install: + name: Install update + description: Install an update for this device or service + target: + entity: + domain: update + fields: + version: + name: Version + description: Version to install, if omitted, the latest version will be installed. + required: false + example: "1.0.0" + selector: + text: + backup: + name: Backup + description: Backup before installing the update, if supported by the integration. + required: false + selector: + boolean: + +skip: + name: Skip update + description: Mark currently available update as skipped. + target: + entity: + domain: update diff --git a/homeassistant/components/update/significant_change.py b/homeassistant/components/update/significant_change.py new file mode 100644 index 00000000000000..400734f2e439ff --- /dev/null +++ b/homeassistant/components/update/significant_change.py @@ -0,0 +1,30 @@ +"""Helper to test significant update state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from .const import ATTR_CURRENT_VERSION, ATTR_LATEST_VERSION + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + if old_attrs.get(ATTR_CURRENT_VERSION) != new_attrs.get(ATTR_CURRENT_VERSION): + return True + + if old_attrs.get(ATTR_LATEST_VERSION) != new_attrs.get(ATTR_LATEST_VERSION): + return True + + return False diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json new file mode 100644 index 00000000000000..b079c9ec8b6cdc --- /dev/null +++ b/homeassistant/components/update/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Update" +} diff --git a/homeassistant/const.py b/homeassistant/const.py index 4f2f5ed747882c..2c2676d8bb4470 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -50,6 +50,7 @@ class Platform(StrEnum): SWITCH = "switch" TTS = "tts" VACUUM = "vacuum" + UPDATE = "update" WATER_HEATER = "water_heater" WEATHER = "weather" diff --git a/mypy.ini b/mypy.ini index 1a87e77298f602..b4d51d7f4e1855 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2079,6 +2079,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/demo/test_update.py b/tests/components/demo/test_update.py new file mode 100644 index 00000000000000..37e0ea903d1dab --- /dev/null +++ b/tests/components/demo/test_update.py @@ -0,0 +1,157 @@ +"""The tests for the demo update platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.update import DOMAIN, SERVICE_INSTALL, UpdateDeviceClass +from homeassistant.components.update.const import ( + ATTR_CURRENT_VERSION, + ATTR_IN_PROGRESS, + ATTR_LATEST_VERSION, + ATTR_RELEASE_SUMMARY, + ATTR_RELEASE_URL, + ATTR_TITLE, +) +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_demo_update(hass: HomeAssistant) -> None: + """Initialize setup demo update entity.""" + assert await async_setup_component(hass, DOMAIN, {"update": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get("update.demo_update_no_install") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_TITLE] == "Awesomesoft Inc." + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert ( + state.attributes[ATTR_RELEASE_SUMMARY] == "Awesome update, fixing everything!" + ) + assert state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.0.1" + + state = hass.states.get("update.demo_no_update") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_TITLE] == "AdGuard Home" + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + assert state.attributes[ATTR_RELEASE_SUMMARY] is None + assert state.attributes[ATTR_RELEASE_URL] is None + + state = hass.states.get("update.demo_add_on") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_TITLE] == "AdGuard Home" + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert ( + state.attributes[ATTR_RELEASE_SUMMARY] == "Awesome update, fixing everything!" + ) + assert state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.0.1" + + state = hass.states.get("update.demo_living_room_bulb_update") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_TITLE] == "Philips Lamps Firmware" + assert state.attributes[ATTR_CURRENT_VERSION] == "1.93.3" + assert state.attributes[ATTR_LATEST_VERSION] == "1.94.2" + assert state.attributes[ATTR_RELEASE_SUMMARY] == "Added support for effects" + assert ( + state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.93.3" + ) + assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE + + state = hass.states.get("update.demo_update_with_progress") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_TITLE] == "Philips Lamps Firmware" + assert state.attributes[ATTR_CURRENT_VERSION] == "1.93.3" + assert state.attributes[ATTR_LATEST_VERSION] == "1.94.2" + assert state.attributes[ATTR_RELEASE_SUMMARY] == "Added support for effects" + assert ( + state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.93.3" + ) + assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE + + +async def test_update_with_progress(hass: HomeAssistant) -> None: + """Test update with progress.""" + state = hass.states.get("update.demo_update_with_progress") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_IN_PROGRESS] is False + + events = [] + async_track_state_change_event( + hass, + "update.demo_update_with_progress", + callback(lambda event: events.append(event)), + ) + + with patch("homeassistant.components.demo.update.FAKE_INSTALL_SLEEP_TIME", new=0): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.demo_update_with_progress"}, + blocking=True, + ) + + assert len(events) == 10 + assert events[0].data["new_state"].state == STATE_ON + assert events[0].data["new_state"].attributes[ATTR_IN_PROGRESS] == 10 + assert events[1].data["new_state"].attributes[ATTR_IN_PROGRESS] == 20 + assert events[2].data["new_state"].attributes[ATTR_IN_PROGRESS] == 30 + assert events[3].data["new_state"].attributes[ATTR_IN_PROGRESS] == 40 + assert events[4].data["new_state"].attributes[ATTR_IN_PROGRESS] == 50 + assert events[5].data["new_state"].attributes[ATTR_IN_PROGRESS] == 60 + assert events[6].data["new_state"].attributes[ATTR_IN_PROGRESS] == 70 + assert events[7].data["new_state"].attributes[ATTR_IN_PROGRESS] == 80 + assert events[8].data["new_state"].attributes[ATTR_IN_PROGRESS] == 90 + assert events[9].data["new_state"].attributes[ATTR_IN_PROGRESS] is False + assert events[9].data["new_state"].state == STATE_OFF + + +async def test_update_with_progress_raising(hass: HomeAssistant) -> None: + """Test update with progress failing to install.""" + state = hass.states.get("update.demo_update_with_progress") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_IN_PROGRESS] is False + + events = [] + async_track_state_change_event( + hass, + "update.demo_update_with_progress", + callback(lambda event: events.append(event)), + ) + + with patch( + "homeassistant.components.demo.update._fake_install", + side_effect=[None, None, None, None, RuntimeError], + ) as fake_sleep, pytest.raises(RuntimeError): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.demo_update_with_progress"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert fake_sleep.call_count == 5 + assert len(events) == 5 + assert events[0].data["new_state"].state == STATE_ON + assert events[0].data["new_state"].attributes[ATTR_IN_PROGRESS] == 10 + assert events[1].data["new_state"].attributes[ATTR_IN_PROGRESS] == 20 + assert events[2].data["new_state"].attributes[ATTR_IN_PROGRESS] == 30 + assert events[3].data["new_state"].attributes[ATTR_IN_PROGRESS] == 40 + assert events[4].data["new_state"].attributes[ATTR_IN_PROGRESS] is False + assert events[4].data["new_state"].state == STATE_ON diff --git a/tests/components/update/__init__.py b/tests/components/update/__init__.py new file mode 100644 index 00000000000000..c711b2779b9ea6 --- /dev/null +++ b/tests/components/update/__init__.py @@ -0,0 +1 @@ +"""The 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 00000000000000..cc8315c5aec6c9 --- /dev/null +++ b/tests/components/update/test_init.py @@ -0,0 +1,583 @@ +"""The tests for the Update component.""" +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.update import ( + ATTR_BACKUP, + ATTR_VERSION, + DOMAIN, + SERVICE_INSTALL, + SERVICE_SKIP, + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, +) +from homeassistant.components.update.const import ( + ATTR_CURRENT_VERSION, + ATTR_IN_PROGRESS, + ATTR_LATEST_VERSION, + ATTR_RELEASE_SUMMARY, + ATTR_RELEASE_URL, + ATTR_SKIPPED_VERSION, + ATTR_TITLE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_PLATFORM, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.setup import async_setup_component + +from tests.common import mock_restore_cache + + +class MockUpdateEntity(UpdateEntity): + """Mock UpdateEntity to use in tests.""" + + +async def test_update(hass: HomeAssistant) -> None: + """Test getting data from the mocked update entity.""" + update = MockUpdateEntity() + update.hass = hass + + update._attr_current_version = "1.0.0" + update._attr_latest_version = "1.0.1" + update._attr_release_summary = "Summary" + update._attr_release_url = "https://example.com" + update._attr_title = "Title" + + assert update.entity_category is EntityCategory.CONFIG + assert update.current_version == "1.0.0" + assert update.latest_version == "1.0.1" + assert update.release_summary == "Summary" + assert update.release_url == "https://example.com" + assert update.title == "Title" + assert update.in_progress is False + assert update.state == STATE_ON + assert update.state_attributes == { + ATTR_CURRENT_VERSION: "1.0.0", + ATTR_IN_PROGRESS: False, + ATTR_LATEST_VERSION: "1.0.1", + ATTR_RELEASE_SUMMARY: "Summary", + ATTR_RELEASE_URL: "https://example.com", + ATTR_SKIPPED_VERSION: None, + ATTR_TITLE: "Title", + } + + # Test no update available + update._attr_current_version = "1.0.0" + update._attr_latest_version = "1.0.0" + assert update.state is STATE_OFF + + # Test state becomes unknown if current version is unknown + update._attr_current_version = None + update._attr_latest_version = "1.0.0" + assert update.state is None + + # Test state becomes unknown if latest version is unknown + update._attr_current_version = "1.0.0" + update._attr_latest_version = None + assert update.state is None + + # UpdateEntityDescription was set + update.entity_description = UpdateEntityDescription(key="F5 - Its very refreshing") + assert update.device_class is None + assert update.entity_category is EntityCategory.CONFIG + update.entity_description = UpdateEntityDescription( + key="F5 - Its very refreshing", + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=None, + ) + assert update.device_class is UpdateDeviceClass.FIRMWARE + assert update.entity_category is None + + # Device class via attribute (override entity description) + update._attr_device_class = None + assert update.device_class is None + update._attr_device_class = UpdateDeviceClass.FIRMWARE + assert update.device_class is UpdateDeviceClass.FIRMWARE + + # Entity Attribute via attribute (override entity description) + update._attr_entity_category = None + assert update.entity_category is None + update._attr_entity_category = EntityCategory.DIAGNOSTIC + assert update.entity_category is EntityCategory.DIAGNOSTIC + + with pytest.raises(NotImplementedError): + await update.async_install() + + with pytest.raises(NotImplementedError): + update.install() + + update.install = MagicMock() + await update.async_install(version="1.0.1", backup=True) + + assert update.install.called + assert update.install.call_args[0][0] == "1.0.1" + assert update.install.call_args[0][1] is True + + +async def test_entity_with_no_install( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test entity with no updates.""" + 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() + + # Update is available + state = hass.states.get("update.update_no_install") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + + # Should not be able to install as the entity doesn't support that + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_no_install"}, + blocking=True, + ) + + # Nothing changed + state = hass.states.get("update.update_no_install") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_SKIPPED_VERSION] is None + + # We can mark the update as skipped + await hass.services.async_call( + DOMAIN, + SERVICE_SKIP, + {ATTR_ENTITY_ID: "update.update_no_install"}, + blocking=True, + ) + + state = hass.states.get("update.update_no_install") + assert state + assert state.state == STATE_OFF + 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_entity_with_no_updates( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test entity with no updates.""" + 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() + + # No update available + state = hass.states.get("update.no_update") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + + # Should not be able to skip when there is no update available + with pytest.raises(HomeAssistantError, match="No update available to skip for"): + await hass.services.async_call( + DOMAIN, + SERVICE_SKIP, + {ATTR_ENTITY_ID: "update.no_update"}, + blocking=True, + ) + + # Should not be able to install an update when there is no update available + with pytest.raises(HomeAssistantError, match="No update available for"): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.no_update"}, + blocking=True, + ) + + # Updating to a specific version is not supported by this entity + with pytest.raises( + HomeAssistantError, + match="Installing a specific version is not supported for", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_VERSION: "0.9.0", ATTR_ENTITY_ID: "update.no_update"}, + blocking=True, + ) + + +async def test_entity_with_updates_available( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test basic update entity with updates available.""" + 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() + + # Entity has an update available + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_SKIPPED_VERSION] is None + + # Skip skip the update + await hass.services.async_call( + DOMAIN, + SERVICE_SKIP, + {ATTR_ENTITY_ID: "update.update_available"}, + blocking=True, + ) + + # The state should have changed to off, skipped version should be set + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_OFF + 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" + + # Even though skipped, we can still update if we want to + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_available"}, + blocking=True, + ) + + # The state should have changed to off, skipped version should be set + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.1" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert "Installed latest update" in caplog.text + + +async def test_entity_with_unknown_version( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity that has an unknown version.""" + 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() + + state = hass.states.get("update.update_unknown") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] is None + assert state.attributes[ATTR_SKIPPED_VERSION] is None + + # Should not be able to install an update when there is no update available + with pytest.raises(HomeAssistantError, match="No update available for"): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_unknown"}, + blocking=True, + ) + + # Should not be to skip the update + with pytest.raises(HomeAssistantError, match="Cannot skip an unknown version for"): + await hass.services.async_call( + DOMAIN, + SERVICE_SKIP, + {ATTR_ENTITY_ID: "update.update_unknown"}, + blocking=True, + ) + + +async def test_entity_with_specific_version( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity that support specific version.""" + 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() + + state = hass.states.get("update.update_specific_version") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + + # Update to a specific version + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_VERSION: "0.9.9", ATTR_ENTITY_ID: "update.update_specific_version"}, + blocking=True, + ) + + # Version has changed, state should be on as there is an update available + state = hass.states.get("update.update_specific_version") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "0.9.9" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + assert "Installed update with version: 0.9.9" in caplog.text + + # Update back to the latest version + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_specific_version"}, + blocking=True, + ) + + state = hass.states.get("update.update_specific_version") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + assert "Installed latest update" in caplog.text + + # This entity does not support doing a backup before upgrade + with pytest.raises(HomeAssistantError, match="Backup is not supported for"): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + { + ATTR_VERSION: "0.9.9", + ATTR_BACKUP: True, + ATTR_ENTITY_ID: "update.update_specific_version", + }, + blocking=True, + ) + + +async def test_entity_with_backup_support( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity with backup support.""" + 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() + + # This entity support backing up before install the update + state = hass.states.get("update.update_backup") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + + # Without a backup + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + { + ATTR_BACKUP: False, + ATTR_ENTITY_ID: "update.update_backup", + }, + blocking=True, + ) + + state = hass.states.get("update.update_backup") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.1" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert "Creating backup before installing update" not in caplog.text + assert "Installed latest update" in caplog.text + + # Specific version, do create a backup this time + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + { + ATTR_BACKUP: True, + ATTR_VERSION: "0.9.8", + ATTR_ENTITY_ID: "update.update_backup", + }, + blocking=True, + ) + + # This entity support backing up before install the update + state = hass.states.get("update.update_backup") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "0.9.8" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert "Creating backup before installing update" in caplog.text + assert "Installed update with version: 0.9.8" in caplog.text + + +async def test_entity_already_in_progress( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update install already in progress.""" + 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() + + state = hass.states.get("update.update_already_in_progress") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_IN_PROGRESS] == 50 + + with pytest.raises( + HomeAssistantError, + match="Update installation already in progress for", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_already_in_progress"}, + blocking=True, + ) + + +async def test_entity_without_progress_support( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity without progress support. + + In that case, progress is still handled by Home Assistant. + """ + 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() + + events = [] + async_track_state_change_event( + hass, "update.update_available", callback(lambda event: events.append(event)) + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_available"}, + blocking=True, + ) + + assert len(events) == 2 + assert events[0].data.get("old_state").attributes[ATTR_IN_PROGRESS] is False + assert events[0].data.get("old_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert events[0].data.get("new_state").attributes[ATTR_IN_PROGRESS] is True + assert events[0].data.get("new_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + + assert events[1].data.get("old_state").attributes[ATTR_IN_PROGRESS] is True + assert events[1].data.get("old_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert events[1].data.get("new_state").attributes[ATTR_IN_PROGRESS] is False + assert events[1].data.get("new_state").attributes[ATTR_CURRENT_VERSION] == "1.0.1" + + +async def test_entity_without_progress_support_raising( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity without progress support that raises during install. + + In that case, progress is still handled by Home Assistant. + """ + 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() + + events = [] + async_track_state_change_event( + hass, "update.update_available", callback(lambda event: events.append(event)) + ) + + with patch( + "homeassistant.components.update.UpdateEntity.async_install", + side_effect=RuntimeError, + ), pytest.raises(RuntimeError): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_available"}, + blocking=True, + ) + + assert len(events) == 2 + assert events[0].data.get("old_state").attributes[ATTR_IN_PROGRESS] is False + assert events[0].data.get("old_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert events[0].data.get("new_state").attributes[ATTR_IN_PROGRESS] is True + assert events[0].data.get("new_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + + assert events[1].data.get("old_state").attributes[ATTR_IN_PROGRESS] is True + assert events[1].data.get("old_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert events[1].data.get("new_state").attributes[ATTR_IN_PROGRESS] is False + assert events[1].data.get("new_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + + +async def test_restore_state( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test we restore skipped version state.""" + mock_restore_cache( + hass, + ( + State( + "update.update_available", + STATE_ON, # Incorrect, but helps checking if it is ignored + { + ATTR_SKIPPED_VERSION: "1.0.1", + }, + ), + ), + ) + + 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() + + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_OFF + 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" diff --git a/tests/components/update/test_significant_change.py b/tests/components/update/test_significant_change.py new file mode 100644 index 00000000000000..699d3e60f571ee --- /dev/null +++ b/tests/components/update/test_significant_change.py @@ -0,0 +1,90 @@ +"""Test the update significant change platform.""" +from homeassistant.components.update.const import ( + ATTR_CURRENT_VERSION, + ATTR_IN_PROGRESS, + ATTR_LATEST_VERSION, + ATTR_RELEASE_SUMMARY, + ATTR_RELEASE_URL, + ATTR_SKIPPED_VERSION, + ATTR_TITLE, +) +from homeassistant.components.update.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_significant_change(hass: HomeAssistant) -> None: + """Detect update significant changes.""" + assert async_check_significant_change(hass, STATE_ON, {}, STATE_OFF, {}) + assert async_check_significant_change(hass, STATE_OFF, {}, STATE_ON, {}) + assert not async_check_significant_change(hass, STATE_OFF, {}, STATE_OFF, {}) + assert not async_check_significant_change(hass, STATE_ON, {}, STATE_ON, {}) + + attrs = { + ATTR_CURRENT_VERSION: "1.0.0", + ATTR_IN_PROGRESS: False, + ATTR_LATEST_VERSION: "1.0.1", + ATTR_RELEASE_SUMMARY: "Fixes!", + ATTR_RELEASE_URL: "https://www.example.com", + ATTR_SKIPPED_VERSION: None, + ATTR_TITLE: "Piece of Software", + } + assert not async_check_significant_change(hass, STATE_ON, attrs, STATE_ON, attrs) + + assert async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_CURRENT_VERSION: "1.0.1"}, + ) + + assert async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_LATEST_VERSION: "1.0.2"}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_IN_PROGRESS: True}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_RELEASE_SUMMARY: "More fixes!"}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_RELEASE_URL: "https://www.example.com/changed_url"}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_SKIPPED_VERSION: "1.0.0"}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_TITLE: "Renamed the software..."}, + ) diff --git a/tests/testing_config/custom_components/test/update.py b/tests/testing_config/custom_components/test/update.py new file mode 100644 index 00000000000000..aeac37d198e888 --- /dev/null +++ b/tests/testing_config/custom_components/test/update.py @@ -0,0 +1,138 @@ +""" +Provide a mock update platform. + +Call init before using it in your tests to ensure clean test data. +""" +from __future__ import annotations + +import logging + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature + +from tests.common import MockEntity + +ENTITIES = [] + +_LOGGER = logging.getLogger(__name__) + + +class MockUpdateEntity(MockEntity, UpdateEntity): + """Mock UpdateEntity class.""" + + @property + def current_version(self) -> str | None: + """Version currently in use.""" + return self._handle("current_version") + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress.""" + return self._handle("in_progress") + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self._handle("latest_version") + + @property + def release_summary(self) -> str | None: + """Summary of the release notes or changelog.""" + return self._handle("release_summary") + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + return self._handle("release_url") + + @property + def title(self) -> str | None: + """Title of the software.""" + return self._handle("title") + + def install( + self, + version: str | None = None, + backup: bool | None = None, + ) -> None: + """Install an update.""" + if backup: + _LOGGER.info("Creating backup before installing update") + + if version is not None: + self._values["current_version"] = version + _LOGGER.info(f"Installed update with version: {version}") + else: + self._values["current_version"] = self.latest_version + _LOGGER.info("Installed latest update") + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockUpdateEntity( + name="No Update", + unique_id="no_update", + current_version="1.0.0", + latest_version="1.0.0", + supported_features=UpdateEntityFeature.INSTALL, + ), + MockUpdateEntity( + name="Update Available", + unique_id="update_available", + current_version="1.0.0", + latest_version="1.0.1", + supported_features=UpdateEntityFeature.INSTALL, + ), + MockUpdateEntity( + name="Update Unknown", + unique_id="update_unknown", + current_version="1.0.0", + latest_version=None, + supported_features=UpdateEntityFeature.INSTALL, + ), + MockUpdateEntity( + name="Update Specific Version", + unique_id="update_specific_version", + current_version="1.0.0", + latest_version="1.0.0", + supported_features=UpdateEntityFeature.INSTALL + | UpdateEntityFeature.SPECIFIC_VERSION, + ), + MockUpdateEntity( + name="Update Backup", + unique_id="update_backup", + current_version="1.0.0", + latest_version="1.0.1", + supported_features=UpdateEntityFeature.INSTALL + | UpdateEntityFeature.SPECIFIC_VERSION + | UpdateEntityFeature.BACKUP, + ), + MockUpdateEntity( + name="Update Already in Progress", + unique_id="update_already_in_progres", + current_version="1.0.0", + latest_version="1.0.1", + in_progress=50, + supported_features=UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS, + ), + MockUpdateEntity( + name="Update No Install", + unique_id="no_install", + current_version="1.0.0", + latest_version="1.0.1", + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES)