Skip to content

Commit 97d2734

Browse files
authored
Fix IKEA double battery percentage for old/new firmware (#2948)
We always double the battery pct first if the firmware is unknown (for devices which have the doubling clusters, very new IKEA devices do not at all). But if the firmware is unknown, we cause a read to be sent (as the device is hopefully awake). If that read now returns a new firmware, we "undouble" the battery pct again, so it's correct. We also always read `sw_build_id` on `bind()`
1 parent 2070f3a commit 97d2734

File tree

4 files changed

+175
-11
lines changed

4 files changed

+175
-11
lines changed

tests/test_ikea.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
from unittest import mock
44

5+
import pytest
56
from zigpy.zcl import foundation
7+
from zigpy.zcl.clusters.general import Basic, PowerConfiguration
68
from zigpy.zcl.clusters.measurement import PM25
79

810
import zhaquirks
911
import zhaquirks.ikea.starkvind
1012

13+
from tests.common import ClusterListener
14+
1115
zhaquirks.setup()
1216

1317

@@ -122,3 +126,91 @@ def mock_read(attributes, manufacturer=None):
122126
assert success
123127
assert 6 in success.values()
124128
assert not fail
129+
130+
131+
@mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock())
132+
@pytest.mark.parametrize(
133+
"firmware, pct_device, pct_correct, expected_pct_updates, expect_log_warning",
134+
(
135+
("2.3.075", 50, 100, 1, False),
136+
("24.4.5", 50, 50, 2, False),
137+
("invalid_fw_string", 50, 50, 2, True),
138+
),
139+
)
140+
async def test_double_power_config_firmware(
141+
caplog,
142+
zigpy_device_from_quirk,
143+
firmware,
144+
pct_device,
145+
pct_correct,
146+
expected_pct_updates,
147+
expect_log_warning,
148+
):
149+
"""Test battery percentage remaining is doubled for old firmware."""
150+
151+
device = zigpy_device_from_quirk(zhaquirks.ikea.fivebtnremote.IkeaTradfriRemote1)
152+
153+
basic_cluster = device.endpoints[1].basic
154+
ClusterListener(basic_cluster)
155+
sw_build_id = Basic.AttributeDefs.sw_build_id.id
156+
157+
power_cluster = device.endpoints[1].power
158+
power_listener = ClusterListener(power_cluster)
159+
battery_pct_id = PowerConfiguration.AttributeDefs.battery_percentage_remaining.id
160+
161+
# fake read response for attributes: return plug_read argument for all attributes
162+
def mock_read(attributes, manufacturer=None):
163+
records = [
164+
foundation.ReadAttributeRecord(
165+
attr, foundation.Status.SUCCESS, foundation.TypeValue(None, firmware)
166+
)
167+
for attr in attributes
168+
]
169+
return (records,)
170+
171+
p1 = mock.patch.object(power_cluster, "create_catching_task")
172+
p2 = mock.patch.object(
173+
basic_cluster, "_read_attributes", mock.AsyncMock(side_effect=mock_read)
174+
)
175+
176+
with p1 as mock_task, p2 as request_mock:
177+
# update battery percentage with no firmware in attr cache, check pct doubled for now
178+
power_cluster.update_attribute(battery_pct_id, pct_device)
179+
assert len(power_listener.attribute_updates) == 1
180+
assert power_listener.attribute_updates[0] == (battery_pct_id, pct_device * 2)
181+
182+
# but also check that sw_build_id read is requested in the background for next update
183+
assert mock_task.call_count == 1
184+
await mock_task.call_args[0][0] # await coroutine to read attribute
185+
assert request_mock.call_count == 1 # verify request to read sw_build_id
186+
assert request_mock.mock_calls[0][1][0][0] == sw_build_id
187+
188+
# battery pct might be updated again when the attribute read returned new firmware, check pct not doubled then
189+
# if firmware turned out to be old or still unknown, do not update battery pct again, as we doubled it already
190+
assert len(power_listener.attribute_updates) == expected_pct_updates
191+
if expected_pct_updates > 2:
192+
assert power_listener.attribute_updates[1] == (battery_pct_id, pct_correct)
193+
194+
# reset mocks for testing when sw_build_id is known next
195+
mock_task.reset_mock()
196+
request_mock.reset_mock()
197+
power_listener = ClusterListener(power_cluster)
198+
199+
# update battery percentage with firmware in attr cache, check pct doubled if needed
200+
basic_cluster.update_attribute(sw_build_id, firmware)
201+
power_cluster.update_attribute(battery_pct_id, pct_device)
202+
assert len(power_listener.attribute_updates) == 1
203+
assert power_listener.attribute_updates[0] == (battery_pct_id, pct_correct)
204+
205+
# check no attribute reads were requested when sw_build_id is known
206+
assert mock_task.call_count == 0
207+
assert request_mock.call_count == 0
208+
209+
# make sure a call to bind() always reads sw_build_id (e.g. on join or to refresh when repaired/reconfigured)
210+
await power_cluster.bind()
211+
assert request_mock.call_count == 1
212+
assert request_mock.mock_calls[0][1][0][0] == sw_build_id
213+
214+
# check log output if we expect a warning
215+
if expect_log_warning:
216+
assert f"sw_build_id is not a number: {firmware} for device" in caplog.text

