diff --git a/tests/test_sinope.py b/tests/test_sinope.py index 1cba91274f..0680d45163 100644 --- a/tests/test_sinope.py +++ b/tests/test_sinope.py @@ -1,16 +1,32 @@ """Tests for Sinope.""" +from unittest import mock + import pytest +from zigpy.device import Device +import zigpy.types as t +from zigpy.zcl import foundation from zigpy.zcl.clusters.general import DeviceTemperature from zigpy.zcl.clusters.measurement import FlowMeasurement from tests.common import ClusterListener -import zhaquirks.sinope.switch +import zhaquirks +from zhaquirks.const import COMMAND_BUTTON_DOUBLE, COMMAND_BUTTON_HOLD +from zhaquirks.sinope import SINOPE_MANUFACTURER_CLUSTER_ID +from zhaquirks.sinope.light import ( + SinopeTechnologieslight, + SinopeTechnologiesManufacturerCluster, +) +from zhaquirks.sinope.switch import SinopeTechnologiesCalypso, SinopeTechnologiesValveG2 zhaquirks.setup() +ButtonAction = SinopeTechnologiesManufacturerCluster.Action + +SINOPE_MANUFACTURER_ID = 4508 # 0x119C -@pytest.mark.parametrize("quirk", (zhaquirks.sinope.switch.SinopeTechnologiesCalypso,)) + +@pytest.mark.parametrize("quirk", (SinopeTechnologiesCalypso,)) async def test_sinope_device_temp(zigpy_device_from_quirk, quirk): """Test that device temperature is multiplied.""" device = zigpy_device_from_quirk(quirk) @@ -33,7 +49,7 @@ async def test_sinope_device_temp(zigpy_device_from_quirk, quirk): assert dev_temp_listener.attribute_updates[1][1] == 25 # not modified -@pytest.mark.parametrize("quirk", (zhaquirks.sinope.switch.SinopeTechnologiesValveG2,)) +@pytest.mark.parametrize("quirk", (SinopeTechnologiesValveG2,)) async def test_sinope_flow_measurement(zigpy_device_from_quirk, quirk): """Test that flow measurement measured value is divided.""" device = zigpy_device_from_quirk(quirk) @@ -57,3 +73,133 @@ async def test_sinope_flow_measurement(zigpy_device_from_quirk, quirk): == flow_measurement_other_attr_id ) assert flow_measurement_listener.attribute_updates[1][1] == 25 # not modified + + +def _get_packet_data( + command: foundation.GeneralCommand, + attr: foundation.Attribute | None = None, + dirc: foundation.Direction = foundation.Direction.Server_to_Client, +) -> bytes: + hdr = foundation.ZCLHeader.general( + 1, command, SINOPE_MANUFACTURER_ID, dirc + ).serialize() + if attr is not None: + cmd = foundation.GENERAL_COMMANDS[command].schema([attr]).serialize() + else: + cmd = b"" + return t.SerializableBytes(hdr + cmd).serialize() + + +@pytest.mark.parametrize("quirk", (SinopeTechnologieslight,)) +@pytest.mark.parametrize( + "press_type,exp_event", + ( + (ButtonAction.Single_off, None), + (ButtonAction.Single_on, None), + (ButtonAction.Double_on, COMMAND_BUTTON_DOUBLE), + (ButtonAction.Double_off, COMMAND_BUTTON_DOUBLE), + (ButtonAction.Long_on, COMMAND_BUTTON_HOLD), + (ButtonAction.Long_off, COMMAND_BUTTON_HOLD), + # Should gracefully handle broken actions. + (t.uint8_t(0x00), None), + ), +) +async def test_sinope_light_switch( + zigpy_device_from_quirk, quirk, press_type, exp_event +): + """Test that button presses are sent as events.""" + device: Device = zigpy_device_from_quirk(quirk) + cluster_id = SINOPE_MANUFACTURER_CLUSTER_ID + endpoint_id = 1 + + class Listener: + zha_send_event = mock.MagicMock() + + cluster_listener = Listener() + device.endpoints[endpoint_id].in_clusters[cluster_id].add_listener(cluster_listener) + + attr = foundation.Attribute( + attrid=0x54, # "action_report" attribute + value=foundation.TypeValue( + type=t.enum8(0x30), + value=press_type, + ), + ) + data = _get_packet_data(foundation.GeneralCommand.Report_Attributes, attr) + device.handle_message(260, cluster_id, endpoint_id, endpoint_id, data) + + if exp_event is None: + assert cluster_listener.zha_send_event.call_count == 0 + else: + assert cluster_listener.zha_send_event.call_count == 1 + assert cluster_listener.zha_send_event.call_args == mock.call( + exp_event, + { + "attribute_id": 84, + "attribute_name": "action_report", + "value": press_type.value, + }, + ) + + +@pytest.mark.parametrize("quirk", (SinopeTechnologieslight,)) +async def test_sinope_light_switch_non_action_report(zigpy_device_from_quirk, quirk): + """Test commands not handled by custom handler. + + Make sure that non attribute report commands and attribute reports that don't + concern action_report are passed through to base class. + """ + + device: Device = zigpy_device_from_quirk(quirk) + cluster_id = SINOPE_MANUFACTURER_CLUSTER_ID + endpoint_id = 1 + + class Listener: + zha_send_event = mock.MagicMock() + + cluster_listener = Listener() + device.endpoints[endpoint_id].in_clusters[cluster_id].add_listener(cluster_listener) + + # read attributes general command + data = _get_packet_data(foundation.GeneralCommand.Read_Attributes) + device.handle_message(260, cluster_id, endpoint_id, endpoint_id, data) + # no ZHA events emitted because we only handle Report_Attributes + assert cluster_listener.zha_send_event.call_count == 0 + + # report attributes command, but not "action_report" + attr = foundation.Attribute( + attrid=0x10, # "on_intensity" attribute + value=foundation.TypeValue( + type=t.int16s(0x29), value=t.int16s(50) + ), # 0x29 = t.int16s + ) + data = _get_packet_data(foundation.GeneralCommand.Report_Attributes, attr) + device.handle_message(260, cluster_id, endpoint_id, endpoint_id, data) + # ZHA event emitted because we pass non "action_report" + # reports to the base class handler. + assert cluster_listener.zha_send_event.call_count == 1 + + +@pytest.mark.parametrize("quirk", (SinopeTechnologieslight,)) +async def test_sinope_light_switch_reporting(zigpy_device_from_quirk, quirk): + """Test that configuring reporting for action_report works.""" + device: Device = 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: + request_mock.return_value = (foundation.Status.SUCCESS, "done") + + await manu_cluster.bind() + await manu_cluster.configure_reporting( + SinopeTechnologiesManufacturerCluster.AttributeDefs.action_report.id, + 3600, + 10800, + 1, + ) + + assert len(request_mock.mock_calls) == 1 + assert len(bind_mock.mock_calls) == 1 diff --git a/zhaquirks/sinope/__init__.py b/zhaquirks/sinope/__init__.py index 285b283bc7..1a2bffbb94 100644 --- a/zhaquirks/sinope/__init__.py +++ b/zhaquirks/sinope/__init__.py @@ -23,7 +23,7 @@ SINOPE = "Sinope Technologies" SINOPE_MANUFACTURER_CLUSTER_ID = 0xFF01 -ATTRIBUTE_ACTION = "actionReport" +ATTRIBUTE_ACTION = "action_report" LIGHT_DEVICE_TRIGGERS = { (SHORT_PRESS, TURN_ON): { diff --git a/zhaquirks/sinope/light.py b/zhaquirks/sinope/light.py index 381fedf59e..a95d4b35a9 100644 --- a/zhaquirks/sinope/light.py +++ b/zhaquirks/sinope/light.py @@ -4,9 +4,13 @@ DM2550ZB-G2. """ +import logging +from typing import Any, Final, Optional, Union + import zigpy.profiles.zha as zha_p from zigpy.quirks import CustomCluster, CustomDevice import zigpy.types as t +from zigpy.zcl import foundation from zigpy.zcl.clusters.general import ( Basic, DeviceTemperature, @@ -23,79 +27,194 @@ from zhaquirks import EventableCluster from zhaquirks.const import ( + ATTRIBUTE_ID, + ATTRIBUTE_NAME, + COMMAND_BUTTON_DOUBLE, + COMMAND_BUTTON_HOLD, DEVICE_TYPE, ENDPOINTS, INPUT_CLUSTERS, MODELS_INFO, OUTPUT_CLUSTERS, PROFILE_ID, + VALUE, + ZHA_SEND_EVENT, ) from zhaquirks.sinope import ( + ATTRIBUTE_ACTION, LIGHT_DEVICE_TRIGGERS, SINOPE, SINOPE_MANUFACTURER_CLUSTER_ID, CustomDeviceTemperatureCluster, ) +_LOGGER = logging.getLogger(__name__) + + +class KeypadLock(t.enum8): + """Keypad_lockout values.""" + + Unlocked = 0x00 + Locked = 0x01 + Partial_lock = 0x02 + + +class PhaseControl(t.enum8): + """Phase control value, reverse / forward.""" + + Forward = 0x00 + Reverse = 0x01 + + +class DoubleFull(t.enum8): + """Double click up set full intensity.""" + + Off = 0x00 + On = 0x01 + + +class ButtonAction(t.enum8): + """Action_report values.""" + + Single_on = 0x01 + Single_release_on = 0x02 + Long_on = 0x03 + Double_on = 0x04 + Single_off = 0x11 + Single_release_off = 0x12 + Long_off = 0x13 + Double_off = 0x14 + class SinopeTechnologiesManufacturerCluster(CustomCluster): """SinopeTechnologiesManufacturerCluster manufacturer cluster.""" - class KeypadLock(t.enum8): - """Keypad_lockout values.""" - - Unlocked = 0x00 - Locked = 0x01 - Partial_lock = 0x02 - - class PhaseControl(t.enum8): - """Phase control value, reverse / forward.""" - - Forward = 0x00 - Reverse = 0x01 - - class DoubleFull(t.enum8): - """Double click up set full intensity.""" - - Off = 0x00 - On = 0x01 - - class Action(t.enum8): - """Action_report values.""" - - Single_on = 0x01 - Single_release_on = 0x02 - Long_on = 0x03 - Double_on = 0x04 - Single_off = 0x11 - Single_release_off = 0x12 - Long_off = 0x13 - Double_off = 0x14 - - cluster_id = SINOPE_MANUFACTURER_CLUSTER_ID - name = "Sinopé Technologies Manufacturer specific" - ep_attribute = "sinope_manufacturer_specific" - attributes = { - 0x0002: ("keypad_lockout", KeypadLock, True), - 0x0003: ("firmware_number", t.uint16_t, True), - 0x0004: ("firmware_version", t.CharacterString, True), - 0x0010: ("on_intensity", t.int16s, True), - 0x0050: ("on_led_color", t.uint24_t, True), - 0x0051: ("off_led_color", t.uint24_t, True), - 0x0052: ("on_led_intensity", t.uint8_t, True), - 0x0053: ("off_led_intensity", t.uint8_t, True), - 0x0054: ("action_report", Action, True), - 0x0055: ("min_intensity", t.uint16_t, True), - 0x0056: ("phase_control", PhaseControl, True), - 0x0058: ("double_up_full", DoubleFull, True), - 0x0090: ("current_summation_delivered", t.uint32_t, True), - 0x00A0: ("timer", t.uint32_t, True), - 0x00A1: ("timer_countdown", t.uint32_t, True), - 0x0119: ("connected_load", t.uint16_t, True), - 0x0200: ("status", t.bitmap32, True), - 0xFFFD: ("cluster_revision", t.uint16_t, True), + KeypadLock: Final = KeypadLock + PhaseControl: Final = PhaseControl + DoubleFull: Final = DoubleFull + Action: Final = ButtonAction + + cluster_id: Final[t.uint16_t] = SINOPE_MANUFACTURER_CLUSTER_ID + name: Final = "SinopeTechnologiesManufacturerCluster" + ep_attribute: Final = "sinope_manufacturer_specific" + + class AttributeDefs(foundation.BaseAttributeDefs): + """Sinope Manufacturer Cluster Attributes.""" + + keypad_lockout: Final = foundation.ZCLAttributeDef( + id=0x0002, type=KeypadLock, access="rw", is_manufacturer_specific=True + ) + firmware_number: Final = foundation.ZCLAttributeDef( + id=0x0003, type=t.uint16_t, access="r", is_manufacturer_specific=True + ) + firmware_version: Final = foundation.ZCLAttributeDef( + id=0x0004, type=t.CharacterString, access="r", is_manufacturer_specific=True + ) + on_intensity: Final = foundation.ZCLAttributeDef( + id=0x0010, type=t.int16s, access="rw", is_manufacturer_specific=True + ) + on_led_color: Final = foundation.ZCLAttributeDef( + id=0x0050, type=t.uint24_t, access="rw", is_manufacturer_specific=True + ) + off_led_color: Final = foundation.ZCLAttributeDef( + id=0x0051, type=t.uint24_t, access="rw", is_manufacturer_specific=True + ) + on_led_intensity: Final = foundation.ZCLAttributeDef( + id=0x0052, type=t.uint8_t, access="rw", is_manufacturer_specific=True + ) + off_led_intensity: Final = foundation.ZCLAttributeDef( + id=0x0053, type=t.uint8_t, access="rw", is_manufacturer_specific=True + ) + action_report: Final = foundation.ZCLAttributeDef( + id=0x0054, type=ButtonAction, access="rp", is_manufacturer_specific=True + ) + min_intensity: Final = foundation.ZCLAttributeDef( + id=0x0055, type=t.uint16_t, access="rw", is_manufacturer_specific=True + ) + phase_control: Final = foundation.ZCLAttributeDef( + id=0x0056, type=PhaseControl, access="rw", is_manufacturer_specific=True + ) + double_up_full: Final = foundation.ZCLAttributeDef( + id=0x0058, type=DoubleFull, access="rw", is_manufacturer_specific=True + ) + current_summation_delivered: Final = foundation.ZCLAttributeDef( + id=0x0090, type=t.uint32_t, access="rp", is_manufacturer_specific=True + ) + timer: Final = foundation.ZCLAttributeDef( + id=0x00A0, type=t.uint32_t, access="rw", is_manufacturer_specific=True + ) + timer_countdown: Final = foundation.ZCLAttributeDef( + id=0x00A1, type=t.uint32_t, access="r", is_manufacturer_specific=True + ) + connected_load: Final = foundation.ZCLAttributeDef( + id=0x0119, type=t.uint16_t, access="rw", is_manufacturer_specific=True + ) + status: Final = foundation.ZCLAttributeDef( + id=0x0200, type=t.bitmap32, access="rp", is_manufacturer_specific=True + ) + cluster_revision: Final = foundation.ZCL_CLUSTER_REVISION_ATTR + + server_commands = { + 0x54: foundation.ZCLCommandDef( + "button_press", + {"command": t.uint8_t}, + direction=foundation.Direction.Server_to_Client, + is_manufacturer_specific=True, + ) } + def handle_cluster_general_request( + self, + hdr: foundation.ZCLHeader, + args: list[Any], + *, + dst_addressing: Optional[ + Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK] + ] = None, + ): + """Handle the cluster command.""" + self.debug( + "SINOPE cluster general request: hdr: %s - args: [%s]", + hdr, + args, + ) + + if hdr.command_id != foundation.GeneralCommand.Report_Attributes: + return super().handle_cluster_general_request( + hdr, args, dst_addressing=dst_addressing + ) + + attr = args[0][0] + + if attr.attrid != self.AttributeDefs.action_report.id: + return super().handle_cluster_general_request( + hdr, args, dst_addressing=dst_addressing + ) + + value = attr.value.value + event_args = { + ATTRIBUTE_ID: 84, + ATTRIBUTE_NAME: ATTRIBUTE_ACTION, + VALUE: value.value, + } + action = self._get_command_from_action(self.Action(value)) + if not action: + return + self.listener_event(ZHA_SEND_EVENT, action, event_args) + + def _get_command_from_action(self, action: ButtonAction) -> str | None: + # const lookup = {2: 'up_single', 3: 'up_hold', 4: 'up_double', + # 18: 'down_single', 19: 'down_hold', 20: 'down_double'}; + match action: + case self.Action.Single_off | self.Action.Single_on: + return None + case self.Action.Double_off | self.Action.Double_on: + return COMMAND_BUTTON_DOUBLE + case self.Action.Long_off | self.Action.Long_on: + return COMMAND_BUTTON_HOLD + case _: + return None + class LightManufacturerCluster(EventableCluster, SinopeTechnologiesManufacturerCluster): """LightManufacturerCluster: fire events corresponding to press type."""