From f96e0e55cdbdfe8de61a209c07c1435ff439dcd9 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Sun, 12 Jan 2025 01:14:25 +0100 Subject: [PATCH 1/2] Cluster.bind is sync. --- tests/test_danfoss.py | 6 ++++-- tests/test_ikea.py | 17 +++++++---------- tests/test_sinope.py | 9 ++++----- tests/test_tuya.py | 10 ++++++---- tests/test_xiaomi.py | 20 ++++++++------------ zhaquirks/__init__.py | 24 ++++++++++++++---------- zhaquirks/danfoss/thermostat.py | 12 ++++++------ zhaquirks/develco/air_quality.py | 5 ++--- zhaquirks/ikea/__init__.py | 8 +++++--- zhaquirks/osram/lightifyx4.py | 8 +++++--- zhaquirks/tuya/__init__.py | 2 +- zhaquirks/tuya/ts0501_fan_switch.py | 6 +++--- zhaquirks/tuya/ts0601_valve.py | 8 +++++--- zhaquirks/waxman/leaksmart.py | 4 ++-- zhaquirks/xiaomi/aqara/opple_remote.py | 8 +++++--- zhaquirks/xiaomi/aqara/plug_eu.py | 6 +++--- zhaquirks/xiaomi/aqara/tvoc.py | 16 +++++++++------- 17 files changed, 89 insertions(+), 80 deletions(-) diff --git a/tests/test_danfoss.py b/tests/test_danfoss.py index 7031984918..bf7fb5e11f 100644 --- a/tests/test_danfoss.py +++ b/tests/test_danfoss.py @@ -8,6 +8,7 @@ from zigpy.zcl.clusters.hvac import Thermostat from zigpy.zcl.foundation import WriteAttributesStatusRecord, ZCLAttributeDef +from tests.common import wait_for_zigpy_tasks import zhaquirks from zhaquirks.danfoss.thermostat import CustomizedStandardCluster @@ -46,7 +47,7 @@ def test_popp_signature(assert_signature_matches_quirk): ) -@mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock()) +@mock.patch("zigpy.zcl.Cluster.bind", mock.Mock()) async def test_danfoss_time_bind(zigpy_device_from_quirk): """Test the time being set when binding the Time cluster.""" device = zigpy_device_from_quirk(zhaquirks.danfoss.thermostat.DanfossThermostat) @@ -67,7 +68,8 @@ def mock_write(attributes, manufacturer=None): ) with patch_danfoss_trv_write: - await danfoss_thermostat_cluster.bind() + danfoss_thermostat_cluster.bind() + await wait_for_zigpy_tasks() assert 0x0000 in danfoss_time_cluster._attr_cache assert 0x0001 in danfoss_time_cluster._attr_cache diff --git a/tests/test_ikea.py b/tests/test_ikea.py index 88b1e4643e..6b7c1aa858 100644 --- a/tests/test_ikea.py +++ b/tests/test_ikea.py @@ -7,7 +7,7 @@ from zigpy.zcl.clusters.general import Basic, PowerConfiguration from zigpy.zcl.clusters.measurement import PM25 -from tests.common import ClusterListener +from tests.common import ClusterListener, wait_for_zigpy_tasks import zhaquirks import zhaquirks.ikea.starkvind from zhaquirks.ikea.starkvind import IkeaAirpurifier @@ -159,7 +159,7 @@ def mock_read(attributes, manufacturer=None): assert not fail -@mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock()) +@mock.patch("zigpy.zcl.Cluster.bind", mock.Mock()) @pytest.mark.parametrize( "firmware, pct_device, pct_correct, expected_pct_updates, expect_log_warning", ( @@ -204,20 +204,18 @@ def mock_read(attributes, manufacturer=None): ] return (records,) - p1 = mock.patch.object(power_cluster, "create_catching_task") - p2 = mock.patch.object( + p1 = mock.patch.object( basic_cluster, "_read_attributes", mock.AsyncMock(side_effect=mock_read) ) - with p1 as mock_task, p2 as request_mock: + with p1 as request_mock: # update battery percentage with no firmware in attr cache, check pct not doubled for now power_cluster.update_attribute(battery_pct_id, pct_device) assert len(power_listener.attribute_updates) == 1 assert power_listener.attribute_updates[0] == (battery_pct_id, pct_device) # but also check that sw_build_id read is requested in the background for next update - assert mock_task.call_count == 1 - await mock_task.call_args[0][0] # await coroutine to read attribute + await wait_for_zigpy_tasks() assert request_mock.call_count == 1 # verify request to read sw_build_id assert request_mock.mock_calls[0][1][0][0] == sw_build_id @@ -228,7 +226,6 @@ def mock_read(attributes, manufacturer=None): assert power_listener.attribute_updates[1] == (battery_pct_id, pct_correct) # reset mocks for testing when sw_build_id is known next - mock_task.reset_mock() request_mock.reset_mock() power_listener = ClusterListener(power_cluster) @@ -239,11 +236,11 @@ def mock_read(attributes, manufacturer=None): assert power_listener.attribute_updates[0] == (battery_pct_id, pct_correct) # check no attribute reads were requested when sw_build_id is known - assert mock_task.call_count == 0 assert request_mock.call_count == 0 # make sure a call to bind() always reads sw_build_id (e.g. on join or to refresh when repaired/reconfigured) - await power_cluster.bind() + power_cluster.bind() + await wait_for_zigpy_tasks() assert request_mock.call_count == 1 assert request_mock.mock_calls[0][1][0][0] == sw_build_id diff --git a/tests/test_sinope.py b/tests/test_sinope.py index 72b3e69abd..b39fd38199 100644 --- a/tests/test_sinope.py +++ b/tests/test_sinope.py @@ -9,7 +9,7 @@ from zigpy.zcl.clusters.general import DeviceTemperature from zigpy.zcl.clusters.measurement import FlowMeasurement -from tests.common import ClusterListener +from tests.common import ClusterListener, wait_for_zigpy_tasks import zhaquirks from zhaquirks.const import ( COMMAND_M_INITIAL_PRESS, @@ -224,12 +224,12 @@ async def test_sinope_light_switch_reporting(zigpy_device_from_quirk, quirk): manu_cluster = device.endpoints[1].in_clusters[SINOPE_MANUFACTURER_CLUSTER_ID] request_patch = mock.patch("zigpy.zcl.Cluster.request", mock.AsyncMock()) - bind_patch = mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock()) - with request_patch as request_mock, bind_patch as bind_mock: + with request_patch as request_mock: request_mock.return_value = (foundation.Status.SUCCESS, "done") - await manu_cluster.bind() + manu_cluster.bind() + await wait_for_zigpy_tasks() await manu_cluster.configure_reporting( SinopeTechnologiesManufacturerCluster.AttributeDefs.action_report.id, 3600, @@ -238,7 +238,6 @@ async def test_sinope_light_switch_reporting(zigpy_device_from_quirk, quirk): ) assert len(request_mock.mock_calls) == 1 - assert len(bind_mock.mock_calls) == 1 @pytest.mark.parametrize("quirk", (SinopeTechnologieslight,)) diff --git a/tests/test_tuya.py b/tests/test_tuya.py index 2a97a05851..7f4decce42 100644 --- a/tests/test_tuya.py +++ b/tests/test_tuya.py @@ -1641,7 +1641,7 @@ def test_multiple_attributes_report(): assert data.data.datapoints[3].dp == 9 -@mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock()) +@mock.patch("zigpy.zcl.Cluster.bind", mock.Mock()) @pytest.mark.parametrize( "quirk", (zhaquirks.tuya.ts0501_fan_switch.TS0501FanSwitch,), @@ -1655,7 +1655,8 @@ async def test_fan_switch_writes_attributes(zigpy_device_from_quirk, quirk): with mock.patch.object(fan_cluster.endpoint, "request", mock.AsyncMock()) as m1: m1.return_value = (foundation.Status.SUCCESS, "done") - await fan_cluster.bind() + fan_cluster.bind() + await wait_for_zigpy_tasks() assert len(m1.mock_calls) == 1 assert m1.mock_calls[0].kwargs["cluster"] == 514 @@ -1693,12 +1694,13 @@ async def test_power_config_no_bind(zigpy_device_from_quirk, quirk): power_cluster = device.endpoints[1].power request_patch = mock.patch("zigpy.zcl.Cluster.request", mock.AsyncMock()) - bind_patch = mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock()) + bind_patch = mock.patch("zigpy.zcl.Cluster.bind") with request_patch as request_mock, bind_patch as bind_mock: request_mock.return_value = (foundation.Status.SUCCESS, "done") - await power_cluster.bind() + power_cluster.bind() + await wait_for_zigpy_tasks() await power_cluster.configure_reporting( PowerConfiguration.attributes_by_name["battery_percentage_remaining"].id, 3600, diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py index 811a66746a..0d03e2248c 100644 --- a/tests/test_xiaomi.py +++ b/tests/test_xiaomi.py @@ -31,7 +31,7 @@ from zigpy.zcl.clusters.security import IasZone from zigpy.zcl.clusters.smartenergy import Metering -from tests.common import ZCL_OCC_ATTR_RPT_OCC, ClusterListener +from tests.common import ZCL_OCC_ATTR_RPT_OCC, ClusterListener, wait_for_zigpy_tasks import zhaquirks from zhaquirks.const import ( BUTTON_1, @@ -491,7 +491,7 @@ def test_attribute_parsing(raw_report): assert len(raw_report) == 2 * len(reports[0]) -@mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock()) +@mock.patch("zigpy.zcl.Cluster.bind", mock.Mock()) @pytest.mark.parametrize("quirk", (zhaquirks.xiaomi.aqara.plug_eu.PlugMAEU01,)) async def test_xiaomi_eu_plug_binding(zigpy_device_from_quirk, quirk): """Test binding Xiaomi EU plug sets OppleMode to True and removes the plug from group 0.""" @@ -499,27 +499,23 @@ async def test_xiaomi_eu_plug_binding(zigpy_device_from_quirk, quirk): device = zigpy_device_from_quirk(quirk) opple_cluster = device.endpoints[1].opple_cluster - p1 = mock.patch.object(opple_cluster, "create_catching_task") p2 = mock.patch.object(opple_cluster.endpoint, "request", mock.AsyncMock()) - with p1 as mock_task, p2 as request_mock: + with p2 as request_mock: request_mock.return_value = (foundation.Status.SUCCESS, "done") - await opple_cluster.bind() + opple_cluster.bind() + await wait_for_zigpy_tasks() - # Only removed the plug from group 0 so far - assert len(request_mock.mock_calls) == 1 - assert mock_task.call_count == 1 + assert len(request_mock.mock_calls) == 2 + # Removed the plug from group 0 assert request_mock.mock_calls[0] assert request_mock.mock_calls[0].kwargs["cluster"] == 4 assert request_mock.mock_calls[0].kwargs["sequence"] == 1 assert request_mock.mock_calls[0].kwargs["data"] == b"\x01\x01\x03\x00\x00" - # Await call writing OppleMode attribute - await mock_task.call_args[0][0] - - assert len(request_mock.mock_calls) == 2 + # Write the OppleMode attribute assert request_mock.mock_calls[1].kwargs["cluster"] == 64704 assert request_mock.mock_calls[1].kwargs["sequence"] == 2 assert ( diff --git a/zhaquirks/__init__.py b/zhaquirks/__init__.py index 28d501e619..ff914adb85 100644 --- a/zhaquirks/__init__.py +++ b/zhaquirks/__init__.py @@ -71,7 +71,7 @@ class LocalDataCluster(CustomCluster): _CONSTANT_ATTRIBUTES: dict[int, typing.Any] = {} _VALID_ATTRIBUTES: set[int] = set() - async def bind(self): + def bind(self): """Prevent bind.""" self.debug("binding LocalDataCluster") return (foundation.Status.SUCCESS,) @@ -172,14 +172,16 @@ class GroupBoundCluster(CustomCluster): COORDINATOR_GROUP_ID = 0x30 # Group id with only coordinator as a member - async def bind(self): + def bind(self): """Bind cluster to a group.""" # Ensure coordinator is a member of the group application = self._endpoint.device.application coordinator = application.get_device(application.state.node_info.ieee) - await coordinator.add_to_group( - self.COORDINATOR_GROUP_ID, - name="Coordinator Group - Created by ZHAQuirks", + self.create_catching_task( + coordinator.add_to_group( + self.COORDINATOR_GROUP_ID, + name="Coordinator Group - Created by ZHAQuirks", + ) ) # Bind cluster to group @@ -187,11 +189,13 @@ async def bind(self): dstaddr.addrmode = 1 dstaddr.nwk = self.COORDINATOR_GROUP_ID dstaddr.endpoint = self._endpoint.endpoint_id - return await self._endpoint.device.zdo.Bind_req( - self._endpoint.device.ieee, - self._endpoint.endpoint_id, - self.cluster_id, - dstaddr, + self.create_catching_task( + self._endpoint.device.zdo.Bind_req( + self._endpoint.device.ieee, + self._endpoint.endpoint_id, + self.cluster_id, + dstaddr, + ) ) diff --git a/zhaquirks/danfoss/thermostat.py b/zhaquirks/danfoss/thermostat.py index fe41a91db9..0fc1ad6525 100644 --- a/zhaquirks/danfoss/thermostat.py +++ b/zhaquirks/danfoss/thermostat.py @@ -375,14 +375,14 @@ async def write_attributes(self, attributes, manufacturer=None): return write_res - async def bind(self): + def bind(self): """According to the documentation of Zigbee2MQTT there is a bug in the Danfoss firmware with the time. It doesn't request it, so it has to be fed the correct time. """ - await self.endpoint.time.write_time() + self.create_catching_task(self.endpoint.time.write_time()) - return await super().bind() + return super().bind() class DanfossUserInterfaceCluster(CustomizedStandardCluster, UserInterface): @@ -470,13 +470,13 @@ async def write_time(self): } ) - async def bind(self): + def bind(self): """According to the documentation of Zigbee2MQTT there is a bug in the Danfoss firmware with the time. It doesn't request it, so it has to be fed the correct time. """ - result = await super().bind() - await self.write_time() + result = super().bind() + self.create_catching_task(self.write_time()) return result diff --git a/zhaquirks/develco/air_quality.py b/zhaquirks/develco/air_quality.py index c8819ec292..6a9f05c226 100644 --- a/zhaquirks/develco/air_quality.py +++ b/zhaquirks/develco/air_quality.py @@ -130,10 +130,9 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.endpoint.device.voc_bus.add_listener(self) - async def bind(self): + def bind(self): """Bind cluster.""" - result = await self.endpoint.device.app_cluster.bind() - return result + return self.endpoint.device.app_cluster.bind() async def write_attributes(self, attributes, manufacturer=None): """Ignore write_attributes.""" diff --git a/zhaquirks/ikea/__init__.py b/zhaquirks/ikea/__init__.py index d584269ccf..83c5b6e852 100644 --- a/zhaquirks/ikea/__init__.py +++ b/zhaquirks/ikea/__init__.py @@ -204,10 +204,12 @@ class DoublingPowerConfigClusterIKEA(CustomCluster, PowerConfiguration): This implementation doubles battery pct remaining for IKEA devices with old firmware. """ - async def bind(self): + def bind(self): """Bind cluster and read the sw_build_id for later use.""" - result = await super().bind() - await self.endpoint.basic.read_attributes([Basic.AttributeDefs.sw_build_id.id]) + result = super().bind() + self.create_catching_task( + self.endpoint.basic.read_attributes([Basic.AttributeDefs.sw_build_id.id]) + ) return result def _is_firmware_new(self): diff --git a/zhaquirks/osram/lightifyx4.py b/zhaquirks/osram/lightifyx4.py index 1885a39939..3e4e561231 100644 --- a/zhaquirks/osram/lightifyx4.py +++ b/zhaquirks/osram/lightifyx4.py @@ -85,10 +85,12 @@ class OsramButtonCluster(CustomCluster): 0x002F: 0xFFFF, } - async def bind(self): + def bind(self): """Bind cluster.""" - result = await super().bind() - await self.write_attributes(self.attr_config, manufacturer=OSRAM_MFG_CODE) + result = super().bind() + self.create_catching_task( + self.write_attributes(self.attr_config, manufacturer=OSRAM_MFG_CODE) + ) return result diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py index b63b48bea6..7f8716b747 100644 --- a/zhaquirks/tuya/__init__.py +++ b/zhaquirks/tuya/__init__.py @@ -881,7 +881,7 @@ def update_attribute(self, attr_name: str, value: Any) -> None: class TuyaNoBindPowerConfigurationCluster(CustomCluster, PowerConfiguration): """PowerConfiguration cluster that prevents setting up binding/attribute reports in order to stop battery drain.""" - async def bind(self): + def bind(self): """Prevent bind.""" return (foundation.Status.SUCCESS,) diff --git a/zhaquirks/tuya/ts0501_fan_switch.py b/zhaquirks/tuya/ts0501_fan_switch.py index e5ccc68246..e5b6d3c4ab 100644 --- a/zhaquirks/tuya/ts0501_fan_switch.py +++ b/zhaquirks/tuya/ts0501_fan_switch.py @@ -23,10 +23,10 @@ class FanCluster(CustomCluster, Fan): Fan.attributes_by_name["fan_mode_sequence"].id: Fan.FanModeSequence.Low_Med_High } - async def bind(self): + def bind(self): """Bind fan cluster and write attributes.""" - result = await super().bind() - await self.write_attributes(self.attr_config) + result = super().bind() + self.create_catching_task(self.write_attributes(self.attr_config)) return result diff --git a/zhaquirks/tuya/ts0601_valve.py b/zhaquirks/tuya/ts0601_valve.py index 69ebc3281e..95bdef5479 100644 --- a/zhaquirks/tuya/ts0601_valve.py +++ b/zhaquirks/tuya/ts0601_valve.py @@ -255,14 +255,16 @@ class AttributeDefs(TuyaMCUCluster.AttributeDefs): 109: "_dp_2_attr_update", } - async def bind(self): + def bind(self): """Bind cluster. When adding this device tuya gateway issues factory reset, we just need to reset the frost lock, because its state is unknown to us. """ - result = await super().bind() - await self.write_attributes({self.attributes_by_name["frost_lock_reset"].id: 0}) + result = super().bind() + self.create_catching_task( + self.write_attributes({self.attributes_by_name["frost_lock_reset"].id: 0}) + ) return result diff --git a/zhaquirks/waxman/leaksmart.py b/zhaquirks/waxman/leaksmart.py index 7efb7db50a..b61771719d 100644 --- a/zhaquirks/waxman/leaksmart.py +++ b/zhaquirks/waxman/leaksmart.py @@ -46,9 +46,9 @@ def __init__(self, *args, **kwargs): self.endpoint.device.ias_bus.add_listener(self) super()._update_attribute(ZONE_TYPE, MOISTURE_TYPE) - async def bind(self): + def bind(self): """Bind cluster.""" - return await self.endpoint.device.app_cluster.bind() + return self.endpoint.device.app_cluster.bind() async def write_attributes(self, attributes, manufacturer=None): """Ignore write_attributes.""" diff --git a/zhaquirks/xiaomi/aqara/opple_remote.py b/zhaquirks/xiaomi/aqara/opple_remote.py index a848a0e5e9..bdec7a21b7 100644 --- a/zhaquirks/xiaomi/aqara/opple_remote.py +++ b/zhaquirks/xiaomi/aqara/opple_remote.py @@ -146,10 +146,12 @@ def __init__(self, *args, **kwargs): self._current_state = None super().__init__(*args, **kwargs) - async def bind(self): + def bind(self): """Bind cluster.""" - result = await super().bind() - await self.write_attributes(self.attr_config, manufacturer=OPPLE_MFG_CODE) + result = super().bind() + self.create_catching_task( + self.write_attributes(self.attr_config, manufacturer=OPPLE_MFG_CODE) + ) return result diff --git a/zhaquirks/xiaomi/aqara/plug_eu.py b/zhaquirks/xiaomi/aqara/plug_eu.py index fc7b95f44e..5d5bb73232 100644 --- a/zhaquirks/xiaomi/aqara/plug_eu.py +++ b/zhaquirks/xiaomi/aqara/plug_eu.py @@ -65,14 +65,14 @@ class OppleCluster(XiaomiAqaraE1Cluster): # This only exists on older firmware versions. Newer versions always have the behavior as if this was set to true attr_config = {0x0009: 0x01} - async def bind(self): + def bind(self): """Bind cluster.""" - result = await super().bind() + result = super().bind() + self.create_catching_task(remove_from_ep(self.endpoint.device)) # Request seems to time out, but still writes the attribute successfully self.create_catching_task( self.write_attributes(self.attr_config, manufacturer=OPPLE_MFG_CODE) ) - await remove_from_ep(self.endpoint.device) return result diff --git a/zhaquirks/xiaomi/aqara/tvoc.py b/zhaquirks/xiaomi/aqara/tvoc.py index 7e585c8077..67e3e044f2 100644 --- a/zhaquirks/xiaomi/aqara/tvoc.py +++ b/zhaquirks/xiaomi/aqara/tvoc.py @@ -55,14 +55,16 @@ class EmulatedTVOCMeasurement(LocalDataCluster): MEASURED_VALUE: ("measured_value", t.Single), } - async def bind(self): + def bind(self): """Bind cluster.""" - result = await self.endpoint.analog_input.bind() - await self.endpoint.analog_input.configure_reporting( - self.PRESENT_VALUE, - self.TEN_SECONDS, - self.ONE_HOUR, - self.MIN_CHANGE, + result = self.endpoint.analog_input.bind() + self.create_catching_task( + self.endpoint.analog_input.configure_reporting( + self.PRESENT_VALUE, + self.TEN_SECONDS, + self.ONE_HOUR, + self.MIN_CHANGE, + ) ) return result From 94f7ec8bef90dbae4bb42b73776de19925f34ad7 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Sun, 12 Jan 2025 01:18:57 +0100 Subject: [PATCH 2/2] Cluster.unbind is sync. --- zhaquirks/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zhaquirks/__init__.py b/zhaquirks/__init__.py index ff914adb85..b698d855b8 100644 --- a/zhaquirks/__init__.py +++ b/zhaquirks/__init__.py @@ -76,7 +76,7 @@ def bind(self): self.debug("binding LocalDataCluster") return (foundation.Status.SUCCESS,) - async def unbind(self): + def unbind(self): """Prevent unbind.""" self.debug("unbinding LocalDataCluster") return (foundation.Status.SUCCESS,)