zhaquirks/ikea/__init__.py

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Ikea module."""
2+
import logging
23

34
from zigpy.quirks import CustomCluster
45
import zigpy.types as t
56
from zigpy.zcl import foundation
6-
from zigpy.zcl.clusters.general import PowerConfiguration, Scenes
7+
from zigpy.zcl.clusters.general import Basic, PowerConfiguration, Scenes
78

8-
from zhaquirks import DoublingPowerConfigurationCluster, EventableCluster
9+
from zhaquirks import EventableCluster
10+
11+
_LOGGER = logging.getLogger(__name__)
912

1013
IKEA = "IKEA of Sweden"
1114
IKEA_CLUSTER_ID = 0xFC7C # decimal = 64636
@@ -192,25 +195,96 @@ class PowerConfig1CRXCluster(CustomCluster, PowerConfiguration):
192195

193196

194197
# doubling IKEA power configuration clusters:
198+
199+
200+
class DoublingPowerConfigClusterIKEA(CustomCluster, PowerConfiguration):
201+
"""PowerConfiguration cluster implementation for IKEA devices.
202+
203+
This implementation doubles battery pct remaining for IKEA devices with old firmware.
204+
"""
205+
206+
async def bind(self):
207+
"""Bind cluster and read the sw_build_id for later use."""
208+
result = await super().bind()
209+
await self.endpoint.basic.read_attributes([Basic.AttributeDefs.sw_build_id.id])
210+
return result
211+
212+
def _is_firmware_old(self):
213+
"""Checks if firmware is old or unknown."""
214+
# get sw_build_id from attribute cache if available
215+
sw_build_id = self.endpoint.basic.get(Basic.AttributeDefs.sw_build_id.id, None)
216+
217+
# guard against possible future version formatting which includes more than just numbers
218+
try:
219+
# if first part of sw_build_id is 24 or higher, then firmware is new
220+
if sw_build_id and int(sw_build_id.split(".")[0]) >= 24:
221+
return False
222+
except ValueError:
223+
_LOGGER.warning(
224+
"sw_build_id is not a number: %s for device %s",
225+
sw_build_id,
226+
self.endpoint.device.ieee,
227+
)
228+
# sw_build_id is not a number, so it must be new firmware
229+
return False
230+
231+
# unknown or old firmware
232+
return True
233+
234+
async def _read_fw_and_update_battery_pct(self, reported_battery_pct):
235+
"""Read firmware version and update battery percentage remaining if necessary."""
236+
# read sw_build_id from device
237+
await self.endpoint.basic.read_attributes([Basic.AttributeDefs.sw_build_id.id])
238+
239+
# check if sw_build_id was read successfully and new firmware is installed
240+
# if so, update cache with reported battery percentage (non-doubled)
241+
if not self._is_firmware_old():
242+
self._update_attribute(
243+
PowerConfiguration.AttributeDefs.battery_percentage_remaining.id,
244+
reported_battery_pct,
245+
)
246+
247+
def _update_attribute(self, attrid, value):
248+
"""Update attribute to double battery percentage if firmware is old/unknown.
249+
250+
If the firmware version is unknown, a background task to read the firmware version is also started,
251+
but the percentage is also doubled for now then, as that task happens asynchronously.
252+
"""
253+
if attrid == PowerConfiguration.AttributeDefs.battery_percentage_remaining.id:
254+
# if sw_build_id is not cached, create task to read from device, since it should be awake now
255+
if (
256+
self.endpoint.basic.get(Basic.AttributeDefs.sw_build_id.id, None)
257+
is None
258+
):
259+
self.create_catching_task(self._read_fw_and_update_battery_pct(value))
260+
261+
# double percentage if the firmware is old or unknown
262+
# the coroutine above will not have executed yet if the firmware is unknown,
263+
# so we double for now in that case too, and it updates again later if our doubling was wrong
264+
if self._is_firmware_old():
265+
value = value * 2
266+
super()._update_attribute(attrid, value)
267+
268+
195269
class DoublingPowerConfig2AAACluster(
196-
DoublingPowerConfigurationCluster, PowerConfig2AAACluster
270+
DoublingPowerConfigClusterIKEA, PowerConfig2AAACluster
197271
):
198272
"""Doubling power configuration cluster. Updating power attributes: 2 AAA."""
199273

200274

201275
class DoublingPowerConfig2CRCluster(
202-
DoublingPowerConfigurationCluster, PowerConfig2CRCluster
276+
DoublingPowerConfigClusterIKEA, PowerConfig2CRCluster
203277
):
204278
"""Doubling power configuration cluster. Updating power attributes: 2 CR2032."""
205279

206280

207281
class DoublingPowerConfig1CRCluster(
208-
DoublingPowerConfigurationCluster, PowerConfig1CRCluster
282+
DoublingPowerConfigClusterIKEA, PowerConfig1CRCluster
209283
):
210284
"""Doubling power configuration cluster. Updating power attributes: 1 CR2032."""
211285

212286

213287
class DoublingPowerConfig1CRXCluster(
214-
DoublingPowerConfigurationCluster, PowerConfig1CRXCluster
288+
DoublingPowerConfigClusterIKEA, PowerConfig1CRXCluster
215289
):
216290
"""Doubling power configuration cluster. Updating power attributes: 1 CR2032 and zero voltage."""

zhaquirks/ikea/blinds.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
)
1414
from zigpy.zcl.clusters.lightlink import LightLink
1515

16-
from zhaquirks import DoublingPowerConfigurationCluster
1716
from zhaquirks.const import (
1817
DEVICE_TYPE,
1918
ENDPOINTS,
@@ -22,7 +21,7 @@
2221
OUTPUT_CLUSTERS,
2322
PROFILE_ID,
2423
)
25-
from zhaquirks.ikea import IKEA, IKEA_CLUSTER_ID
24+
from zhaquirks.ikea import IKEA, IKEA_CLUSTER_ID, DoublingPowerConfigClusterIKEA
2625

2726

2827
class IkeaTradfriRollerBlinds(CustomDevice):
@@ -66,7 +65,7 @@ class IkeaTradfriRollerBlinds(CustomDevice):
6665
DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE,
6766
INPUT_CLUSTERS: [
6867
Basic.cluster_id,
69-
DoublingPowerConfigurationCluster,
68+
DoublingPowerConfigClusterIKEA,
7069
Identify.cluster_id,
7170
Groups.cluster_id,
7271
Scenes.cluster_id,

zhaquirks/ikea/fivebtnremote.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from zigpy.zcl.clusters.homeautomation import Diagnostic
1717
from zigpy.zcl.clusters.lightlink import LightLink
1818

19-
from zhaquirks import DoublingPowerConfigurationCluster
2019
from zhaquirks.const import (
2120
CLUSTER_ID,
2221
COMMAND,
@@ -98,7 +97,7 @@ class IkeaTradfriRemote1(CustomDevice):
9897
DEVICE_TYPE: zll.DeviceType.SCENE_CONTROLLER,
9998
INPUT_CLUSTERS: [
10099
Basic.cluster_id,
101-
DoublingPowerConfigurationCluster,
100+
DoublingPowerConfig1CRCluster,
102101
Identify.cluster_id,
103102
Alarms.cluster_id,
104103
Diagnostic.cluster_id,

0 commit comments

Comments
 (0)