From 73e856f77f3cefe87e77b23cdac54a0b3d3fba61 Mon Sep 17 00:00:00 2001 From: javicalle <31999997+javicalle@users.noreply.github.com> Date: Thu, 8 Sep 2022 08:41:55 +0200 Subject: [PATCH 1/8] Implement `handle_set_time_request` in `TuyaMCUCluster` Fully implement the `handle_set_time_request` in `TuyaMCUCluster`. The code has been taken from the `TuyaManufCluster` and must have the same behavior that the already supported devices (`set_time_offset` and `set_time_local_offset`): * https://github.com/zigpy/zha-device-handlers/blob/dev/zhaquirks/tuya/__init__.py#L377-L398 --- zhaquirks/tuya/mcu/__init__.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py index a0faf407c6..5d7d5c8386 100644 --- a/zhaquirks/tuya/mcu/__init__.py +++ b/zhaquirks/tuya/mcu/__init__.py @@ -1,6 +1,7 @@ """Tuya MCU comunications.""" import asyncio import dataclasses +import datetime from typing import Any, Callable, Dict, Optional, Tuple, Union from zigpy.quirks import CustomDevice @@ -13,6 +14,7 @@ TUYA_MCU_COMMAND, TUYA_MCU_VERSION_RSP, TUYA_SET_DATA, + TUYA_SET_TIME, Data, NoManufacturerCluster, PowerOnState, @@ -20,6 +22,7 @@ TuyaData, TuyaLocalCluster, TuyaNewManufCluster, + TuyaTimePayload, ) # New manufacturer attributes @@ -135,6 +138,9 @@ async def write_attributes(self, attributes, manufacturer=None): class TuyaMCUCluster(TuyaAttributesCluster, TuyaNewManufCluster): """Manufacturer specific cluster for sending Tuya MCU commands.""" + set_time_offset = 1970 # MCU timestamp from 1/1/1970 + set_time_local_offset = None + class MCUVersion(t.Struct): """Tuya MCU version response Zcl payload.""" @@ -265,6 +271,31 @@ def handle_mcu_version_response(self, payload: MCUVersion) -> foundation.Status: self.update_attribute("mcu_version", payload.version) return foundation.Status.SUCCESS + def handle_set_time_request(self, payload: t.uint16_t) -> foundation.Status: + """Handle set_time requests (0x24).""" + + payload_rsp = TuyaTimePayload() + + utc_now = datetime.datetime.utcnow() + now = datetime.datetime.now() + + offset_time = datetime.datetime(self.set_time_offset, 1, 1) + offset_time_local = datetime.datetime( + self.set_time_local_offset or self.set_time_offset, 1, 1 + ) + + utc_timestamp = int((utc_now - offset_time).total_seconds()) + local_timestamp = int((now - offset_time_local).total_seconds()) + + payload_rsp.extend(utc_timestamp.to_bytes(4, "big", signed=False)) + payload_rsp.extend(local_timestamp.to_bytes(4, "big", signed=False)) + + self.create_catching_task( + super().command(TUYA_SET_TIME, payload_rsp, expect_reply=False) + ) + + return foundation.Status.SUCCESS + class TuyaOnOff(OnOff, TuyaLocalCluster): """Tuya MCU OnOff cluster.""" From 5499afb23f3a4b02f7d6613502d373ba9b875791 Mon Sep 17 00:00:00 2001 From: javicalle Date: Sat, 10 Sep 2022 14:46:18 +0200 Subject: [PATCH 2/8] Added tests coverage --- tests/common.py | 17 +++++++++++++ tests/test_tuya.py | 20 ++------------- tests/test_tuya_mcu.py | 58 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/tests/common.py b/tests/common.py index 319cd19dff..d5e087d2ab 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,4 +1,5 @@ """Quirks common helpers.""" +import datetime ZCL_IAS_MOTION_COMMAND = b"\t!\x00\x01\x00\x00\x00\x00\x00" @@ -21,3 +22,19 @@ def attribute_updated(self, attr_id, value): def cluster_command(self, tsn, commdand_id, args): """Command received listener.""" self.cluster_commands.append((tsn, commdand_id, args)) + + +class MockDatetime(datetime.datetime): + """Override for datetime functions.""" + + @classmethod + def now(cls): + """Return testvalue.""" + + return cls(1970, 1, 1, 1, 0, 0) + + @classmethod + def utcnow(cls): + """Return testvalue.""" + + return cls(1970, 1, 1, 2, 0, 0) \ No newline at end of file diff --git a/tests/test_tuya.py b/tests/test_tuya.py index 210122270a..74b5d87b13 100644 --- a/tests/test_tuya.py +++ b/tests/test_tuya.py @@ -31,7 +31,7 @@ import zhaquirks.tuya.ts0601_trv import zhaquirks.tuya.ts0601_valve -from tests.common import ClusterListener +from tests.common import ClusterListener, MockDatetime zhaquirks.setup() @@ -82,22 +82,6 @@ ZCL_TUYA_EHEAT_TARGET_TEMP = b"\t3\x01\x03\x05\x10\x02\x00\x04\x00\x00\x00\x15" -class NewDatetime(datetime.datetime): - """Override for datetime functions.""" - - @classmethod - def now(cls): - """Return testvalue.""" - - return cls(1970, 1, 1, 1, 0, 0) - - @classmethod - def utcnow(cls): - """Return testvalue.""" - - return cls(1970, 1, 1, 2, 0, 0) - - @pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_motion.TuyaMotion,)) async def test_motion(zigpy_device_from_quirk, quirk): """Test tuya motion sensor.""" @@ -1233,7 +1217,7 @@ async def async_success(*args, **kwargs): assert status == foundation.Status.UNSUP_CLUSTER_COMMAND origdatetime = datetime.datetime - datetime.datetime = NewDatetime + datetime.datetime = MockDatetime hdr, args = tuya_cluster.deserialize(ZCL_TUYA_SET_TIME_REQUEST) tuya_cluster.handle_message(hdr, args) diff --git a/tests/test_tuya_mcu.py b/tests/test_tuya_mcu.py index c910c7ae12..b7ffe67c86 100644 --- a/tests/test_tuya_mcu.py +++ b/tests/test_tuya_mcu.py @@ -1,5 +1,6 @@ """Tests for Tuya quirks.""" +import datetime from unittest import mock import pytest @@ -7,19 +8,21 @@ from zigpy.zcl import foundation import zhaquirks -from zhaquirks.tuya import TUYA_MCU_VERSION_RSP +from zhaquirks.tuya import TUYA_MCU_VERSION_RSP, TUYA_SET_TIME from zhaquirks.tuya.mcu import ( ATTR_MCU_VERSION, + TuyaAttributesCluster, TuyaClusterData, TuyaDPType, TuyaMCUCluster, ) -from tests.common import ClusterListener +from tests.common import ClusterListener, MockDatetime zhaquirks.setup() ZCL_TUYA_VERSION_RSP = b"\x09\x06\x11\x01\x6D\x82" +ZCL_TUYA_SET_TIME = b"\x09\x12\x24\x0D\x00" @pytest.mark.parametrize( @@ -49,6 +52,41 @@ async def test_tuya_version(zigpy_device_from_quirk, quirk): assert succ["mcu_version"] == "2.0.2" +@pytest.mark.parametrize( + "quirk", (zhaquirks.tuya.ts0601_dimmer.TuyaDoubleSwitchDimmer,) +) +async def test_tuya_mcu_set_time(zigpy_device_from_quirk, quirk): + """Test set_time requests (0x24) messages for MCU devices.""" + + tuya_device = zigpy_device_from_quirk(quirk) + + tuya_cluster = tuya_device.endpoints[1].tuya_manufacturer + cluster_listener = ClusterListener(tuya_cluster) + + # Mock datetime + origdatetime = datetime.datetime + datetime.datetime = MockDatetime + + # simulate a SET_TIME message + hdr, args = tuya_cluster.deserialize(ZCL_TUYA_SET_TIME) + assert hdr.command_id == TUYA_SET_TIME + + with mock.patch.object( + TuyaAttributesCluster, "command" + ) as m1: # tuya_cluster parent class (because of super() call) + tuya_cluster.handle_message(hdr, args) + + assert len(cluster_listener.cluster_commands) == 1 + assert cluster_listener.cluster_commands[0][1] == TUYA_SET_TIME + + m1.assert_called_once_with( + TUYA_SET_TIME, [0, 0, 28, 32, 0, 0, 14, 16], expect_reply=False + ) + + # restore datetime + datetime.datetime = origdatetime # restore datetime + + @pytest.mark.parametrize( "quirk", (zhaquirks.tuya.ts0601_dimmer.TuyaDoubleSwitchDimmer,) ) @@ -66,6 +104,12 @@ async def test_tuya_methods(zigpy_device_from_quirk, quirk): tcd_switch1_on = TuyaClusterData( endpoint_id=1, cluster_attr="on_off", attr_value=1, expect_reply=True ) + tcd_dimmer2_on = TuyaClusterData( + endpoint_id=2, cluster_attr="on_off", attr_value=1, expect_reply=True + ) + tcd_dimmer2_level = TuyaClusterData( + endpoint_id=2, cluster_attr="current_level", attr_value=75, expect_reply=True + ) result_1 = tuya_cluster.from_cluster_data(tcd_1) assert result_1 @@ -92,10 +136,18 @@ async def test_tuya_methods(zigpy_device_from_quirk, quirk): m1.assert_called_once_with(tcd_switch1_on) assert rsp.status == foundation.Status.SUCCESS + assert m1.call_count == 1 rsp = await switch1_cluster.command(0x0004) - m1.assert_called_once_with(tcd_switch1_on) # no extra calls assert rsp.status == foundation.Status.UNSUP_CLUSTER_COMMAND + assert m1.call_count == 1 + + # test `move_to_level_with_on_off` quirk (call on_off + current_level) + rsp = await dimmer2_cluster.command(0x0004, 75, 1) + assert rsp.status == foundation.Status.SUCCESS + m1.assert_any_call(tcd_dimmer2_on) # on_off + m1.assert_called_with(tcd_dimmer2_level) # current_level + assert m1.call_count == 3 async def test_tuya_mcu_classes(): From 4e5836174e10ca3c51efd1f5abcc28b4f2e680d9 Mon Sep 17 00:00:00 2001 From: javicalle Date: Sat, 10 Sep 2022 15:20:12 +0200 Subject: [PATCH 3/8] Fix common.py checks --- tests/common.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/common.py b/tests/common.py index d5e087d2ab..16fe56b4b7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,7 +1,6 @@ """Quirks common helpers.""" import datetime - ZCL_IAS_MOTION_COMMAND = b"\t!\x00\x01\x00\x00\x00\x00\x00" ZCL_OCC_ATTR_RPT_OCC = b"\x18d\n\x00\x00\x18\x01" @@ -37,4 +36,4 @@ def now(cls): def utcnow(cls): """Return testvalue.""" - return cls(1970, 1, 1, 2, 0, 0) \ No newline at end of file + return cls(1970, 1, 1, 2, 0, 0) From 33bed1ba47972230ecd34f980075098f3f8d09a9 Mon Sep 17 00:00:00 2001 From: javicalle Date: Sat, 10 Sep 2022 22:05:42 +0200 Subject: [PATCH 4/8] WTH with test coverage --- tests/test_tuya_mcu.py | 18 +++++++++--------- zhaquirks/tuya/mcu/__init__.py | 34 +++++++++++++++++----------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/test_tuya_mcu.py b/tests/test_tuya_mcu.py index b7ffe67c86..a242136ce9 100644 --- a/tests/test_tuya_mcu.py +++ b/tests/test_tuya_mcu.py @@ -71,17 +71,17 @@ async def test_tuya_mcu_set_time(zigpy_device_from_quirk, quirk): hdr, args = tuya_cluster.deserialize(ZCL_TUYA_SET_TIME) assert hdr.command_id == TUYA_SET_TIME - with mock.patch.object( - TuyaAttributesCluster, "command" - ) as m1: # tuya_cluster parent class (because of super() call) - tuya_cluster.handle_message(hdr, args) + # with mock.patch.object( + # TuyaAttributesCluster, "command" + # ) as m1: # tuya_cluster parent class (because of super() call) + # tuya_cluster.handle_message(hdr, args) - assert len(cluster_listener.cluster_commands) == 1 - assert cluster_listener.cluster_commands[0][1] == TUYA_SET_TIME + # assert len(cluster_listener.cluster_commands) == 1 + # assert cluster_listener.cluster_commands[0][1] == TUYA_SET_TIME - m1.assert_called_once_with( - TUYA_SET_TIME, [0, 0, 28, 32, 0, 0, 14, 16], expect_reply=False - ) + # m1.assert_called_once_with( + # TUYA_SET_TIME, [0, 0, 28, 32, 0, 0, 14, 16], expect_reply=False + # ) # restore datetime datetime.datetime = origdatetime # restore datetime diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py index 5d7d5c8386..629f119b89 100644 --- a/zhaquirks/tuya/mcu/__init__.py +++ b/zhaquirks/tuya/mcu/__init__.py @@ -271,30 +271,30 @@ def handle_mcu_version_response(self, payload: MCUVersion) -> foundation.Status: self.update_attribute("mcu_version", payload.version) return foundation.Status.SUCCESS - def handle_set_time_request(self, payload: t.uint16_t) -> foundation.Status: - """Handle set_time requests (0x24).""" + # def handle_set_time_request(self, payload: t.uint16_t) -> foundation.Status: + # """Handle set_time requests (0x24).""" - payload_rsp = TuyaTimePayload() + # payload_rsp = TuyaTimePayload() - utc_now = datetime.datetime.utcnow() - now = datetime.datetime.now() + # utc_now = datetime.datetime.utcnow() + # now = datetime.datetime.now() - offset_time = datetime.datetime(self.set_time_offset, 1, 1) - offset_time_local = datetime.datetime( - self.set_time_local_offset or self.set_time_offset, 1, 1 - ) + # offset_time = datetime.datetime(self.set_time_offset, 1, 1) + # offset_time_local = datetime.datetime( + # self.set_time_local_offset or self.set_time_offset, 1, 1 + # ) - utc_timestamp = int((utc_now - offset_time).total_seconds()) - local_timestamp = int((now - offset_time_local).total_seconds()) + # utc_timestamp = int((utc_now - offset_time).total_seconds()) + # local_timestamp = int((now - offset_time_local).total_seconds()) - payload_rsp.extend(utc_timestamp.to_bytes(4, "big", signed=False)) - payload_rsp.extend(local_timestamp.to_bytes(4, "big", signed=False)) + # payload_rsp.extend(utc_timestamp.to_bytes(4, "big", signed=False)) + # payload_rsp.extend(local_timestamp.to_bytes(4, "big", signed=False)) - self.create_catching_task( - super().command(TUYA_SET_TIME, payload_rsp, expect_reply=False) - ) + # self.create_catching_task( + # super().command(TUYA_SET_TIME, payload_rsp, expect_reply=False) + # ) - return foundation.Status.SUCCESS + # return foundation.Status.SUCCESS class TuyaOnOff(OnOff, TuyaLocalCluster): From b0f6ab882e0ee2f532ae30e1265d20fc9d5c9279 Mon Sep 17 00:00:00 2001 From: javicalle Date: Sat, 10 Sep 2022 22:10:05 +0200 Subject: [PATCH 5/8] WTH with test coverage --- tests/test_tuya_mcu.py | 18 +++++++++--------- zhaquirks/tuya/mcu/__init__.py | 34 +++++++++++++++++----------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/test_tuya_mcu.py b/tests/test_tuya_mcu.py index a242136ce9..b7ffe67c86 100644 --- a/tests/test_tuya_mcu.py +++ b/tests/test_tuya_mcu.py @@ -71,17 +71,17 @@ async def test_tuya_mcu_set_time(zigpy_device_from_quirk, quirk): hdr, args = tuya_cluster.deserialize(ZCL_TUYA_SET_TIME) assert hdr.command_id == TUYA_SET_TIME - # with mock.patch.object( - # TuyaAttributesCluster, "command" - # ) as m1: # tuya_cluster parent class (because of super() call) - # tuya_cluster.handle_message(hdr, args) + with mock.patch.object( + TuyaAttributesCluster, "command" + ) as m1: # tuya_cluster parent class (because of super() call) + tuya_cluster.handle_message(hdr, args) - # assert len(cluster_listener.cluster_commands) == 1 - # assert cluster_listener.cluster_commands[0][1] == TUYA_SET_TIME + assert len(cluster_listener.cluster_commands) == 1 + assert cluster_listener.cluster_commands[0][1] == TUYA_SET_TIME - # m1.assert_called_once_with( - # TUYA_SET_TIME, [0, 0, 28, 32, 0, 0, 14, 16], expect_reply=False - # ) + m1.assert_called_once_with( + TUYA_SET_TIME, [0, 0, 28, 32, 0, 0, 14, 16], expect_reply=False + ) # restore datetime datetime.datetime = origdatetime # restore datetime diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py index 629f119b89..5d7d5c8386 100644 --- a/zhaquirks/tuya/mcu/__init__.py +++ b/zhaquirks/tuya/mcu/__init__.py @@ -271,30 +271,30 @@ def handle_mcu_version_response(self, payload: MCUVersion) -> foundation.Status: self.update_attribute("mcu_version", payload.version) return foundation.Status.SUCCESS - # def handle_set_time_request(self, payload: t.uint16_t) -> foundation.Status: - # """Handle set_time requests (0x24).""" + def handle_set_time_request(self, payload: t.uint16_t) -> foundation.Status: + """Handle set_time requests (0x24).""" - # payload_rsp = TuyaTimePayload() + payload_rsp = TuyaTimePayload() - # utc_now = datetime.datetime.utcnow() - # now = datetime.datetime.now() + utc_now = datetime.datetime.utcnow() + now = datetime.datetime.now() - # offset_time = datetime.datetime(self.set_time_offset, 1, 1) - # offset_time_local = datetime.datetime( - # self.set_time_local_offset or self.set_time_offset, 1, 1 - # ) + offset_time = datetime.datetime(self.set_time_offset, 1, 1) + offset_time_local = datetime.datetime( + self.set_time_local_offset or self.set_time_offset, 1, 1 + ) - # utc_timestamp = int((utc_now - offset_time).total_seconds()) - # local_timestamp = int((now - offset_time_local).total_seconds()) + utc_timestamp = int((utc_now - offset_time).total_seconds()) + local_timestamp = int((now - offset_time_local).total_seconds()) - # payload_rsp.extend(utc_timestamp.to_bytes(4, "big", signed=False)) - # payload_rsp.extend(local_timestamp.to_bytes(4, "big", signed=False)) + payload_rsp.extend(utc_timestamp.to_bytes(4, "big", signed=False)) + payload_rsp.extend(local_timestamp.to_bytes(4, "big", signed=False)) - # self.create_catching_task( - # super().command(TUYA_SET_TIME, payload_rsp, expect_reply=False) - # ) + self.create_catching_task( + super().command(TUYA_SET_TIME, payload_rsp, expect_reply=False) + ) - # return foundation.Status.SUCCESS + return foundation.Status.SUCCESS class TuyaOnOff(OnOff, TuyaLocalCluster): From 55cf4507e1ca5fbd7a728153ed06013f6b22339e Mon Sep 17 00:00:00 2001 From: javicalle Date: Sat, 10 Sep 2022 23:15:26 +0200 Subject: [PATCH 6/8] WTH with test coverage --- tests/test_tuya_mcu.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_tuya_mcu.py b/tests/test_tuya_mcu.py index b7ffe67c86..6c073975d7 100644 --- a/tests/test_tuya_mcu.py +++ b/tests/test_tuya_mcu.py @@ -139,6 +139,7 @@ async def test_tuya_methods(zigpy_device_from_quirk, quirk): assert m1.call_count == 1 rsp = await switch1_cluster.command(0x0004) + m1.assert_called_once_with(tcd_switch1_on) # no extra calls assert rsp.status == foundation.Status.UNSUP_CLUSTER_COMMAND assert m1.call_count == 1 From c593c62d923eb144bdbd6517733039a2a7f2a456 Mon Sep 17 00:00:00 2001 From: javicalle Date: Sat, 10 Sep 2022 23:35:06 +0200 Subject: [PATCH 7/8] WTH with test coverage --- tests/test_tuya_mcu.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_tuya_mcu.py b/tests/test_tuya_mcu.py index 6c073975d7..af57e36a2c 100644 --- a/tests/test_tuya_mcu.py +++ b/tests/test_tuya_mcu.py @@ -47,6 +47,17 @@ async def test_tuya_version(zigpy_device_from_quirk, quirk): assert cluster_listener.attribute_updates[0][0] == ATTR_MCU_VERSION assert cluster_listener.attribute_updates[0][1] == "2.0.2" + + with mock.patch.object(tuya_cluster, "handle_mcu_version_response") as m1: + tuya_cluster.handle_message(hdr, args) + + assert len(cluster_listener.cluster_commands) == 2 + assert cluster_listener.cluster_commands[1][1] == TUYA_MCU_VERSION_RSP + assert cluster_listener.cluster_commands[1][2].version.version_raw == 130 + assert cluster_listener.cluster_commands[1][2].version.version == "2.0.2" + + m1.assert_called_once_with(tuya_cluster.MCUVersion(status=1, tsn=109, version_raw=130)) + # read 'mcu_version' from cluster's attributes succ, fail = await tuya_cluster.read_attributes(("mcu_version",)) assert succ["mcu_version"] == "2.0.2" From 83ef4df4c2fd9a8f476132b3cfbf953be4fdef82 Mon Sep 17 00:00:00 2001 From: javicalle Date: Sat, 10 Sep 2022 23:40:02 +0200 Subject: [PATCH 8/8] WTH with test coverage --- tests/test_tuya_mcu.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_tuya_mcu.py b/tests/test_tuya_mcu.py index af57e36a2c..fc79c74f2b 100644 --- a/tests/test_tuya_mcu.py +++ b/tests/test_tuya_mcu.py @@ -47,7 +47,6 @@ async def test_tuya_version(zigpy_device_from_quirk, quirk): assert cluster_listener.attribute_updates[0][0] == ATTR_MCU_VERSION assert cluster_listener.attribute_updates[0][1] == "2.0.2" - with mock.patch.object(tuya_cluster, "handle_mcu_version_response") as m1: tuya_cluster.handle_message(hdr, args) @@ -56,7 +55,9 @@ async def test_tuya_version(zigpy_device_from_quirk, quirk): assert cluster_listener.cluster_commands[1][2].version.version_raw == 130 assert cluster_listener.cluster_commands[1][2].version.version == "2.0.2" - m1.assert_called_once_with(tuya_cluster.MCUVersion(status=1, tsn=109, version_raw=130)) + m1.assert_called_once_with( + tuya_cluster.MCUVersion(status=1, tsn=109, version_raw=130) + ) # read 'mcu_version' from cluster's attributes succ, fail = await tuya_cluster.read_attributes(("mcu_version",))