diff --git a/tests/test_schneiderelectric.py b/tests/test_schneiderelectric.py new file mode 100644 index 0000000000..d35cdfb888 --- /dev/null +++ b/tests/test_schneiderelectric.py @@ -0,0 +1,134 @@ +"""Tests for Schneider Electric devices.""" +from unittest import mock + +from zigpy.zcl import foundation +from zigpy.zcl.clusters.closures import WindowCovering + +import zhaquirks.schneiderelectric.shutters + +from tests.common import ClusterListener + +zhaquirks.setup() + + +def test_1gang_shutter_1_signature(assert_signature_matches_quirk): + signature = { + "node_descriptor": ( + "NodeDescriptor(logical_type=, " + "complex_descriptor_available=0, user_descriptor_available=0, reserved=0, " + "aps_flags=0, frequency_band=, " + "mac_capability_flags=, manufacturer_code=4190, " + "maximum_buffer_size=82, maximum_incoming_transfer_size=82, " + "server_mask=10752, maximum_outgoing_transfer_size=82, " + "descriptor_capability_field=, " + "*allocate_address=True, *is_alternate_pan_coordinator=False, " + "*is_coordinator=False, *is_end_device=False, " + "*is_full_function_device=True, *is_mains_powered=True, " + "*is_receiver_on_when_idle=True, *is_router=True, " + "*is_security_capable=False)" + ), + "endpoints": { + "5": { + "profile_id": 0x0104, + "device_type": "0x0202", + "in_clusters": [ + "0x0000", + "0x0003", + "0x0004", + "0x0005", + "0x0102", + "0x0b05", + ], + "out_clusters": ["0x0019"], + }, + "21": { + "profile_id": 0x0104, + "device_type": "0x0104", + "in_clusters": [ + "0x0000", + "0x0003", + "0x0b05", + "0xff17", + ], + "out_clusters": [ + "0x0003", + "0x0005", + "0x0006", + "0x0008", + "0x0019", + "0x0102", + ], + }, + }, + "manufacturer": "Schneider Electric", + "model": "1GANG/SHUTTER/1", + "class": "zigpy.device.Device", + } + assert_signature_matches_quirk( + zhaquirks.schneiderelectric.shutters.OneGangShutter1, signature + ) + + +async def test_1gang_shutter_1_go_to_lift_percentage_cmd(zigpy_device_from_quirk): + """Asserts that the go_to_lift_percentage command inverts the percentage value.""" + + device = zigpy_device_from_quirk( + zhaquirks.schneiderelectric.shutters.OneGangShutter1 + ) + window_covering_cluster = device.endpoints[5].window_covering + + p = mock.patch.object(window_covering_cluster, "request", mock.AsyncMock()) + with p as request_mock: + request_mock.return_value = (foundation.Status.SUCCESS, "done") + + await window_covering_cluster.go_to_lift_percentage(58) + + assert request_mock.call_count == 1 + assert request_mock.call_args[0][1] == ( + WindowCovering.ServerCommandDefs.go_to_lift_percentage.id + ) + assert request_mock.call_args[0][3] == 42 # 100 - 58 + + +async def test_1gang_shutter_1_unpatched_cmd(zigpy_device_from_quirk): + """Asserts that unpatched ZCL commands keep working.""" + + device = zigpy_device_from_quirk( + zhaquirks.schneiderelectric.shutters.OneGangShutter1 + ) + window_covering_cluster = device.endpoints[5].window_covering + + p = mock.patch.object(window_covering_cluster, "request", mock.AsyncMock()) + with p as request_mock: + request_mock.return_value = (foundation.Status.SUCCESS, "done") + + await window_covering_cluster.up_open() + + assert request_mock.call_count == 1 + assert request_mock.call_args[0][1] == ( + WindowCovering.ServerCommandDefs.up_open.id + ) + + +async def test_1gang_shutter_1_lift_percentage_updates(zigpy_device_from_quirk): + """Asserts that updates to the ``current_position_lift_percentage`` attribute + (e.g., by the device) invert the reported percentage value.""" + + device = zigpy_device_from_quirk( + zhaquirks.schneiderelectric.shutters.OneGangShutter1 + ) + window_covering_cluster = device.endpoints[5].window_covering + cluster_listener = ClusterListener(window_covering_cluster) + + window_covering_cluster.update_attribute( + WindowCovering.AttributeDefs.current_position_lift_percentage.id, + 77, + ) + + assert len(cluster_listener.attribute_updates) == 1 + assert cluster_listener.attribute_updates[0] == ( + WindowCovering.AttributeDefs.current_position_lift_percentage.id, + 23, # 100 - 77 + ) + assert len(cluster_listener.cluster_commands) == 0 diff --git a/zhaquirks/schneiderelectric/__init__.py b/zhaquirks/schneiderelectric/__init__.py new file mode 100644 index 0000000000..c239ee063e --- /dev/null +++ b/zhaquirks/schneiderelectric/__init__.py @@ -0,0 +1,189 @@ +"""Quirks implementations for Schneider Electric devices.""" +from typing import Any, Coroutine, Final, Union + +from zigpy import types as t +from zigpy.quirks import CustomCluster +from zigpy.zcl import foundation +from zigpy.zcl.clusters.closures import WindowCovering +from zigpy.zcl.clusters.general import Basic +from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef + +SE_MANUF_NAME = "Schneider Electric" +SE_MANUF_ID = 4190 + + +class SEBasic(CustomCluster, Basic): + """Schneider Electric manufacturer specific Basic cluster.""" + + class AttributeDefs(Basic.AttributeDefs): + se_sw_build_id: Final = ZCLAttributeDef( + id=0xE001, + type=t.CharacterString, + is_manufacturer_specific=True, + ) # value: "002.004.016 R" + unknown_attribute_57346: Final = ZCLAttributeDef( + id=0xE002, + type=t.CharacterString, + is_manufacturer_specific=True, + ) # value: "001.000.000" + unknown_attribute_57348: Final = ZCLAttributeDef( + id=0xE004, + type=t.CharacterString, + is_manufacturer_specific=True, + ) # value: "213249FEFF5ECFD" + unknown_attribute_57351: Final = ZCLAttributeDef( + id=0xE007, + type=t.enum16, + is_manufacturer_specific=True, + ) + se_device_type: Final = ZCLAttributeDef( + id=0xE008, + type=t.CharacterString, + is_manufacturer_specific=True, + ) # value: "Wiser Light" + se_model: Final = ZCLAttributeDef( + id=0xE009, + type=t.CharacterString, + is_manufacturer_specific=True, + ) # value: "NHPB/SHUTTER/1" + se_realm: Final = ZCLAttributeDef( + id=0xE00A, + type=t.CharacterString, + is_manufacturer_specific=True, + ) # value: "Wiser Home" + unknown_attribute_57355: Final = ZCLAttributeDef( + id=0xE00B, + type=t.CharacterString, + is_manufacturer_specific=True, + ) # value: "http://www.schneider-electric.com" + + +class SEWindowCovering(CustomCluster, WindowCovering): + """Schneider Electric manufacturer specific Window Covering cluster.""" + + class AttributeDefs(WindowCovering.AttributeDefs): + unknown_attribute_65533: Final = ZCLAttributeDef( + id=0xFFFD, + type=t.uint16_t, + is_manufacturer_specific=True, + ) + lift_duration: Final = ZCLAttributeDef( + id=0xE000, + type=t.uint16_t, + is_manufacturer_specific=True, + ) + unknown_attribute_57360: Final = ZCLAttributeDef( + id=0xE010, + type=t.bitmap8, + is_manufacturer_specific=True, + ) + unknown_attribute_57362: Final = ZCLAttributeDef( + id=0xE012, + type=t.uint16_t, + is_manufacturer_specific=True, + ) + unknown_attribute_57363: Final = ZCLAttributeDef( + id=0xE013, + type=t.bitmap8, + is_manufacturer_specific=True, + ) + unknown_attribute_57364: Final = ZCLAttributeDef( + id=0xE014, + type=t.uint16_t, + is_manufacturer_specific=True, + ) + unknown_attribute_57365: Final = ZCLAttributeDef( + id=0xE015, + type=t.uint16_t, + is_manufacturer_specific=True, + ) + unknown_attribute_57366: Final = ZCLAttributeDef( + id=0xE016, + type=t.uint16_t, + is_manufacturer_specific=True, + ) + unknown_attribute_57367: Final = ZCLAttributeDef( + id=0xE017, + type=t.uint8_t, + is_manufacturer_specific=True, + ) + + def _update_attribute(self, attrid: Union[int, t.uint16_t], value: Any): + if attrid == WindowCovering.AttributeDefs.current_position_lift_percentage.id: + # Invert the percentage value + value = 100 - value + super()._update_attribute(attrid, value) + + async def command( + self, + command_id: Union[foundation.GeneralCommand, int, t.uint8_t], + *args: Any, + **kwargs: Any, + ) -> Coroutine: + command = self.server_commands[command_id] + + # Override default command to invert percent lift value. + if command.id == WindowCovering.ServerCommandDefs.go_to_lift_percentage.id: + percent = args[0] + percent = 100 - percent + return await super().command(command_id, percent, **kwargs) + + return await super().command(command_id, *args, **kwargs) + + +class SELedIndicatorSignals(t.enum8): + """Available LED indicator signal combinations. + + Shutter movement can be indicated with a red LED signal. A green LED + light permanently provides orientation, if desired. + """ + + MOVEMENT_ONLY = 0x00 + MOVEMENT_AND_ORIENTATION = 0x01 + ORIENTATION_ONLY = 0x02 + NONE = 0x03 + + +class SESpecific(CustomCluster): + """Schneider Electric manufacturer specific cluster.""" + + name = "Schneider Electric Manufacturer Specific" + ep_attribute = "schneider_electric_manufacturer" + cluster_id = 0xFF17 + + class AttributeDefs(BaseAttributeDefs): + led_indicator_signals: Final = ZCLAttributeDef( + id=0x0000, + type=SELedIndicatorSignals, + is_manufacturer_specific=True, + ) + unknown_attribute_1: Final = ZCLAttributeDef( + id=0x0001, + type=t.enum8, + is_manufacturer_specific=True, + ) + unknown_attribute_16: Final = ZCLAttributeDef( + id=0x0010, + type=t.uint8_t, + is_manufacturer_specific=True, + ) + unknown_attribute_17: Final = ZCLAttributeDef( + id=0x0011, + type=t.uint16_t, + is_manufacturer_specific=True, + ) + unknown_attribute_32: Final = ZCLAttributeDef( + id=0x0020, + type=t.uint8_t, + is_manufacturer_specific=True, + ) + unknown_attribute_33: Final = ZCLAttributeDef( + id=0x0021, + type=t.uint16_t, + is_manufacturer_specific=True, + ) + unknown_attribute_65533: Final = ZCLAttributeDef( + id=0xFFFD, + type=t.uint16_t, + is_manufacturer_specific=True, + ) diff --git a/zhaquirks/schneiderelectric/shutters.py b/zhaquirks/schneiderelectric/shutters.py new file mode 100644 index 0000000000..6ff345f848 --- /dev/null +++ b/zhaquirks/schneiderelectric/shutters.py @@ -0,0 +1,116 @@ +"""Quirks for Schneider Electric shutters.""" +from zigpy.profiles import zha +from zigpy.quirks import CustomDevice +from zigpy.zcl.clusters.closures import WindowCovering +from zigpy.zcl.clusters.general import ( + Basic, + Groups, + Identify, + LevelControl, + OnOff, + Ota, + Scenes, +) +from zigpy.zcl.clusters.homeautomation import Diagnostic + +from zhaquirks.const import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + MODELS_INFO, + OUTPUT_CLUSTERS, + PROFILE_ID, +) +from zhaquirks.schneiderelectric import ( + SE_MANUF_NAME, + SEBasic, + SESpecific, + SEWindowCovering, +) + + +class OneGangShutter1(CustomDevice): + """1GANG/SHUTTER/1 from Schneider Electric.""" + + signature = { + MODELS_INFO: [ + (SE_MANUF_NAME, "1GANG/SHUTTER/1"), + ], + ENDPOINTS: { + # + 5: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + WindowCovering.cluster_id, + Diagnostic.cluster_id, + ], + OUTPUT_CLUSTERS: [Ota.cluster_id], + }, + # + 21: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.DIMMER_SWITCH, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Diagnostic.cluster_id, + SESpecific.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + WindowCovering.cluster_id, + ], + }, + }, + } + + replacement = { + ENDPOINTS: { + 5: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE, + INPUT_CLUSTERS: [ + SEBasic, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + SEWindowCovering, + Diagnostic.cluster_id, + ], + OUTPUT_CLUSTERS: [Ota.cluster_id], + }, + 21: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.DIMMER_SWITCH, + INPUT_CLUSTERS: [ + SEBasic, + Identify.cluster_id, + Diagnostic.cluster_id, + SESpecific, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + SEWindowCovering, + ], + }, + } + }