Allow update entities to report progress as a float ()

* Allow update entities to report progress as a float

* Add test

* Update snapshots

* Update recorder test

* Use _attr_* in MockUpdateEntity
pull/129110/head
Erik Montnemery 2024-10-24 21:20:18 +02:00 committed by GitHub
parent 87a2465a25
commit bd55fe868d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 76 additions and 51 deletions
homeassistant/components/update
tests/components
airgradient/snapshots
devolo_home_network/snapshots
fritz/snapshots
iron_os/snapshots
lamarzocco/snapshots
nextcloud/snapshots
smlight/snapshots
teslemetry/snapshots
tessie/snapshots
unifi/snapshots

View File

@ -27,6 +27,7 @@ from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_AUTO_UPDATE,
ATTR_BACKUP,
ATTR_DISPLAY_PRECISION,
ATTR_IN_PROGRESS,
ATTR_INSTALLED_VERSION,
ATTR_LATEST_VERSION,
@ -178,6 +179,7 @@ class UpdateEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes update entities."""
device_class: UpdateDeviceClass | None = None
display_precision: int = 0
entity_category: EntityCategory | None = EntityCategory.CONFIG
@ -191,6 +193,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"auto_update",
"installed_version",
"device_class",
"display_precision",
"in_progress",
"latest_version",
"release_summary",
@ -210,6 +213,7 @@ class UpdateEntity(
_entity_component_unrecorded_attributes = frozenset(
{
ATTR_DISPLAY_PRECISION,
ATTR_ENTITY_PICTURE,
ATTR_IN_PROGRESS,
ATTR_RELEASE_SUMMARY,
@ -221,6 +225,7 @@ class UpdateEntity(
_attr_auto_update: bool = False
_attr_installed_version: str | None = None
_attr_device_class: UpdateDeviceClass | None
_attr_display_precision: int
_attr_in_progress: bool | int = False
_attr_latest_version: str | None = None
_attr_release_summary: str | None = None
@ -228,7 +233,7 @@ class UpdateEntity(
_attr_state: None = None
_attr_supported_features: UpdateEntityFeature = UpdateEntityFeature(0)
_attr_title: str | None = None
_attr_update_percentage: int | None = None
_attr_update_percentage: int | float | None = None
__skipped_version: str | None = None
__in_progress: bool = False
@ -258,6 +263,15 @@ class UpdateEntity(
return self.entity_description.device_class
return None
@cached_property
def display_precision(self) -> int:
"""Return number of decimal digits for display of update progress."""
if hasattr(self, "_attr_display_precision"):
return self._attr_display_precision
if hasattr(self, "entity_description"):
return self.entity_description.display_precision
return 0
@property
def entity_category(self) -> EntityCategory | None:
"""Return the category of the entity, if any."""
@ -337,12 +351,12 @@ class UpdateEntity(
return features
@cached_property
def update_percentage(self) -> int | None:
def update_percentage(self) -> int | float | None:
"""Update installation progress.
Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used.
Can either return an integer to indicate the progress from 0 to 100% or None.
Can either return a number to indicate the progress from 0 to 100% or None.
"""
return self._attr_update_percentage
@ -460,6 +474,7 @@ class UpdateEntity(
return {
ATTR_AUTO_UPDATE: self.auto_update,
ATTR_DISPLAY_PRECISION: self.display_precision,
ATTR_INSTALLED_VERSION: installed_version,
ATTR_IN_PROGRESS: in_progress,
ATTR_LATEST_VERSION: latest_version,

View File

@ -23,6 +23,7 @@ SERVICE_SKIP: Final = "skip"
ATTR_AUTO_UPDATE: Final = "auto_update"
ATTR_BACKUP: Final = "backup"
ATTR_DISPLAY_PRECISION: Final = "display_precision"
ATTR_INSTALLED_VERSION: Final = "installed_version"
ATTR_IN_PROGRESS: Final = "in_progress"
ATTR_LATEST_VERSION: Final = "latest_version"

View File

@ -37,6 +37,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/airgradient/icon.png',
'friendly_name': 'Airgradient Firmware',
'in_progress': False,

View File

@ -4,6 +4,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/devolo_home_network/icon.png',
'friendly_name': 'Mock Title Firmware',
'in_progress': False,

View File

@ -36,6 +36,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/fritz/icon.png',
'friendly_name': 'Mock Title FRITZ!OS',
'in_progress': False,
@ -93,6 +94,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/fritz/icon.png',
'friendly_name': 'Mock Title FRITZ!OS',
'in_progress': False,
@ -150,6 +152,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/fritz/icon.png',
'friendly_name': 'Mock Title FRITZ!OS',
'in_progress': False,

View File

@ -40,6 +40,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/iron_os/icon.png',
'friendly_name': 'Pinecil Firmware',
'in_progress': False,

View File

@ -4,6 +4,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png',
'friendly_name': 'GS01234 Gateway firmware',
'in_progress': False,
@ -62,6 +63,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png',
'friendly_name': 'GS01234 Machine firmware',
'in_progress': False,

View File

@ -36,6 +36,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/nextcloud/icon.png',
'friendly_name': 'my.nc_url.local None',
'in_progress': False,

View File

@ -37,6 +37,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smlight/icon.png',
'friendly_name': 'Mock Title Core firmware',
'in_progress': False,
@ -95,6 +96,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/smlight/icon.png',
'friendly_name': 'Mock Title Zigbee firmware',
'in_progress': False,

View File

@ -36,6 +36,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,
@ -93,6 +94,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,

View File

@ -36,6 +36,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/tessie/icon.png',
'friendly_name': 'Test Update',
'in_progress': False,

View File

@ -37,6 +37,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/unifi/icon.png',
'friendly_name': 'Device 1',
'in_progress': False,
@ -95,6 +96,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/unifi/icon.png',
'friendly_name': 'Device 2',
'in_progress': False,
@ -153,6 +155,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/unifi/icon.png',
'friendly_name': 'Device 1',
'in_progress': False,
@ -211,6 +214,7 @@
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/unifi/icon.png',
'friendly_name': 'Device 2',
'in_progress': False,

View File

@ -5,53 +5,16 @@ from typing import Any
from homeassistant.components.update import UpdateEntity
from tests.common import MockEntity
_LOGGER = logging.getLogger(__name__)
class MockUpdateEntity(MockEntity, UpdateEntity):
class MockUpdateEntity(UpdateEntity):
"""Mock UpdateEntity class."""
@property
def auto_update(self) -> bool:
"""Indicate if the device or service has auto update enabled."""
return self._handle("auto_update")
@property
def installed_version(self) -> str | None:
"""Version currently installed and in use."""
return self._handle("installed_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")
@property
def update_percentage(self) -> int | None:
"""Update installation progress."""
return self._handle("update_percentage")
def __init__(self, **values: Any) -> None:
"""Initialize an entity."""
for key, val in values.items():
setattr(self, f"_attr_{key}", val)
def install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
"""Install an update."""
@ -59,10 +22,10 @@ class MockUpdateEntity(MockEntity, UpdateEntity):
_LOGGER.info("Creating backup before installing update")
if version is not None:
self._values["installed_version"] = version
self._attr_installed_version = version
_LOGGER.info("Installed update with version: %s", version)
else:
self._values["installed_version"] = self.latest_version
self._attr_installed_version = self.latest_version
_LOGGER.info("Installed latest update")
def release_notes(self) -> str | None:

View File

@ -51,7 +51,7 @@ def mock_update_entities() -> list[MockUpdateEntity]:
),
MockUpdateEntity(
name="Update Already in Progress",
unique_id="update_already_in_progres",
unique_id="update_already_in_progress",
installed_version="1.0.0",
latest_version="1.0.1",
in_progress=True,
@ -59,6 +59,17 @@ def mock_update_entities() -> list[MockUpdateEntity]:
| UpdateEntityFeature.PROGRESS,
update_percentage=50,
),
MockUpdateEntity(
name="Update Already in Progress Float",
unique_id="update_already_in_progress_float",
installed_version="1.0.0",
latest_version="1.0.1",
in_progress=True,
supported_features=UpdateEntityFeature.INSTALL
| UpdateEntityFeature.PROGRESS,
update_percentage=0.25,
display_precision=2,
),
MockUpdateEntity(
name="Update No Install",
unique_id="no_install",

View File

@ -18,6 +18,7 @@ from homeassistant.components.update import (
)
from homeassistant.components.update.const import (
ATTR_AUTO_UPDATE,
ATTR_DISPLAY_PRECISION,
ATTR_IN_PROGRESS,
ATTR_INSTALLED_VERSION,
ATTR_LATEST_VERSION,
@ -92,6 +93,7 @@ async def test_update(hass: HomeAssistant) -> None:
assert update.state == STATE_ON
assert update.state_attributes == {
ATTR_AUTO_UPDATE: False,
ATTR_DISPLAY_PRECISION: 0,
ATTR_INSTALLED_VERSION: "1.0.0",
ATTR_IN_PROGRESS: False,
ATTR_LATEST_VERSION: "1.0.1",
@ -546,10 +548,20 @@ async def test_entity_with_backup_support(
assert "Installed update with version: 0.9.8" in caplog.text
@pytest.mark.parametrize(
("entity_id", "expected_display_precision", "expected_update_percentage"),
[
("update.update_already_in_progress", 0, 50),
("update.update_already_in_progress_float", 2, 0.25),
],
)
async def test_entity_already_in_progress(
hass: HomeAssistant,
mock_update_entities: list[MockUpdateEntity],
caplog: pytest.LogCaptureFixture,
entity_id: str,
expected_display_precision: int,
expected_update_percentage: float,
) -> None:
"""Test update install already in progress."""
setup_test_component_platform(hass, DOMAIN, mock_update_entities)
@ -557,13 +569,14 @@ async def test_entity_already_in_progress(
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")
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
assert state.attributes[ATTR_DISPLAY_PRECISION] == expected_display_precision
assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0"
assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1"
assert state.attributes[ATTR_IN_PROGRESS] is True
assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 50
assert state.attributes[ATTR_UPDATE_PERCENTAGE] == expected_update_percentage
with pytest.raises(
HomeAssistantError,
@ -572,7 +585,7 @@ async def test_entity_already_in_progress(
await hass.services.async_call(
DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: "update.update_already_in_progress"},
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
@ -1056,6 +1069,7 @@ async def test_update_percentage_backwards_compatibility(
expected_attributes = {
ATTR_AUTO_UPDATE: False,
ATTR_DISPLAY_PRECISION: 0,
ATTR_ENTITY_PICTURE: "https://brands.home-assistant.io/_/test/icon.png",
ATTR_FRIENDLY_NAME: "legacy",
ATTR_INSTALLED_VERSION: "1.0.0",

View File

@ -7,6 +7,7 @@ from datetime import timedelta
from homeassistant.components.recorder import Recorder
from homeassistant.components.recorder.history import get_significant_states
from homeassistant.components.update.const import (
ATTR_DISPLAY_PRECISION,
ATTR_IN_PROGRESS,
ATTR_INSTALLED_VERSION,
ATTR_RELEASE_SUMMARY,
@ -35,6 +36,7 @@ async def test_exclude_attributes(
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.attributes[ATTR_DISPLAY_PRECISION] == 0
assert state.attributes[ATTR_IN_PROGRESS] is True
assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 50
assert (
@ -54,6 +56,7 @@ async def test_exclude_attributes(
assert len(states) >= 1
for entity_states in states.values():
for state in entity_states:
assert ATTR_DISPLAY_PRECISION not in state.attributes
assert ATTR_ENTITY_PICTURE not in state.attributes
assert ATTR_IN_PROGRESS not in state.attributes
assert ATTR_RELEASE_SUMMARY not in state.attributes