Repository: zigpy/zha-device-handlers
Pull request #2928 (2024-01-21 09:54:47)

Add quirk for Schneider Electric 1GANG/SHUTTER/1 (MEG5113-0300)
## Proposed change

Add a quirk for Schneider Electric 1GANG/SHUTTER/1 (MEG5113-0300) to

* correct the lift percentage (which is inverted).
* add some manufacturer-specific features.


## Additional information

This PR is based on @axellebot’s work in #1705. As the latter is incomplete, I have removed the code of which I don’t know whether it works.

I have successfully tested the quirk with a real device connected to Home Assistant via ZHA.


## Checklist

- [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/2928?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
Attention: `1 lines` in your changes are missing coverage. Please review.
> 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 [(`5257939`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2928?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 87.63%.

| [Files](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2928?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | Patch % | Lines |
|---|---|---|
| [zhaquirks/schneiderelectric/clusters/\_\_init\_\_.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2928?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3NjaG5laWRlcmVsZWN0cmljL2NsdXN0ZXJzL19faW5pdF9fLnB5) | 97.05% | [1 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2928?src=pr&el=tree&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    #2928      +/-   ##
==========================================
+ Coverage   87.58%   87.63%   +0.05%     
==========================================
  Files         294      297       +3     
  Lines        9010     9057      +47     
==========================================
+ Hits         7891     7937      +46     
- Misses       1119     1120       +1     
```



</details>

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

cspurk: Thanks for the positive review, @TheJulianJES!

Fair enough, I’ve removed the README. Is there maybe a better place to put it? I suppose @axellebot’s idea behind the file was to have something similar to Zigbee2MQTT’s devices DB, just for zigpy. I thought about a wiki, but zha-device-handlers doesn’t have one.

Regarding home-assistant/core#108238: I can certainly test my device with/without the quirk once this has landed in a HA release. I doubt, though, that the new inversion logic will help here, as it mostly seems to be based on reversing the motor direction. I had tested that manually before implementing the quirk, and it just resulted in a swapping of the up/down push buttons – interestingly both on the wall and in the HA GUI. The opened/closed states in HA were still wrong.
MattWestb: I think device / vendor things is the best place in the Wiki https://github.com/zigpy/zigpy/wiki.
TheJulianJES: > I thought about a wiki, but zha-device-handlers doesn’t have one.

I think putting quirks-related stuff in a quirks wiki would be good.
TheJulianJES: Also, to structure it like the rest of our repo, I'd get rid of the `devices` and `clusters` folders/package.
Just have `zhaquirks/schneiderelectric` and put the shared code/clusters/constants in `__init__.py`.
The `shutters.py` can go into that folder/package too.
TheJulianJES: > Should I maybe just create an initial structure for a zigpy/zha-device-handlers wiki?

Yup, I think that's fine.
cspurk: > > Should I maybe just create an initial structure for a zigpy/zha-device-handlers wiki?
> 
> Yup, I think that's fine.

Ok, I’ve created a [wiki home page](https://github.com/zigpy/zha-device-handlers/wiki) and added a Schneider-specific page with the deleted “README” (mostly as in a4d596c8e70fb951478c935ca93d3b48789d16da).
cspurk: @TheJulianJES, as promised, I’ve tested HA 2024.2.0 (and hence home-assistant/core#108238) with and without the quirk from this PR:

- Without the quirk, the lift percentage is still inverted (just like before).
- The quirk continues to fix this (just like before).
- I can see the new invert switch in the GUI (with and without the quirk), however, it behaves like I described in a previous comment (and which is not useful for this Schneider Electric device).

Patch:
diff --git a/zhaquirks/schneiderelectric/__init__.py b/zhaquirks/schneiderelectric/__init__.py
new file mode 100644
index 0000000..c239ee0
--- /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 0000000..6ff345f
--- /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: {
+            # <SimpleDescriptor endpoint=5, profile=260, device_type=514,
+            # device_version=0,
+            # input_clusters=[0, 3, 4, 5, 258, 2821],
+            # output_clusters=[25]>
+            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],
+            },
+            # <SimpleDescriptor endpoint=21, profile=260, device_type=260,
+            # device_version=0,
+            # input_clusters=[0, 3, 2821, 65303],
+            # output_clusters=[3, 5, 6, 8, 25, 258]>
+            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,
+                ],
+            },
+        }
+    }


Test patch:
diff --git a/tests/test_schneiderelectric.py b/tests/test_schneiderelectric.py
new file mode 100644
index 0000000..d35cdfb
--- /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=<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=10752, 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": {
+            "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