mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Add update entity platform (#68248)
Co-authored-by: Glenn Waters <glenn@watrs.ca>
This commit is contained in:
parent
830cc278d3
commit
073fb40b79
@ -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/**
|
||||
|
@ -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.*
|
||||
|
@ -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
|
||||
|
@ -40,6 +40,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
|
||||
"sensor",
|
||||
"siren",
|
||||
"switch",
|
||||
"update",
|
||||
"vacuum",
|
||||
"water_heater",
|
||||
]
|
||||
|
153
homeassistant/components/demo/update.py
Normal file
153
homeassistant/components/demo/update.py
Normal file
@ -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()
|
353
homeassistant/components/update/__init__.py
Normal file
353
homeassistant/components/update/__init__.py
Normal file
@ -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]
|
30
homeassistant/components/update/const.py
Normal file
30
homeassistant/components/update/const.py
Normal file
@ -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"
|
7
homeassistant/components/update/manifest.json
Normal file
7
homeassistant/components/update/manifest.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"domain": "update",
|
||||
"name": "Update",
|
||||
"documentation": "https://www.home-assistant.io/integrations/update",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"quality_scale": "internal"
|
||||
}
|
27
homeassistant/components/update/services.yaml
Normal file
27
homeassistant/components/update/services.yaml
Normal file
@ -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
|
30
homeassistant/components/update/significant_change.py
Normal file
30
homeassistant/components/update/significant_change.py
Normal file
@ -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
|
3
homeassistant/components/update/strings.json
Normal file
3
homeassistant/components/update/strings.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "Update"
|
||||
}
|
@ -50,6 +50,7 @@ class Platform(StrEnum):
|
||||
SWITCH = "switch"
|
||||
TTS = "tts"
|
||||
VACUUM = "vacuum"
|
||||
UPDATE = "update"
|
||||
WATER_HEATER = "water_heater"
|
||||
WEATHER = "weather"
|
||||
|
||||
|
11
mypy.ini
11
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
|
||||
|
157
tests/components/demo/test_update.py
Normal file
157
tests/components/demo/test_update.py
Normal file
@ -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
|
1
tests/components/update/__init__.py
Normal file
1
tests/components/update/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""The tests for the Update integration."""
|
583
tests/components/update/test_init.py
Normal file
583
tests/components/update/test_init.py
Normal file
@ -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"
|
90
tests/components/update/test_significant_change.py
Normal file
90
tests/components/update/test_significant_change.py
Normal file
@ -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..."},
|
||||
)
|
138
tests/testing_config/custom_components/test/update.py
Normal file
138
tests/testing_config/custom_components/test/update.py
Normal file
@ -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)
|
Loading…
x
Reference in New Issue
Block a user