Repository: zigpy/zha-device-handlers
Pull request #2955 (2024-01-30 18:03:23)

Add quirk for Schneider Electric NHROTARY/DIMMER/1
## Proposed change

Adds support for Schneider Electric NHROTARY/DIMMER/1.  The main missing piece of functionality is documented in issue #2798.

## Additional information

Builds on #2928, I will rebase this PR once that PR is merged.  I didn't want to blindly cherry pick and squash changes from the other PR so that merging them remains easy.

Asking for help from @julienhd to help validate this PR.

## 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
- [x] Tests have been added to verify that the new code works


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2955?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:
> Comparison is base [(`729bffd`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/729bffd15d61cc12fb13c81cbdec7f99515cfc68?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 87.58% compared to head [(`5026cc3`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2955?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 87.77%.
> Report is 11 commits behind head on dev.


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


```diff
@@            Coverage Diff             @@
##              dev    #2955      +/-   ##
==========================================
+ Coverage   87.58%   87.77%   +0.19%     
==========================================
  Files         294      301       +7     
  Lines        9010     9154     +144     
==========================================
+ Hits         7891     8035     +144     
  Misses       1119     1119              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2955?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).

TheJulianJES: - https://github.com/zigpy/zha-device-handlers/pull/2928 is merged now
dflemstr: Rebased onto upstream `dev` now
dflemstr: I think it is good to merge, since this change only exposes new attributes and doesn't change any existing behavior. I would like to test the integration with other HA components on top of this new attribute, and it would be nice to get this merged so I don't have to maintain my own local HA quirk
TheJulianJES: The quirk will likely only land in Home Assistant Core 2024.3.0 (betas and newer versions) due to the current release schedule/procedure.
TheJulianJES: The wiki can probably be also updated: https://github.com/zigpy/zha-device-handlers/wiki/Schneider-Electric

Patch:
diff --git a/zhaquirks/schneiderelectric/dimmers.py b/zhaquirks/schneiderelectric/dimmers.py
new file mode 100644
index 0000000..32e2303
--- /dev/null
+++ b/zhaquirks/schneiderelectric/dimmers.py
@@ -0,0 +1,155 @@
+from typing import Final
+
+from zigpy.profiles import zgp, zha
+from zigpy.quirks import CustomCluster, CustomDevice
+import zigpy.types as t
+from zigpy.zcl.clusters.closures import WindowCovering
+from zigpy.zcl.clusters.general import (
+    Basic,
+    GreenPowerProxy,
+    Groups,
+    Identify,
+    LevelControl,
+    OnOff,
+    Ota,
+    Scenes,
+)
+from zigpy.zcl.clusters.homeautomation import Diagnostic
+from zigpy.zcl.clusters.lighting import Ballast
+from zigpy.zcl.foundation import ZCLAttributeDef
+
+from zhaquirks.const import (
+    DEVICE_TYPE,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PROFILE_ID,
+)
+from zhaquirks.schneiderelectric import SE_MANUF_ID, SE_MANUF_NAME, SEBasic, SESpecific
+
+
+class ControlMode(t.enum8):
+    """Dimming mode for PUCK/DIMMER/* and NHROTARY/DIMMER/1"""
+
+    Auto = 0
+    RC = 1
+    RL = 2
+    RL_LED = 3
+
+
+class SEBallast(CustomCluster, Ballast):
+    manufacturer_id_override = SE_MANUF_ID
+
+    class AttributeDefs(Ballast.AttributeDefs):
+        control_mode: Final = ZCLAttributeDef(
+            id=0xE000, type=ControlMode, is_manufacturer_specific=True
+        )
+        unknown_attribute_e001: Final = ZCLAttributeDef(
+            id=0xE002, type=t.enum8, is_manufacturer_specific=True
+        )
+        unknown_attribute_e002: Final = ZCLAttributeDef(
+            id=0xE002, type=t.enum8, is_manufacturer_specific=True
+        )
+
+
+class NHRotaryDimmer1(CustomDevice):
+    """NHROTARY/DIMMER/1 by Schneider Electric"""
+
+    signature = {
+        MODELS_INFO: [
+            (SE_MANUF_NAME, "NHROTARY/DIMMER/1"),
+        ],
+        ENDPOINTS: {
+            3: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.DIMMABLE_LIGHT,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Ballast.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,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    WindowCovering.cluster_id,
+                ],
+            },
+            242: {
+                PROFILE_ID: zgp.PROFILE_ID,
+                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
+                INPUT_CLUSTERS: [],
+                OUTPUT_CLUSTERS: [
+                    GreenPowerProxy.cluster_id,
+                ],
+            },
+        },
+    }
+    replacement = {
+        ENDPOINTS: {
+            3: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.DIMMABLE_LIGHT,
+                INPUT_CLUSTERS: [
+                    SEBasic,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    SEBallast,
+                    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,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    WindowCovering.cluster_id,
+                ],
+            },
+            242: {
+                PROFILE_ID: zgp.PROFILE_ID,
+                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
+                INPUT_CLUSTERS: [],
+                OUTPUT_CLUSTERS: [
+                    GreenPowerProxy.cluster_id,
+                ],
+            },
+        }
+    }


Test patch:
diff --git a/tests/test_schneiderelectric.py b/tests/test_schneiderelectric.py
index d35cdfb..e1b7743 100644
--- a/tests/test_schneiderelectric.py
+++ b/tests/test_schneiderelectric.py
@@ -4,6 +4,7 @@ from unittest import mock
 from zigpy.zcl import foundation
 from zigpy.zcl.clusters.closures import WindowCovering
 
+import zhaquirks.schneiderelectric.dimmers
 import zhaquirks.schneiderelectric.shutters
 
 from tests.common import ClusterListener
@@ -132,3 +133,61 @@ async def test_1gang_shutter_1_lift_percentage_updates(zigpy_device_from_quirk):
         23,  # 100 - 77
     )
     assert len(cluster_listener.cluster_commands) == 0
+
+
+def test_nh_rotary_dimmer_1_signature(assert_signature_matches_quirk):
+    signature = {
+        "node_descriptor": (
+            "NodeDescriptor(logical_type=<LogicalType.Router: 1>, complex_descriptor_available=0, "
+            "user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, "
+            "mac_capability_flags=<MACCapabilityFlags.FullFunctionDevice|MainsPowered|RxOnWhenIdle|"
+            "AllocateAddress: 142>, manufacturer_code=4190, maximum_buffer_size=82, maximum_incoming_transfer_size=82, "
+            "server_mask=11264, maximum_outgoing_transfer_size=82, "
+            "descriptor_capability_field=<DescriptorCapability.NONE: 0>, *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": {
+            "3": {
+                "profile_id": 0x0104,
+                "device_type": "0x0101",
+                "in_clusters": [
+                    "0x0000",
+                    "0x0003",
+                    "0x0004",
+                    "0x0005",
+                    "0x0006",
+                    "0x0008",
+                    "0x0301",
+                    "0x0b05",
+                ],
+                "out_clusters": ["0x0019"],
+            },
+            "21": {
+                "profile_id": 0x0104,
+                "device_type": "0x0104",
+                "in_clusters": ["0x0000", "0x0003", "0x0b05", "0xff17"],
+                "out_clusters": [
+                    "0x0003",
+                    "0x0004",
+                    "0x0005",
+                    "0x0006",
+                    "0x0008",
+                    "0x0102",
+                ],
+            },
+            "242": {
+                "profile_id": 0xA1E0,
+                "device_type": "0x0061",
+                "in_clusters": [],
+                "out_clusters": ["0x0021"],
+            },
+        },
+        "manufacturer": "Schneider Electric",
+        "model": "NHROTARY/DIMMER/1",
+        "class": "zigpy.device.Device",
+    }
+    assert_signature_matches_quirk(
+        zhaquirks.schneiderelectric.dimmers.NHRotaryDimmer1, signature
+    )