Repository: zigpy/zha-device-handlers
Pull request #3798 (2025-01-28 19:18:18)

Add pilot wire mode enum select for nodon and legrand
## Proposed change

Following https://github.com/zigpy/zha-device-handlers/pull/3364 and https://github.com/zigpy/zha-device-handlers/pull/3031
Add pilot wire mode enum select for Nodon and Legrand devices. Device mode is also added to Legrand to switch between wire pilot mode and switch mode.

I discussed with @TheJulianJES and having the select entity is better than nothing and in the current state, the quirk is unusable because the modes was not exposed to HA.

## Additional information
<!--
  Please include any additional information that is important to this PR.
  For example, if this PR is a potentially breaking change, mention that here.
  If this PR requires other PRs to be merged in HA Core or other projects, mention that.
  Lastly, if this PR fixes a specific issue, please include "Fixes #xxxx".
-->


## Checklist
<!--
  Put an 'x' in all boxes that apply.
  Note: You do not need to tick all boxes before creating a PR.
-->

- [x] The changes are tested and work correctly
- [x] `pre-commit` checks pass / the code has been formatted using Black
- [ ] Tests have been added to verify that the new code works


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3798?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 90.91%. Comparing base [(`ab4e4f5`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/ab4e4f5e1b3184aa73caf8f4e7e87a1f4ad9576c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`184567a`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/184567a933d706ffa2a7b91bf1f79ffdeb414984?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##              dev    #3798   +/-   ##
=======================================
  Coverage   90.91%   90.91%           
=======================================
  Files         324      324           
  Lines       10562    10562           
=======================================
  Hits         9602     9602           
  Misses        960      960           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3798?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).


Patch:
diff --git a/zhaquirks/legrand/cable_outlet.py b/zhaquirks/legrand/cable_outlet.py
index ffcc421..9e94d80 100644
--- a/zhaquirks/legrand/cable_outlet.py
+++ b/zhaquirks/legrand/cable_outlet.py
@@ -3,7 +3,7 @@
 from typing import Any
 
 from zigpy.quirks import CustomCluster
-from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2 import EntityType, QuirkBuilder
 import zigpy.types as t
 from zigpy.zcl.foundation import (
     BaseAttributeDefs,
@@ -108,5 +108,20 @@ class LegrandCableOutletCluster(CustomCluster):
     QuirkBuilder(f" {LEGRAND}", " Cable outlet")
     .replaces(LegrandCluster)
     .replaces(LegrandCableOutletCluster)
+    .enum(
+        attribute_name=LegrandCluster.AttributeDefs.device_mode.name,
+        cluster_id=LegrandCluster.cluster_id,
+        enum_class=DeviceMode,
+        translation_key="device_mode",
+        fallback_name="Device mode",
+    )
+    .enum(
+        attribute_name=LegrandCableOutletCluster.AttributeDefs.pilot_wire_mode.name,
+        cluster_id=LegrandCableOutletCluster.cluster_id,
+        enum_class=PilotWireMode,
+        translation_key="pilot_wire_mode",
+        fallback_name="Pilot wire mode",
+        entity_type=EntityType.STANDARD,
+    )
     .add_to_registry()
 )
diff --git a/zhaquirks/nodon/pilot_wire.py b/zhaquirks/nodon/pilot_wire.py
index c05ebe9..a48a472 100644
--- a/zhaquirks/nodon/pilot_wire.py
+++ b/zhaquirks/nodon/pilot_wire.py
@@ -1,7 +1,7 @@
 """NodOn pilot wire heating module."""
 
 from zigpy.quirks import CustomCluster
-from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2 import EntityType, QuirkBuilder
 import zigpy.types as t
 from zigpy.zcl.foundation import BaseAttributeDefs, DataTypeId, ZCLAttributeDef
 
@@ -56,6 +56,14 @@ class AdeoPilotWireCluster(NodOnPilotWireCluster):
 nodon = (
     QuirkBuilder(NODON, "SIN-4-FP-21")
     .replaces(NodOnPilotWireCluster)
+    .enum(
+        attribute_name=NodOnPilotWireCluster.AttributeDefs.pilot_wire_mode.name,
+        enum_class=NodOnPilotWireMode,
+        cluster_id=NodOnPilotWireCluster.cluster_id,
+        entity_type=EntityType.STANDARD,
+        translation_key="pilot_wire_mode",
+        fallback_name="Pilot wire mode",
+    )
 )  # fmt: skip
 
 
diff --git a/zhaquirks/tuya/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index fbcb50f..e8b40dc 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -87,6 +87,14 @@ class TuyaIasFire(IasZone, TuyaLocalCluster):
     }
 
 
