diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index e308365c1c6..75535849cc1 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -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, diff --git a/homeassistant/components/update/const.py b/homeassistant/components/update/const.py index 00b8cfa76b2..83a74ef6789 100644 --- a/homeassistant/components/update/const.py +++ b/homeassistant/components/update/const.py @@ -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" diff --git a/tests/components/airgradient/snapshots/test_update.ambr b/tests/components/airgradient/snapshots/test_update.ambr index f76a8fc1196..1f944bb528b 100644 --- a/tests/components/airgradient/snapshots/test_update.ambr +++ b/tests/components/airgradient/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/devolo_home_network/snapshots/test_update.ambr b/tests/components/devolo_home_network/snapshots/test_update.ambr index de6a67d5e3d..8a1065f9a60 100644 --- a/tests/components/devolo_home_network/snapshots/test_update.ambr +++ b/tests/components/devolo_home_network/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/fritz/snapshots/test_update.ambr b/tests/components/fritz/snapshots/test_update.ambr index 4914ba85269..3c7880d01e7 100644 --- a/tests/components/fritz/snapshots/test_update.ambr +++ b/tests/components/fritz/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/iron_os/snapshots/test_update.ambr b/tests/components/iron_os/snapshots/test_update.ambr index fbfc490e121..e0872d032ec 100644 --- a/tests/components/iron_os/snapshots/test_update.ambr +++ b/tests/components/iron_os/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index c40677a80ca..6e6b7285797 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/nextcloud/snapshots/test_update.ambr b/tests/components/nextcloud/snapshots/test_update.ambr index be94339b41a..484106580b1 100644 --- a/tests/components/nextcloud/snapshots/test_update.ambr +++ b/tests/components/nextcloud/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/smlight/snapshots/test_update.ambr b/tests/components/smlight/snapshots/test_update.ambr index e5f7c34ccf5..ed0085dcdc8 100644 --- a/tests/components/smlight/snapshots/test_update.ambr +++ b/tests/components/smlight/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index ef66720a0ed..a1213f3d94b 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/tessie/snapshots/test_update.ambr b/tests/components/tessie/snapshots/test_update.ambr index 5f795007901..1728c13b0ad 100644 --- a/tests/components/tessie/snapshots/test_update.ambr +++ b/tests/components/tessie/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/unifi/snapshots/test_update.ambr b/tests/components/unifi/snapshots/test_update.ambr index 77fd2c7d8bc..405cb9d52a6 100644 --- a/tests/components/unifi/snapshots/test_update.ambr +++ b/tests/components/unifi/snapshots/test_update.ambr @@ -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, diff --git a/tests/components/update/common.py b/tests/components/update/common.py index edbade8f077..465812e6a3a 100644 --- a/tests/components/update/common.py +++ b/tests/components/update/common.py @@ -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: diff --git a/tests/components/update/conftest.py b/tests/components/update/conftest.py index 4fc2a68221e..eae5cc318da 100644 --- a/tests/components/update/conftest.py +++ b/tests/components/update/conftest.py @@ -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", diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index f19b009456a..a354db44bd3 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -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", diff --git a/tests/components/update/test_recorder.py b/tests/components/update/test_recorder.py index 847a08cfd9c..68e5f93a757 100644 --- a/tests/components/update/test_recorder.py +++ b/tests/components/update/test_recorder.py @@ -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