Add Matter update entities for devices with OTA requestor (#120304)
* Add Matter update entities for devices with OTA requestor Matter devices which support the OTA requestor cluster can receive updates from a OTA provider. The Home Assistant Python Matter Server implements such an OTA provider now. Add update entities for devices which support the OTA requestor cluster and check for available updates. Allow the user to update the firmware. The update progress will be read directly from the devices' OTA requestor cluster. * Update homeassistant/components/matter/update.py Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com> * Bump python-matter-server to 6.3.0 This includes models and commands required for device firmware updates. * Fix tests by including the new bluetooth_enabled field * Add update entity tests * Fix update entity test * Update entity picture docstring * Add note about reasons for progress state change update * Enable polling for update entities by default Matter entities don't enable polling any longer. Enable polling for update entities by default. * Add comment about why Update entities are polled --------- Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
This commit is contained in:
parent
b46b74df90
commit
07b2a7537b
10 changed files with 415 additions and 7 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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."]
|
||||
}
|
||||
|
|
232
homeassistant/components/matter/update.py
Normal file
232
homeassistant/components/matter/update.py
Normal file
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
|
|
171
tests/components/matter/test_update.py
Normal file
171
tests/components/matter/test_update.py
Normal file
|
@ -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"
|
Loading…
Add table
Reference in a new issue