+class TuyaIasVibration(IasZone, TuyaLocalCluster):
+    """Tuya local IAS vibration cluster."""
+
+    _CONSTANT_ATTRIBUTES = {
+        IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Vibration_Movement_Sensor
+    }
+
+
 class TuyaPM25Concentration(PM25, TuyaLocalCluster):
     """Tuya PM25 concentration measurement."""
 
@@ -435,6 +443,15 @@ class TuyaQuirkBuilder(QuirkBuilder):
         self.adds(temp_cfg)
         return self
 
+    def tuya_vibration(self, dp_id: int):
+        """Add a Tuya IAS vibration sensor."""
+        self.tuya_ias(
+            dp_id=dp_id,
+            ias_cfg=TuyaIasVibration,
+            converter=lambda x: IasZone.ZoneStatus.Alarm_1 if x != 0 else 0,
+        )
+        return self
+
     def tuya_voc(
         self,
         dp_id: int,
diff --git a/zhaquirks/tuya/ts601_door.py b/zhaquirks/tuya/ts601_door.py
deleted file mode 100644
index 40f6f1c..0000000
--- a/zhaquirks/tuya/ts601_door.py
+++ /dev/null
@@ -1,198 +0,0 @@
-"""Device handler for Tuya Zigbee Vibration Door Sensor.
-
-The device reports:
-- Vibration
-- Open/Closed door/window
-- Remaining battery percentage
-
-Extra details: https://www.aliexpress.com/item/1005004443361928.html?spm=a2g0o.order_list.order_list_main.53.35a81802wiZASs
-"""
-
-from zigpy.profiles import zha
-from zigpy.quirks import CustomCluster, CustomDevice
-from zigpy.zcl.clusters.general import (
-    Basic,
-    Groups,
-    Ota,
-    PowerConfiguration,
-    Scenes,
-    Time,
-)
-from zigpy.zcl.clusters.security import IasZone, ZoneStatus, ZoneType
-
-from zhaquirks.const import (
-    DEVICE_TYPE,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
-    MODELS_INFO,
-    OUTPUT_CLUSTERS,
-    PROFILE_ID,
-    ZONE_STATE,
-)
-from zhaquirks.tuya import (
-    TUYA_CLUSTER_ID,
-    DPToAttributeMapping,
-    TuyaLocalCluster,
-    TuyaNewManufCluster,
-)
-
-DOOR_HANDLE_DP_ID = 1
-BATTERY_STATE_DP_ID = 3
-VIBRATION_DP_ID = 10
-
-DP_HANDLER_EP_ID = 1
-DOOR_HANDLE_EP_ID = 2
-VIBRATION_EP_ID = 3
-
-
-class CustomBasicCluster(CustomCluster, Basic):
-    """Custom Basic cluster to report power source."""
-
-    _CONSTANT_ATTRIBUTES = {
-        Basic.AttributeDefs.power_source.id: Basic.PowerSource.Battery
-    }
-
-
-class CustomTuyaPowerConfigurationCluster(TuyaLocalCluster, PowerConfiguration):
-    """Custom Power Configuration cluster that represents battery reports."""
-
-    _CONSTANT_ATTRIBUTES = {
-        PowerConfiguration.AttributeDefs.battery_size.id: PowerConfiguration.BatterySize.AAA,
-        PowerConfiguration.AttributeDefs.battery_quantity.id: 2,
-    }
-
-
-class CustomTuyaContactSwitchCluster(TuyaLocalCluster, IasZone):
-    """Custom IasZone cluster that represents the Open/Closed sensor."""
-
-    _CONSTANT_ATTRIBUTES = {
-        IasZone.AttributeDefs.zone_state.id: ZONE_STATE,
-        IasZone.AttributeDefs.zone_type.id: ZoneType.Contact_Switch,
-    }
-
-
-class CustomTuyaVibrationCluster(TuyaLocalCluster, IasZone):
-    """Custom IasZone cluster that represents the vibration sensor."""
-
-    _CONSTANT_ATTRIBUTES = {
-        IasZone.AttributeDefs.zone_state.id: ZONE_STATE,
-        IasZone.AttributeDefs.zone_type.id: ZoneType.Vibration_Movement_Sensor,
-    }
-
-
-class CustomTuyaDPProcessor(TuyaNewManufCluster):
-    """Custom cluster that translates Tuya's data point reporting.
-
-    Tuya data points are parsed and their values are used to update the
-    specified endpoint's (endpoint_id) cluster's (ep_attribute)
-    attribute (attribute_name).
-    """
-
-    dp_to_attribute: dict[int, DPToAttributeMapping] = {
-        DOOR_HANDLE_DP_ID: DPToAttributeMapping(
-            endpoint_id=DOOR_HANDLE_EP_ID,
-            ep_attribute=IasZone.ep_attribute,
-            attribute_name=IasZone.AttributeDefs.zone_status.name,
-            converter=lambda x: ZoneStatus.Alarm_1 & x,
-        ),
-        VIBRATION_DP_ID: DPToAttributeMapping(
-            endpoint_id=VIBRATION_EP_ID,
-            ep_attribute=IasZone.ep_attribute,
-            attribute_name=IasZone.AttributeDefs.zone_status.name,
-            converter=lambda x: ZoneStatus.Alarm_1 & x,
-        ),
-        BATTERY_STATE_DP_ID: DPToAttributeMapping(
-            endpoint_id=DP_HANDLER_EP_ID,
-            ep_attribute=PowerConfiguration.ep_attribute,
-            attribute_name=PowerConfiguration.AttributeDefs.battery_percentage_remaining.name,
-            converter=lambda x: 2 * x,
-            # Device measures battery in 1% steps, while the ZCL specifies it in 0.5% steps
-        ),
-    }
-
-    data_point_handlers = {
-        DOOR_HANDLE_DP_ID: "_dp_2_attr_update",
-        VIBRATION_DP_ID: "_dp_2_attr_update",
-        BATTERY_STATE_DP_ID: "_dp_2_attr_update",
-    }
-
-
-class TS0601Door(CustomDevice):
-    """Quirk for Tuya Zigbee Vibration Door Sensor.
-
-    The device reports:
-    - Vibration
-    - Open/closed door/window
-    - Remaining battery percentage
-
-    The original device has a single endpoint that corresponds to the Tuya
-    manufacturer specific cluster. The replacement has 3 endpoints, where
-        - the first is responsible for handling the Tuya data point reports,
-        - the second represents the open/closed sensor,
-        - and the third represents the vibration sensor.
-    The latter two are both IasZone cluster extensions, that's why they had to
-    be put into different endpoints (otherwise we would have two of the same
-    cluster for an endpoint, which is prohibited by the Zigbee standard).
-
-    Extra details about the physical device: https://www.aliexpress.com/item/1005004443361928.html?spm=a2g0o.order_list.order_list_main.53.35a81802wiZASs
-    """
-
-    signature = {
-        # <SimpleDescriptor endpoint=1 profile=260 device_type=81
-        # device_version=1
-        # input_clusters=[4, 5, 61184, 0]
-        # output_clusters=[25, 10]>
-        MODELS_INFO: [("_TZE200_kzm5w4iz", "TS0601")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    TUYA_CLUSTER_ID,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            },
-        },
-    }
-
-    replacement = {
-        ENDPOINTS: {
-            DP_HANDLER_EP_ID: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
-                INPUT_CLUSTERS: [
-                    CustomBasicCluster,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    CustomTuyaPowerConfigurationCluster,
-                    CustomTuyaDPProcessor,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            },
-            DOOR_HANDLE_EP_ID: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.IAS_ZONE,
-                INPUT_CLUSTERS: [
-                    CustomBasicCluster,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    CustomTuyaContactSwitchCluster,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id],
-            },
-            VIBRATION_EP_ID: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.IAS_ZONE,
-                INPUT_CLUSTERS: [
-                    CustomBasicCluster,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    CustomTuyaVibrationCluster,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id],
-            },
-        },
-    }
diff --git a/zhaquirks/tuya/tuay_contact.py b/zhaquirks/tuya/tuay_contact.py
new file mode 100644
index 0000000..f165443
--- /dev/null
+++ b/zhaquirks/tuya/tuay_contact.py
@@ -0,0 +1,24 @@
+"""Tuya contact sensors."""
+
+from zhaquirks.const import BatterySize
+from zhaquirks.tuya.builder import TuyaQuirkBuilder
+
+(
+    TuyaQuirkBuilder("_TZE200_pay2byax", "TS0601")  # Cusam ZG-102ZL
+    .applies_to("_TZE200_n8dljorx", "TS0601")
+    .tuya_illuminance(dp_id=101)
+    .tuya_contact(dp_id=1)
+    .tuya_battery(dp_id=2, battery_type=BatterySize.CR2032, battery_qty=1)
+    .skip_configuration()
+    .add_to_registry()
+)
+
+
+(
+    TuyaQuirkBuilder("_TZE200_kzm5w4iz", "TS0601")
+    .tuya_contact(dp_id=1)
+    .tuya_battery(dp_id=3, battery_type=BatterySize.AAA, battery_qty=2)
+    .tuya_vibration(dp_id=10)
+    .skip_configuration()
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/tuya_sensor.py b/zhaquirks/tuya/tuya_sensor.py
index f63f6b1..dbcec43 100644
--- a/zhaquirks/tuya/tuya_sensor.py
+++ b/zhaquirks/tuya/tuya_sensor.py
@@ -8,7 +8,6 @@ from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass
 import zigpy.types as t
 from zigpy.zcl import foundation
 
-from zhaquirks.const import BatterySize
 from zhaquirks.tuya import (
     TUYA_SET_TIME,
     TuyaPowerConfigurationCluster2AAA,
@@ -302,17 +301,6 @@ class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
 )
 
 
-(
-    TuyaQuirkBuilder("_TZE200_pay2byax", "TS0601")  # Cusam ZG-102ZL
-    .applies_to("_TZE200_n8dljorx", "TS0601")
-    .tuya_illuminance(dp_id=101)
-    .tuya_contact(dp_id=1)
-    .tuya_battery(dp_id=2, battery_type=BatterySize.CR2032, battery_qty=1)
-    .skip_configuration()
-    .add_to_registry()
-)
-
-
 (
     TuyaQuirkBuilder("_TZE204_upagmta9", "TS0601")
     .tuya_temperature(dp_id=1, scale=10)


Test patch:
diff --git a/tests/test_tuya.py b/tests/test_tuya.py
index 7cd634a..9b47fd7 100644
--- a/tests/test_tuya.py
+++ b/tests/test_tuya.py
@@ -36,7 +36,6 @@ import zhaquirks.tuya.ts011f_plug
 import zhaquirks.tuya.ts0501_fan_switch
 import zhaquirks.tuya.ts0601_electric_heating
 import zhaquirks.tuya.ts0601_trv
-import zhaquirks.tuya.ts601_door
 import zhaquirks.tuya.ts1201
 import zhaquirks.tuya.tuya_motion
 import zhaquirks.tuya.tuya_valve
@@ -1955,59 +1954,35 @@ async def test_ts1201_ir_blaster(zigpy_device_from_quirk):
         )
 
 
-def test_ts601_door_sensor_signature(assert_signature_matches_quirk):
-    """Test TS601 Vibration Door Sensor signature against quirk."""
-    signature = {
-        "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.EndDevice: 2>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress: 128>, manufacturer_code=4417, maximum_buffer_size=66, maximum_incoming_transfer_size=66, server_mask=10752, maximum_outgoing_transfer_size=66, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)",
-        "endpoints": {
-            "1": {
-                "profile_id": 260,
-                "device_type": "0x0051",
-                "in_clusters": ["0x0000", "0x0004", "0x0005", "0xef00"],
-                "out_clusters": ["0x000a", "0x0019"],
-            }
-        },
-        "manufacturer": "_TZE200_kzm5w4iz",
-        "model": "TS0601",
-        "class": "zigpy.device.Device",
-    }
-    assert_signature_matches_quirk(zhaquirks.tuya.ts601_door.TS0601Door, signature)
-
-
 @pytest.mark.parametrize(
-    ("data", "endpoint_id", "ep_attr", "attribute", "expected_value"),
+    ("data", "ep_attr", "attribute", "expected_value"),
     [
         (
             b"\t\xfc\x02\x007\x01\x01\x00\x01\x01",
-            zhaquirks.tuya.ts601_door.DOOR_HANDLE_EP_ID,
             IasZone.ep_attribute,
             IasZone.AttributeDefs.zone_status.id,
             ZoneStatus.Alarm_1,
         ),
         (
             b"\t\xfc\x02\x007\x01\x01\x00\x01\x00",
-            zhaquirks.tuya.ts601_door.DOOR_HANDLE_EP_ID,
             IasZone.ep_attribute,
             IasZone.AttributeDefs.zone_status.id,
             0x0000,
         ),
         (
             b"\t\xfc\x02\x007\n\x04\x00\x01\x01",
-            zhaquirks.tuya.ts601_door.VIBRATION_EP_ID,
             IasZone.ep_attribute,
             IasZone.AttributeDefs.zone_status.id,
             ZoneStatus.Alarm_1,
         ),
         (
             b"\t\xfc\x02\x007\n\x04\x00\x01\x00",
-            zhaquirks.tuya.ts601_door.VIBRATION_EP_ID,
             IasZone.ep_attribute,
             IasZone.AttributeDefs.zone_status.id,
             0x0000,
         ),
         (
             b"\to\x02\x00P\x03\x02\x00\x04\x00\x00\x00T",
-            zhaquirks.tuya.ts601_door.DP_HANDLER_EP_ID,
             PowerConfiguration.ep_attribute,
             PowerConfiguration.AttributeDefs.battery_percentage_remaining.id,
             84 * 2,
@@ -2015,7 +1990,7 @@ def test_ts601_door_sensor_signature(assert_signature_matches_quirk):
     ],
 )
 async def test_ts601_door_sensor(
-    zigpy_device_from_quirk, data, endpoint_id, ep_attr, attribute, expected_value
+    zigpy_device_from_v2_quirk, data, ep_attr, attribute, expected_value
 ):
     """Test TS601 Vibration Door Sensor quirk.
 
@@ -2024,25 +1999,22 @@ async def test_ts601_door_sensor(
         - Vibration On/Off
         - Remaining battery percentage
     """
-    device: Device = zigpy_device_from_quirk(zhaquirks.tuya.ts601_door.TS0601Door)
+    device: Device = zigpy_device_from_v2_quirk("_TZE200_kzm5w4iz", "TS0601")
     device._packet_debouncer.filter = mock.MagicMock(return_value=False)
 
-    dp_processor_ep = zhaquirks.tuya.ts601_door.DP_HANDLER_EP_ID
-    cluster = device.endpoints[dp_processor_ep].in_clusters[
-        TuyaNewManufCluster.cluster_id
-    ]
+    cluster = device.endpoints[1].in_clusters[TuyaNewManufCluster.cluster_id]
 
     with mock.patch.object(cluster, "send_default_rsp"):
         device.packet_received(
             t.ZigbeePacket(
                 profile_id=zha.PROFILE_ID,
-                src_ep=dp_processor_ep,
+                src_ep=1,
                 cluster_id=TuyaNewManufCluster.cluster_id,
                 data=t.SerializableBytes(data),
             )
         )
 
-    cluster = getattr(device.endpoints[endpoint_id], ep_attr)
+    cluster = getattr(device.endpoints[1], ep_attr)
     attrs = await cluster.read_attributes(attributes=[attribute])
 
     assert attrs[0].get(attribute) == expected_value
diff --git a/tests/test_tuya_builder.py b/tests/test_tuya_builder.py
index a25fccb..97add16 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -26,6 +26,7 @@ from zhaquirks.tuya.builder import (
     TuyaIasContact,
     TuyaIasFire,
     TuyaIasGas,
+    TuyaIasVibration,
     TuyaIlluminance,
     TuyaPM25Concentration,
     TuyaQuirkBuilder,
@@ -56,6 +57,7 @@ zhaquirks.setup()
         ("tuya_co2", "carbon_dioxide_concentration", TuyaCO2Concentration),
         ("tuya_pm25", "pm25", TuyaPM25Concentration),
         ("tuya_voc", "voc_level", TuyaAirQualityVOC),
+        ("tuya_vibration", "ias_zone", TuyaIasVibration),
         (
             "tuya_formaldehyde",
             "formaldehyde_concentration",