Skip to content

Commit d198d5f

Browse files
authored
Keep the device firmware version in sync (#427)
* Sync the device firmware version to its update entity * Add a unit test * Add event deduplication * Shuffle code around a little * Rename `DeviceUpdatedEvent` to `DeviceFirmwareInfoUpdatedEvent`
1 parent e212bd4 commit d198d5f

File tree

6 files changed

+131
-45
lines changed

6 files changed

+131
-45
lines changed

tests/test_device.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Test ZHA device switch."""
22

33
import asyncio
4+
from datetime import UTC, datetime
45
import logging
56
import time
67
from unittest import mock
@@ -13,7 +14,7 @@
1314
from zigpy.quirks.v2 import DeviceAlertLevel, DeviceAlertMetadata, QuirkBuilder
1415
import zigpy.types
1516
from zigpy.zcl.clusters import general
16-
from zigpy.zcl.clusters.general import PowerConfiguration
17+
from zigpy.zcl.clusters.general import Ota, PowerConfiguration
1718
from zigpy.zcl.foundation import Status, WriteAttributesResponse
1819
import zigpy.zdo.types as zdo_t
1920

@@ -43,7 +44,11 @@
4344
from zha.application.platforms.sensor import LQISensor, RSSISensor
4445
from zha.application.platforms.switch import Switch
4546
from zha.exceptions import ZHAException
46-
from zha.zigbee.device import ClusterBinding, get_device_automation_triggers
47+
from zha.zigbee.device import (
48+
ClusterBinding,
49+
DeviceFirmwareInfoUpdatedEvent,
50+
get_device_automation_triggers,
51+
)
4752
from zha.zigbee.group import Group
4853

4954

@@ -742,7 +747,7 @@ async def test_device_properties(
742747

743748
assert zha_device.power_configuration_ch is None
744749
assert zha_device.basic_ch is not None
745-
assert zha_device.sw_version is None
750+
assert zha_device.firmware_version is None
746751

747752
assert len(zha_device.platform_entities) == 3
748753

@@ -781,6 +786,50 @@ async def test_device_properties(
781786
assert zha_device.is_coordinator is None
782787

783788

789+
async def test_device_firmware_version_syncing(zha_gateway: Gateway) -> None:
790+
"""Test device firmware version syncing."""
791+
zigpy_dev = await zigpy_device_from_json(
792+
zha_gateway.application_controller,
793+
"tests/data/devices/philips-sml001.json",
794+
)
795+
796+
zha_device = await join_zigpy_device(zha_gateway, zigpy_dev)
797+
798+
# Register a callback to listen for device updates
799+
update_callback = mock.Mock()
800+
zha_device.on_event(DeviceFirmwareInfoUpdatedEvent.event_type, update_callback)
801+
802+
# The firmware version is restored on device initialization
803+
assert zha_device.firmware_version == "0x42006bb7"
804+
805+
# If we update the entity, the device updates as well
806+
update_entity = get_entity(zha_device, platform=Platform.UPDATE)
807+
update_entity._ota_cluster_handler.attribute_updated(
808+
attrid=Ota.AttributeDefs.current_file_version.id,
809+
value=zigpy.types.uint32_t(0xABCD1234),
810+
timestamp=datetime.now(UTC),
811+
)
812+
813+
assert zha_device.firmware_version == "0xabcd1234"
814+
815+
# Duplicate updates are ignored
816+
update_entity._ota_cluster_handler.attribute_updated(
817+
attrid=Ota.AttributeDefs.current_file_version.id,
818+
value=zigpy.types.uint32_t(0xABCD1234),
819+
timestamp=datetime.now(UTC),
820+
)
821+
822+
assert zha_device.firmware_version == "0xabcd1234"
823+
assert update_callback.mock_calls == [
824+
call(
825+
DeviceFirmwareInfoUpdatedEvent(
826+
old_firmware_version="0x42006bb7",
827+
new_firmware_version="0xabcd1234",
828+
)
829+
)
830+
]
831+
832+
784833
async def test_quirks_v2_device_renaming(zha_gateway: Gateway) -> None:
785834
"""Test quirks v2 device renaming."""
786835
registry = DeviceRegistry()

tests/test_update.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,6 @@ async def setup_test_data(
153153
)
154154

155155
zha_device = await join_zigpy_device(zha_gateway, zigpy_device)
156-
zha_device.async_update_sw_build_id(installed_fw_version)
157156

158157
return zha_device, ota_cluster, fw_image, installed_fw_version
159158

zha/application/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ def pretty_name(self) -> str:
192192
ZHA_CLUSTER_HANDLER_CFG_DONE = "zha_channel_cfg_done"
193193
ZHA_CLUSTER_HANDLER_READS_PER_REQ = 5
194194
ZHA_EVENT = "zha_event"
195+
ZHA_DEVICE_UPDATED_EVENT = "zha_device_updated_event"
195196
ZHA_GW_MSG = "zha_gateway_message"
196197
ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized"
197198
ZHA_GW_MSG_DEVICE_INFO = "device_info"

zha/application/platforms/update.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import functools
88
import itertools
99
import logging
10-
from typing import TYPE_CHECKING, Any, Final, final
10+
from typing import TYPE_CHECKING, Any, Final
1111

1212
from zigpy.ota import OtaImagesResult, OtaImageWithMetadata
1313
from zigpy.zcl.clusters.general import Ota, QueryNextImageCommand
@@ -52,7 +52,6 @@ class UpdateEntityFeature(IntFlag):
5252
RELEASE_NOTES = 16
5353

5454

55-
ATTR_BACKUP: Final = "backup"
5655
ATTR_INSTALLED_VERSION: Final = "installed_version"
5756
ATTR_IN_PROGRESS: Final = "in_progress"
5857
ATTR_UPDATE_PERCENTAGE: Final = "update_percentage"
@@ -69,15 +68,13 @@ class UpdateEntityInfo(BaseEntityInfo):
6968

7069
supported_features: UpdateEntityFeature
7170
device_class: UpdateDeviceClass
72-
entity_category: EntityCategory
7371

7472

7573
class BaseFirmwareUpdateEntity(PlatformEntity):
7674
"""Base representation of a ZHA firmware update entity."""
7775

7876
PLATFORM = Platform.UPDATE
7977

80-
_unique_id_suffix = "firmware_update"
8178
_attr_entity_category = EntityCategory.CONFIG
8279
_attr_device_class = UpdateDeviceClass.FIRMWARE
8380
_attr_supported_features = (
@@ -105,7 +102,16 @@ def info_object(self) -> UpdateEntityInfo:
105102
def state(self):
106103
"""Get the state for the entity."""
107104
response = super().state
108-
response.update(self.state_attributes)
105+
if (release_summary := self.release_summary) is not None:
106+
release_summary = release_summary[:255]
107+
108+
response[ATTR_INSTALLED_VERSION] = self.installed_version
109+
response[ATTR_IN_PROGRESS] = self.in_progress
110+
response[ATTR_UPDATE_PERCENTAGE] = self.update_percentage
111+
response[ATTR_LATEST_VERSION] = self.latest_version
112+
response[ATTR_RELEASE_SUMMARY] = release_summary
113+
response[ATTR_RELEASE_NOTES] = self.release_notes
114+
response[ATTR_RELEASE_URL] = self.release_url
109115
return response
110116

111117
@property
@@ -161,23 +167,6 @@ def supported_features(self) -> UpdateEntityFeature:
161167
"""Flag supported features."""
162168
return self._attr_supported_features
163169

164-
@final
165-
@property
166-
def state_attributes(self) -> dict[str, Any] | None:
167-
"""Return state attributes."""
168-
if (release_summary := self.release_summary) is not None:
169-
release_summary = release_summary[:255]
170-
171-
return {
172-
ATTR_INSTALLED_VERSION: self.installed_version,
173-
ATTR_IN_PROGRESS: self.in_progress,
174-
ATTR_UPDATE_PERCENTAGE: self.update_percentage,
175-
ATTR_LATEST_VERSION: self.latest_version,
176-
ATTR_RELEASE_SUMMARY: release_summary,
177-
ATTR_RELEASE_NOTES: self.release_notes,
178-
ATTR_RELEASE_URL: self.release_url,
179-
}
180-
181170
def _get_cluster_version(self) -> str | None:
182171
"""Synchronize current file version with the cluster."""
183172

@@ -303,6 +292,8 @@ async def on_remove(self) -> None:
303292
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
304293
"""Representation of a ZHA firmware update entity."""
305294

295+
_unique_id_suffix = "firmware_update"
296+
306297
def __init__(
307298
self,
308299
cluster_handlers: list[ClusterHandler],
@@ -326,6 +317,8 @@ def __init__(
326317
class FirmwareUpdateServerEntity(BaseFirmwareUpdateEntity):
327318
"""Representation of a ZHA firmware update entity."""
328319

320+
_unique_id_suffix = "firmware_update"
321+
329322
def __init__(
330323
self,
331324
cluster_handlers: list[ClusterHandler],

zha/zigbee/cluster_handlers/general.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,6 @@ def cluster_command(
691691
self.cluster.update_attribute(
692692
Ota.AttributeDefs.current_file_version.id, current_file_version
693693
)
694-
self._endpoint.device.sw_version = current_file_version
695694

696695

697696
@registries.CLUSTER_HANDLER_REGISTRY.register(Partition.cluster_id)

zha/zigbee/device.py

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import asyncio
88
from collections import defaultdict
9-
from collections.abc import Iterable
9+
from collections.abc import Callable, Iterable
1010
import copy
1111
import dataclasses
1212
from dataclasses import dataclass
@@ -61,10 +61,16 @@
6161
UNKNOWN_MODEL,
6262
ZHA_CLUSTER_HANDLER_CFG_DONE,
6363
ZHA_CLUSTER_HANDLER_MSG,
64+
ZHA_DEVICE_UPDATED_EVENT,
6465
ZHA_EVENT,
6566
)
6667
from zha.application.helpers import convert_to_zcl_values, convert_zcl_value
67-
from zha.application.platforms import BaseEntityInfo, PlatformEntity
68+
from zha.application.platforms import (
69+
BaseEntityInfo,
70+
EntityStateChangedEvent,
71+
PlatformEntity,
72+
)
73+
from zha.const import STATE_CHANGED
6874
from zha.event import EventBase
6975
from zha.exceptions import ZHAException
7076
from zha.mixins import LogMixin
@@ -147,6 +153,17 @@ class ZHAEvent:
147153
event: Final[str] = ZHA_EVENT
148154

149155

156+
@dataclass(kw_only=True, frozen=True)
157+
class DeviceFirmwareInfoUpdatedEvent:
158+
"""Event generated when the device firmware information has changed."""
159+
160+
event_type: Final[str] = ZHA_DEVICE_UPDATED_EVENT
161+
event: Final[str] = ZHA_DEVICE_UPDATED_EVENT
162+
163+
old_firmware_version: str | None
164+
new_firmware_version: str | None
165+
166+
150167
@dataclass(kw_only=True, frozen=True)
151168
class ClusterHandlerConfigurationComplete:
152169
"""Event generated when all cluster handlers are configured."""
@@ -252,7 +269,7 @@ def __init__(
252269
self._power_config_ch: ClusterHandler | None = None
253270
self._identify_ch: ClusterHandler | None = None
254271
self._basic_ch: ClusterHandler | None = None
255-
self._sw_build_id: int | None = None
272+
self._firmware_version: str | None = None
256273

257274
device_options = _gateway.config.config.device_options
258275
if self.is_mains_powered:
@@ -272,15 +289,20 @@ def __init__(
272289
self._pending_entities: list[PlatformEntity] = []
273290
self.semaphore: asyncio.Semaphore = asyncio.Semaphore(3)
274291

292+
self._on_remove_callbacks: list[Callable[[], None]] = []
293+
275294
self._zdo_handler: ZDOClusterHandler = ZDOClusterHandler(self)
276295
self._zdo_handler.on_add()
296+
self._on_remove_callbacks.append(self._zdo_handler.on_remove)
277297

278298
self.status: DeviceStatus = DeviceStatus.CREATED
279299

280300
self._endpoints: dict[int, Endpoint] = {}
281301
for ep_id, endpoint in zigpy_device.endpoints.items():
282302
if ep_id != 0:
283-
self._endpoints[ep_id] = Endpoint.new(endpoint, self)
303+
ep = Endpoint.new(endpoint, self)
304+
self._endpoints[ep_id] = ep
305+
self._on_remove_callbacks.append(ep.on_remove)
284306

285307
def __repr__(self) -> str:
286308
"""Return a string representation of the device."""
@@ -557,14 +579,9 @@ def zigbee_signature(self) -> dict[str, Any]:
557579
}
558580

559581
@property
560-
def sw_version(self) -> int | None:
582+
def firmware_version(self) -> str | None:
561583
"""Return the software version for this device."""
562-
return self._sw_build_id
563-
564-
@sw_version.setter
565-
def sw_version(self, sw_build_id: int) -> None:
566-
"""Set the software version for this device."""
567-
self._sw_build_id = sw_build_id
584+
return self._firmware_version
568585

569586
@property
570587
def platform_entities(self) -> dict[tuple[Platform, str], PlatformEntity]:
@@ -587,9 +604,21 @@ def new(
587604
"""Create new device."""
588605
return cls(zigpy_dev, gateway)
589606

590-
def async_update_sw_build_id(self, sw_version: int) -> None:
591-
"""Update device sw version."""
592-
self._sw_build_id = sw_version
607+
def async_update_firmware_version(self, firmware_version: str) -> None:
608+
"""Update device firmware version."""
609+
if firmware_version == self._firmware_version:
610+
return
611+
612+
old_firmware_version = self._firmware_version
613+
self._firmware_version = firmware_version
614+
615+
self.emit(
616+
DeviceFirmwareInfoUpdatedEvent.event_type,
617+
DeviceFirmwareInfoUpdatedEvent(
618+
old_firmware_version=old_firmware_version,
619+
new_firmware_version=firmware_version,
620+
),
621+
)
593622

594623
async def _check_available(self, *_: Any) -> None:
595624
# don't flip the availability state of the coordinator
@@ -903,16 +932,32 @@ async def async_initialize(self, from_cache: bool = False) -> None:
903932
# At this point we can compute a primary entity
904933
self._compute_primary_entity()
905934

935+
# Sync the device's firmware version with the first platform entity
936+
for (platform, _unique_id), entity in self.platform_entities.items():
937+
if platform != Platform.UPDATE:
938+
continue
939+
940+
self._firmware_version = entity.installed_version
941+
942+
def entity_update_listener(event: EntityStateChangedEvent) -> None:
943+
"""Listen to firmware update entity changes."""
944+
entity = self.get_platform_entity(event.platform, event.unique_id)
945+
self.async_update_firmware_version(entity.installed_version)
946+
947+
self._on_remove_callbacks.append(
948+
entity.on_event(STATE_CHANGED, entity_update_listener)
949+
)
950+
951+
break
952+
906953
self.debug("power source: %s", self.power_source)
907954
self.status = DeviceStatus.INITIALIZED
908955
self.debug("completed initialization")
909956

910957
async def on_remove(self) -> None:
911958
"""Cancel tasks this device owns."""
912-
self._zdo_handler.on_remove()
913-
914-
for endpoint in self._endpoints.values():
915-
endpoint.on_remove()
959+
for callback in self._on_remove_callbacks:
960+
callback()
916961

917962
for platform_entity in self._platform_entities.values():
918963
await platform_entity.on_remove()

0 commit comments

Comments
 (0)