diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 912bf7bd7c2..33c8bb47e6a 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -22,6 +22,7 @@ from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS +from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, @@ -32,9 +33,10 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, Platform.NUMBER: NUMBER_SCHEMAS, + Platform.SELECT: SELECT_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, - Platform.SELECT: SELECT_SCHEMAS, + Platform.UPDATE: UPDATE_SCHEMAS, } SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 1dac5ef0cb2..5488df01e4e 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==6.2.2"], + "requirements": ["python-matter-server==6.3.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py new file mode 100644 index 00000000000..4e6733db045 --- /dev/null +++ b/homeassistant/components/matter/update.py @@ -0,0 +1,232 @@ +"""Matter update.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any + +from chip.clusters import Objects as clusters +from matter_server.common.errors import UpdateCheckError, UpdateError +from matter_server.common.models import MatterSoftwareVersion + +from homeassistant.components.update import ( + ATTR_LATEST_VERSION, + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.restore_state import ExtraStoredData + +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +SCAN_INTERVAL = timedelta(hours=12) +POLL_AFTER_INSTALL = 10 + +ATTR_SOFTWARE_UPDATE = "software_update" + + +@dataclass +class MatterUpdateExtraStoredData(ExtraStoredData): + """Extra stored data for Matter node firmware update entity.""" + + software_update: MatterSoftwareVersion | None = None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + ATTR_SOFTWARE_UPDATE: self.software_update.as_dict() + if self.software_update is not None + else None, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MatterUpdateExtraStoredData: + """Initialize the extra data from a dict.""" + if data[ATTR_SOFTWARE_UPDATE] is None: + return cls() + return cls(MatterSoftwareVersion.from_dict(data[ATTR_SOFTWARE_UPDATE])) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter lock from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.UPDATE, async_add_entities) + + +class MatterUpdate(MatterEntity, UpdateEntity): + """Representation of a Matter node capable of updating.""" + + # Matter attribute changes are generally not polled, but the update check + # itself is. The update check is not done by the device itself, but by the + # Matter server. + _attr_should_poll = True + _software_update: MatterSoftwareVersion | None = None + _cancel_update: CALLBACK_TYPE | None = None + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + + self._attr_installed_version = self.get_matter_attribute_value( + clusters.BasicInformation.Attributes.SoftwareVersionString + ) + + if self.get_matter_attribute_value( + clusters.OtaSoftwareUpdateRequestor.Attributes.UpdatePossible + ): + self._attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.SPECIFIC_VERSION + ) + + update_state: clusters.OtaSoftwareUpdateRequestor.Enums.UpdateStateEnum = ( + self.get_matter_attribute_value( + clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateState + ) + ) + if ( + update_state + == clusters.OtaSoftwareUpdateRequestor.Enums.UpdateStateEnum.kIdle + ): + self._attr_in_progress = False + return + + update_progress: int = self.get_matter_attribute_value( + clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress + ) + + if ( + update_state + == clusters.OtaSoftwareUpdateRequestor.Enums.UpdateStateEnum.kDownloading + and update_progress is not None + and update_progress > 0 + ): + self._attr_in_progress = update_progress + else: + self._attr_in_progress = True + + async def async_update(self) -> None: + """Call when the entity needs to be updated.""" + try: + update_information = await self.matter_client.check_node_update( + node_id=self._endpoint.node.node_id + ) + if not update_information: + self._attr_latest_version = self._attr_installed_version + return + + self._software_update = update_information + self._attr_latest_version = update_information.software_version_string + self._attr_release_url = update_information.release_notes_url + except UpdateCheckError as err: + raise HomeAssistantError(f"Error finding applicable update: {err}") from err + + async def async_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_added_to_hass() + + if state := await self.async_get_last_state(): + self._attr_latest_version = state.attributes.get(ATTR_LATEST_VERSION) + + if (extra_data := await self.async_get_last_extra_data()) and ( + matter_extra_data := MatterUpdateExtraStoredData.from_dict( + extra_data.as_dict() + ) + ): + self._software_update = matter_extra_data.software_update + else: + # Check for updates when added the first time. + await self.async_update() + + @property + def extra_restore_state_data(self) -> MatterUpdateExtraStoredData: + """Return Matter specific state data to be restored.""" + return MatterUpdateExtraStoredData(self._software_update) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend. + + This overrides UpdateEntity.entity_picture because the Matter brand picture + is not appropriate for a matter device which has its own brand. + """ + return None + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install a new software version.""" + + software_version: str | int | None = version + if self._software_update is not None and ( + version is None or version == self._software_update.software_version_string + ): + # Update to the version previously fetched and shown. + # We can pass the integer version directly to speedup download. + software_version = self._software_update.software_version + + if software_version is None: + raise HomeAssistantError("No software version specified") + + self._attr_in_progress = True + # Immediately update the progress state change to make frontend feel responsive. + # Progress updates from the device usually take few seconds to come in. + self.async_write_ha_state() + try: + await self.matter_client.update_node( + node_id=self._endpoint.node.node_id, + software_version=software_version, + ) + except UpdateCheckError as err: + raise HomeAssistantError(f"Error finding applicable update: {err}") from err + except UpdateError as err: + raise HomeAssistantError(f"Error updating: {err}") from err + finally: + # Check for updates right after the update since Matter devices + # can have strict update paths (e.g. Eve) + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_future + ) + + async def _async_update_future(self, now: datetime | None = None) -> None: + """Request update.""" + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + await super().async_will_remove_from_hass() + if self._cancel_update is not None: + self._cancel_update() + + +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.UPDATE, + entity_description=UpdateEntityDescription( + key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE, name=None + ), + entity_class=MatterUpdate, + required_attributes=( + clusters.BasicInformation.Attributes.SoftwareVersion, + clusters.BasicInformation.Attributes.SoftwareVersionString, + clusters.OtaSoftwareUpdateRequestor.Attributes.UpdatePossible, + clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateState, + clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress, + ), + ), +] diff --git a/requirements_all.txt b/requirements_all.txt index 328e9bf840c..010f0ed32a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2289,7 +2289,7 @@ python-kasa[speedups]==0.7.0.5 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==6.2.2 +python-matter-server==6.3.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ff5e7e8c02..54079d42273 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.0.5 # homeassistant.components.matter -python-matter-server==6.2.2 +python-matter-server==6.3.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index d561f6db1f9..f3d8740a73b 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -51,6 +51,7 @@ async def matter_client_fixture() -> AsyncGenerator[MagicMock]: wifi_credentials_set=True, thread_credentials_set=True, min_supported_schema_version=SCHEMA_VERSION, + bluetooth_enabled=False, ) yield client diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index f591709fbda..000b0d4e2e6 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -6,7 +6,8 @@ "sdk_version": "2022.12.0", "wifi_credentials_set": true, "thread_credentials_set": false, - "min_supported_schema_version": 1 + "min_supported_schema_version": 1, + "bluetooth_enabled": false }, "nodes": [ { diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 503fd3b9a7a..95447783bbc 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -7,7 +7,8 @@ "sdk_version": "2022.12.0", "wifi_credentials_set": true, "thread_credentials_set": false, - "min_supported_schema_version": 1 + "min_supported_schema_version": 1, + "bluetooth_enabled": false }, "nodes": [ { diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index aad0afdfdcd..58c22f1b807 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -78,7 +78,7 @@ ], "0/42/0": [], "0/42/1": true, - "0/42/2": 0, + "0/42/2": 1, "0/42/3": 0, "0/42/65532": 0, "0/42/65533": 1, diff --git a/tests/components/matter/test_update.py b/tests/components/matter/test_update.py new file mode 100644 index 00000000000..73c69407bbc --- /dev/null +++ b/tests/components/matter/test_update.py @@ -0,0 +1,171 @@ +"""Test Matter number entities.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from chip.clusters import Objects as clusters +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor +from matter_server.client.models.node import MatterNode +from matter_server.common.models import MatterSoftwareVersion, UpdateSource +import pytest + +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +def set_node_attribute_typed( + node: MatterNode, + endpoint: int, + attribute: ClusterAttributeDescriptor, + value: Any, +) -> None: + """Set a node attribute.""" + set_node_attribute( + node, endpoint, attribute.cluster_id, attribute.attribute_id, value + ) + + +@pytest.fixture(name="check_node_update") +async def check_node_update_fixture(matter_client: MagicMock) -> AsyncMock: + """Fixture for a flow sensor node.""" + matter_client.check_node_update = AsyncMock(return_value=None) + return matter_client.check_node_update + + +@pytest.fixture(name="updateable_node") +async def updateable_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a flow sensor node.""" + return await setup_integration_with_node_fixture( + hass, "dimmable-light", matter_client + ) + + +async def test_update_entity( + hass: HomeAssistant, + matter_client: MagicMock, + check_node_update: AsyncMock, + updateable_node: MatterNode, +) -> None: + """Test update entity exists and update check got made.""" + state = hass.states.get("update.mock_dimmable_light") + assert state + assert state.state == STATE_OFF + + assert matter_client.check_node_update.call_count == 1 + + +async def test_update_install( + hass: HomeAssistant, + matter_client: MagicMock, + check_node_update: AsyncMock, + updateable_node: MatterNode, +) -> None: + """Test update entity exists and update check got made.""" + state = hass.states.get("update.mock_dimmable_light") + assert state + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "v1.0" + + await async_setup_component(hass, "homeassistant", {}) + + check_node_update.return_value = MatterSoftwareVersion( + vid=65521, + pid=32768, + software_version=2, + software_version_string="v2.0", + firmware_information="", + min_applicable_software_version=0, + max_applicable_software_version=1, + release_notes_url="http://home-assistant.io/non-existing-product", + update_source=UpdateSource.LOCAL, + ) + + await hass.services.async_call( + "homeassistant", + "update_entity", + { + ATTR_ENTITY_ID: "update.mock_dimmable_light", + }, + blocking=True, + ) + + assert matter_client.check_node_update.call_count == 2 + + state = hass.states.get("update.mock_dimmable_light") + assert state + assert state.state == STATE_ON + assert state.attributes.get("latest_version") == "v2.0" + assert ( + state.attributes.get("release_url") + == "http://home-assistant.io/non-existing-product" + ) + + await async_setup_component(hass, "update", {}) + + await hass.services.async_call( + "update", + "install", + { + ATTR_ENTITY_ID: "update.mock_dimmable_light", + }, + blocking=True, + ) + + set_node_attribute_typed( + updateable_node, + 0, + clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateState, + clusters.OtaSoftwareUpdateRequestor.Enums.UpdateStateEnum.kDownloading, + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("update.mock_dimmable_light") + assert state + assert state.state == STATE_ON + assert state.attributes.get("in_progress") + + set_node_attribute_typed( + updateable_node, + 0, + clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress, + 50, + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("update.mock_dimmable_light") + assert state + assert state.state == STATE_ON + assert state.attributes.get("in_progress") == 50 + + set_node_attribute_typed( + updateable_node, + 0, + clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateState, + clusters.OtaSoftwareUpdateRequestor.Enums.UpdateStateEnum.kIdle, + ) + set_node_attribute_typed( + updateable_node, + 0, + clusters.BasicInformation.Attributes.SoftwareVersion, + 2, + ) + set_node_attribute_typed( + updateable_node, + 0, + clusters.BasicInformation.Attributes.SoftwareVersionString, + "v2.0", + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("update.mock_dimmable_light") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "v2.0"