Repository navigation
Fix analog output number losing its range, unit and name on the first read #917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,8 +4,8 @@ | |
|
|
||
| from abc import ABC, abstractmethod | ||
| from dataclasses import dataclass | ||
| import functools | ||
| import logging | ||
| import math | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| from zigpy.zcl import ( | ||
|
|
@@ -110,12 +110,12 @@ def native_step(self) -> float | None: | |
| """Return the value step.""" | ||
| return self._attr_native_step | ||
|
|
||
| @functools.cached_property | ||
| @property | ||
| def native_unit_of_measurement(self) -> str | None: | ||
| """Return the unit the value is expressed in.""" | ||
| return self._attr_native_unit_of_measurement | ||
|
|
||
| @functools.cached_property | ||
| @property | ||
| def mode(self) -> NumberMode: | ||
| """Return the mode of the entity.""" | ||
| return self._attr_mode | ||
|
|
@@ -168,17 +168,38 @@ class AnalogOutputNumber(BaseNumber): | |
| ), | ||
| } | ||
|
|
||
| _capability_attribute_ids = frozenset( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On whether this concept exists elsewhere: not as a shared mechanism.
So there is nothing to reuse, and the set here is fine for this PR. It does repeat six of the eight attributes declared in |
||
| { | ||
| AnalogOutput.AttributeDefs.min_present_value.id, | ||
| AnalogOutput.AttributeDefs.max_present_value.id, | ||
| AnalogOutput.AttributeDefs.resolution.id, | ||
| AnalogOutput.AttributeDefs.description.id, | ||
| AnalogOutput.AttributeDefs.engineering_units.id, | ||
| AnalogOutput.AttributeDefs.application_type.id, | ||
| } | ||
| ) | ||
|
|
||
| def __init__(self, endpoint: Endpoint, device: Device, **kwargs: Any) -> None: | ||
| """Init this number.""" | ||
| super().__init__(endpoint=endpoint, device=device, **kwargs) | ||
| self.recompute_capabilities() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On the Overrides that rely only on the central call: Lifecycle in
So the one central call comes after the point where state is first needed, and "state-ready before the first read" is left to each entity. ( Design option for the maintainers, not a request to change this PR: call
That means the central call is safe to add as a guarantee, placed right after construction, but the existing |
||
|
|
||
| def recompute_capabilities(self) -> None: | ||
| """Recompute capabilities.""" | ||
| super().recompute_capabilities() | ||
|
|
||
| min_val = self._cluster.get(AnalogOutput.AttributeDefs.min_present_value.name) | ||
| max_val = self._cluster.get(AnalogOutput.AttributeDefs.max_present_value.name) | ||
| self._attr_native_min_value = min_val or 0 | ||
| self._attr_native_max_value = max_val or 1023 | ||
| self._attr_native_step = self._cluster.get( | ||
| AnalogOutput.AttributeDefs.resolution.name | ||
| ) | ||
| self._attr_native_min_value = 0 if min_val is None else min_val | ||
| self._attr_native_max_value = 1023 if max_val is None else max_val | ||
|
|
||
| # Guard against 0 resolution reported by some devices | ||
| resolution = self._cluster.get(AnalogOutput.AttributeDefs.resolution.name) | ||
| if resolution is not None and math.isfinite(resolution) and resolution > 0: | ||
| self._attr_native_step = resolution | ||
| else: | ||
| self._attr_native_step = None | ||
|
|
||
| self._attr_native_unit_of_measurement = BACNET_UNITS_TO_HA_UNITS.get( | ||
| self._cluster.get(AnalogOutput.AttributeDefs.engineering_units.name) | ||
| ) | ||
|
|
@@ -241,6 +262,8 @@ def handle_attribute_updated( | |
| | AttributeWrittenEvent, | ||
| ) -> None: | ||
| """Handle value update from cluster.""" | ||
| if event.attribute_id in self._capability_attribute_ids: | ||
| self.recompute_capabilities() | ||
| self.maybe_emit_state_changed_event() | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inventory for the
cached_propertyquestion (line numbers at this PR's head). 52 uses remain:zha/zigbee/device.py(14): all listed in_ZIGPY_CACHED_PROPERTIES(device.py:344) and deleted when the zigpy device is swapped (device.py:411-413). The tuple and the decorated properties match one to one.zha/zigbee/group.py(6):members,stateandall_member_entity_unique_idsare cleared byGroup.clear_caches()(group.py:252). TheGroupMemberones (endpoint,member_info,associated_entities) are not cleared individually and live as long as theGroupMemberobject, which is rebuilt whenmembersis cleared. Note thatGroup.state(group.py:199) andGroupMember.member_info(group.py:99) embedentity.statesnapshots, so they keep whatever state the entities had when first accessed until the nextclear_caches(). That predates this PR and is the closest relative of the bug fixed here.zha/zigbee/endpoint.py(4),zha/async_.py(1),identifiersinzha/application/platforms/__init__.py(3) andDeviceCounterSensor.identifiers/.device(sensor/__init__.py:459,:495): values fixed at construction.fan/__init__.py(13),climate/__init__.py(4),cover/__init__.py:919,:924,button/__init__.py:110,:115,device_tracker.py:186: per-class constants or values set in__init__(_fan_cluster,_args,_kwargs).Apart from the group snapshots, none of these depends on a value that a
recompute_capabilities()override rewrites. Of the two converted here, onlynative_unit_of_measurementdid (_attr_native_unit_of_measurement, line 203). Nothing writes_attr_modeafterNumberConfigurationEntity.__init__, somodewould have been fine either way. The one to keep an eye on isBaseFan.supported_features(fan/__init__.py:130-133): it cachesself._attr_supported_features, which today is only ever a class attribute (fan/__init__.py:82,:393,:568). If a fan ever computes features from attributes it would go stale the same way.There is no generic invalidation for entity-level cached properties. The two mechanisms above are specific to
DeviceandGroup. Plain properties, as done here, match what the rest ofBaseNumberand the other platforms' capability properties already do.Related, on the Home Assistant side:
ZHAEntity.nameis acached_property(homeassistant/components/zha/entity.py:83in home-assistant/core) and the number platform's_update_capability_attrs(number.py:56) refreshes only mode, min, max, step and unit. That is why adescriptionthat arrives after the entity exists shows up in HA only after a restart, as the PR description notes.