Repository: zigpy/zha-device-handlers
Pull request #3457 (2024-10-25 10:05:56)

Fix Nimly smart lock mains-powered capability
## Proposed change
Add nimly smart lock quirk
Clears the mains powered mac capability flag since lock is battery powered.

Note that after quirk is applied, ZHA reports that my lock has 20% remaining battery, but when I query PowerConfiguration/battery_percentage_remaining attribute it reports 41%.
Not sure where ZHA gets 20% from.

See picture below:
<img src="https://github.com/user-attachments/assets/84baf505-61c3-4f03-b67d-56a9830c856a" width="400">

## Additional information
Tested on NimlyPro model.

Ref
#3095
#2354

## 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/3457?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 89.41%. Comparing base [(`de3643c`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/de3643c917b550fc75687835f49159eca47bdf1b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`fa05ca4`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/fa05ca47b0c52a49adb546ff834eec8994106d37?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    #3457   +/-   ##
=======================================
  Coverage   89.40%   89.41%           
=======================================
  Files         309      311    +2     
  Lines       10014    10020    +6     
=======================================
+ Hits         8953     8959    +6     
  Misses       1061     1061           
```

</details>

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

uvNikita: I didn't add tests since I'm not sure how to test quirks v2, e.g. `assert_signature_matches_quirk`. Also not sure how to test node descriptor and power source specifically.

So it would be great with some direction on this if tests are required :)

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 37fd9eb..1bb1568 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -12,12 +12,12 @@ repos:
         args: ["--toml", "pyproject.toml"]
 
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.11.2
+    rev: v1.12.1
     hooks:
       - id: mypy
   
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.6.9
+    rev: v0.7.0
     hooks:
       - id: ruff
         args: ["--fix", "--exit-non-zero-on-fix", "--config", "pyproject.toml"]
diff --git a/pyproject.toml b/pyproject.toml
index eac3ebe..3404b7c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,7 +14,7 @@ readme = "README.md"
 license = {text = "Apache License Version 2.0"}
 requires-python = ">=3.12"
 dependencies = [
-    "zigpy>=0.68.1",
+    "zigpy>=0.70.0",
 ]
 
 [tool.setuptools.packages.find]
diff --git a/zhaquirks/nimly/__init__.py b/zhaquirks/nimly/__init__.py
new file mode 100644
index 0000000..425e60d
--- /dev/null
+++ b/zhaquirks/nimly/__init__.py
@@ -0,0 +1,3 @@
+"""Nimly module."""
+
+NIMLY = "Onesti Products AS"
diff --git a/zhaquirks/nimly/lock.py b/zhaquirks/nimly/lock.py
new file mode 100644
index 0000000..2f3100f
--- /dev/null
+++ b/zhaquirks/nimly/lock.py
@@ -0,0 +1,36 @@
+"""Device handler for Nimly Smart Locks."""
+
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.zdo.types import NodeDescriptor
+
+from zhaquirks.nimly import NIMLY
+
+# clears the mains powered mac capability flag
+NIMLY_LOCK_NODE_DESCRIPTOR = NodeDescriptor(
+    logical_type=2,
+    complex_descriptor_available=0,
+    user_descriptor_available=0,
+    reserved=0,
+    aps_flags=0,
+    frequency_band=8,
+    manufacturer_code=4660,
+    maximum_buffer_size=108,
+    maximum_incoming_transfer_size=127,
+    server_mask=11264,
+    maximum_outgoing_transfer_size=127,
+    descriptor_capability_field=0,
+    mac_capability_flags=NodeDescriptor.MACCapabilityFlags.AllocateAddress
+    | NodeDescriptor.MACCapabilityFlags.RxOnWhenIdle,
+)
+
+
+(
+    QuirkBuilder(NIMLY, "NimlyPRO")
+    .also_applies_to(NIMLY, "NimlyCode")
+    .also_applies_to(NIMLY, "NimlyTouch")
+    .also_applies_to(NIMLY, "NimlyIn")
+    .also_applies_to(NIMLY, "EasyFingerTouch")
+    .also_applies_to(NIMLY, "EasyCodeTouch")
+    .node_descriptor(NIMLY_LOCK_NODE_DESCRIPTOR)
+    .add_to_registry()
+)
diff --git a/zhaquirks/philips/__init__.py b/zhaquirks/philips/__init__.py
index 8833bfe..e5d0231 100644
--- a/zhaquirks/philips/__init__.py
+++ b/zhaquirks/philips/__init__.py
@@ -69,11 +69,9 @@ class PhilipsBasicCluster(CustomCluster, Basic):
 
     attr_config = {AttributeDefs.philips.id: 0x000B}
 
-    async def bind(self):
-        """Bind cluster."""
-        result = await super().bind()
+    async def apply_custom_configuration(self, *args, **kwargs):
+        """Apply custom configuration."""
         await self.write_attributes(self.attr_config, manufacturer=0x100B)
-        return result
 
 
 class ButtonPressQueue:
diff --git a/zhaquirks/philips/soc001.py b/zhaquirks/philips/soc001.py
index 66ef3a1..d51b65b 100644
--- a/zhaquirks/philips/soc001.py
+++ b/zhaquirks/philips/soc001.py
@@ -51,7 +51,6 @@ class PhilipsContactCluster(CustomCluster):
         endpoint_id=2,
         device_class=BinarySensorDeviceClass.TAMPER,
         entity_type=EntityType.DIAGNOSTIC,
-        translation_key="tamper",
         fallback_name="Tamper",
     )
     .add_to_registry()
diff --git a/zhaquirks/sinope/__init__.py b/zhaquirks/sinope/__init__.py
index 1a2bffb..ff59f31 100644
--- a/zhaquirks/sinope/__init__.py
+++ b/zhaquirks/sinope/__init__.py
@@ -1,21 +1,25 @@
 """Module for Sinope quirks implementations."""
 
 from zigpy.quirks import CustomCluster
+import zigpy.types as t
 from zigpy.zcl.clusters.general import DeviceTemperature
 
 from zhaquirks.const import (
     ARGS,
     ATTRIBUTE_ID,
     ATTRIBUTE_NAME,
+    BUTTON,
     CLUSTER_ID,
     COMMAND,
-    COMMAND_BUTTON_DOUBLE,
-    COMMAND_BUTTON_HOLD,
-    COMMAND_BUTTON_SINGLE,
+    COMMAND_M_INITIAL_PRESS,
+    COMMAND_M_LONG_RELEASE,
+    COMMAND_M_MULTI_PRESS_COMPLETE,
+    COMMAND_M_SHORT_RELEASE,
     DOUBLE_PRESS,
     ENDPOINT_ID,
     LONG_PRESS,
     SHORT_PRESS,
+    SHORT_RELEASE,
     TURN_OFF,
     TURN_ON,
     VALUE,
@@ -25,42 +29,108 @@ SINOPE = "Sinope Technologies"
 SINOPE_MANUFACTURER_CLUSTER_ID = 0xFF01
 ATTRIBUTE_ACTION = "action_report"
 
+
+class ButtonAction(t.enum8):
+    """Action_report values."""
+
+    Pressed_on = 0x01
+    Released_on = 0x02
+    Long_on = 0x03
+    Double_on = 0x04
+    Pressed_off = 0x11
+    Released_off = 0x12
+    Long_off = 0x13
+    Double_off = 0x14
+
+
 LIGHT_DEVICE_TRIGGERS = {
     (SHORT_PRESS, TURN_ON): {
         ENDPOINT_ID: 1,
         CLUSTER_ID: 65281,
-        COMMAND: COMMAND_BUTTON_SINGLE,
-        ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 2},
+        COMMAND: COMMAND_M_INITIAL_PRESS,
+        ARGS: {
+            ATTRIBUTE_ID: 84,
+            ATTRIBUTE_NAME: ATTRIBUTE_ACTION,
+            BUTTON: TURN_ON,
+            VALUE: ButtonAction.Pressed_on,
+        },
     },
     (SHORT_PRESS, TURN_OFF): {
         ENDPOINT_ID: 1,
         CLUSTER_ID: 65281,
-        COMMAND: COMMAND_BUTTON_SINGLE,
-        ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 18},
+        COMMAND: COMMAND_M_INITIAL_PRESS,
+        ARGS: {
+            ATTRIBUTE_ID: 84,
+            ATTRIBUTE_NAME: ATTRIBUTE_ACTION,
+            BUTTON: TURN_OFF,
+            VALUE: ButtonAction.Pressed_off,
+        },
+    },
+    (SHORT_RELEASE, TURN_ON): {
+        ENDPOINT_ID: 1,
+        CLUSTER_ID: 65281,
+        COMMAND: COMMAND_M_SHORT_RELEASE,
+        ARGS: {
+            ATTRIBUTE_ID: 84,
+            ATTRIBUTE_NAME: ATTRIBUTE_ACTION,
+            BUTTON: TURN_ON,
+            VALUE: ButtonAction.Released_on,
+        },
+    },
+    (SHORT_RELEASE, TURN_OFF): {
+        ENDPOINT_ID: 1,
+        CLUSTER_ID: 65281,
+        COMMAND: COMMAND_M_SHORT_RELEASE,
+        ARGS: {
+            ATTRIBUTE_ID: 84,
+            ATTRIBUTE_NAME: ATTRIBUTE_ACTION,
+            BUTTON: TURN_OFF,
+            VALUE: ButtonAction.Released_off,
+        },
     },
     (DOUBLE_PRESS, TURN_ON): {
         ENDPOINT_ID: 1,
         CLUSTER_ID: 65281,
-        COMMAND: COMMAND_BUTTON_DOUBLE,
-        ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 4},
+        COMMAND: COMMAND_M_MULTI_PRESS_COMPLETE,
+        ARGS: {
+            ATTRIBUTE_ID: 84,
+            ATTRIBUTE_NAME: ATTRIBUTE_ACTION,
+            BUTTON: TURN_ON,
+            VALUE: ButtonAction.Double_on,
+        },
     },
     (DOUBLE_PRESS, TURN_OFF): {
         ENDPOINT_ID: 1,
         CLUSTER_ID: 65281,
-        COMMAND: COMMAND_BUTTON_DOUBLE,
-        ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 20},
+        COMMAND: COMMAND_M_MULTI_PRESS_COMPLETE,
+        ARGS: {
+            ATTRIBUTE_ID: 84,
+            ATTRIBUTE_NAME: ATTRIBUTE_ACTION,
+            BUTTON: TURN_OFF,
+            VALUE: ButtonAction.Double_off,
+        },
     },
     (LONG_PRESS, TURN_ON): {
         ENDPOINT_ID: 1,
         CLUSTER_ID: 65281,
-        COMMAND: COMMAND_BUTTON_HOLD,
-        ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 3},
+        COMMAND: COMMAND_M_LONG_RELEASE,
+        ARGS: {
+            ATTRIBUTE_ID: 84,
+            ATTRIBUTE_NAME: ATTRIBUTE_ACTION,
+            BUTTON: TURN_ON,
+            VALUE: ButtonAction.Long_on,
+        },
     },
     (LONG_PRESS, TURN_OFF): {
         ENDPOINT_ID: 1,
         CLUSTER_ID: 65281,
-        COMMAND: COMMAND_BUTTON_HOLD,
-        ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 19},
+        COMMAND: COMMAND_M_LONG_RELEASE,
+        ARGS: {
+            ATTRIBUTE_ID: 84,
+            ATTRIBUTE_NAME: ATTRIBUTE_ACTION,
+            BUTTON: TURN_OFF,
+            VALUE: ButtonAction.Long_off,
+        },
     },
 }
 
diff --git a/zhaquirks/sinope/light.py b/zhaquirks/sinope/light.py
index a95d4b3..328315f 100644
--- a/zhaquirks/sinope/light.py
+++ b/zhaquirks/sinope/light.py
@@ -29,14 +29,20 @@ from zhaquirks import EventableCluster
 from zhaquirks.const import (
     ATTRIBUTE_ID,
     ATTRIBUTE_NAME,
-    COMMAND_BUTTON_DOUBLE,
-    COMMAND_BUTTON_HOLD,
+    BUTTON,
+    COMMAND_M_INITIAL_PRESS,
+    COMMAND_M_LONG_RELEASE,
+    COMMAND_M_MULTI_PRESS_COMPLETE,
+    COMMAND_M_SHORT_RELEASE,
+    DESCRIPTION,
     DEVICE_TYPE,
     ENDPOINTS,
     INPUT_CLUSTERS,
     MODELS_INFO,
     OUTPUT_CLUSTERS,
     PROFILE_ID,
+    TURN_OFF,
+    TURN_ON,
     VALUE,
     ZHA_SEND_EVENT,
 )
@@ -45,6 +51,7 @@ from zhaquirks.sinope import (
     LIGHT_DEVICE_TRIGGERS,
     SINOPE,
     SINOPE_MANUFACTURER_CLUSTER_ID,
+    ButtonAction,
     CustomDeviceTemperatureCluster,
 )
 
@@ -73,19 +80,6 @@ class DoubleFull(t.enum8):
     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."""
 
@@ -191,29 +185,53 @@ class SinopeTechnologiesManufacturerCluster(CustomCluster):
                 hdr, args, dst_addressing=dst_addressing
             )
 
-        value = attr.value.value
+        action = self.Action(attr.value.value)
+
+        command, button = self._get_command_from_action(action)
+        if not command or not button:
+            return
+
         event_args = {
             ATTRIBUTE_ID: 84,
             ATTRIBUTE_NAME: ATTRIBUTE_ACTION,
-            VALUE: value.value,
+            BUTTON: button,
+            DESCRIPTION: action.name,
+            VALUE: action.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'};
+        self.debug(
+            "SINOPE ZHA_SEND_EVENT command: '%s' event_args: %s",
+            command,
+            event_args,
+        )
+
+        self.listener_event(ZHA_SEND_EVENT, command, event_args)
+
+    def _get_command_from_action(
+        self, action: ButtonAction
+    ) -> tuple[str | None, str | None]:
+        # const lookup = {1: 'up_single', 2: 'up_single_released', 3: 'up_hold', 4: 'up_double',
+        #             17: 'down_single, 18: 'down_single_released', 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 self.Action.Pressed_off:
+                return COMMAND_M_INITIAL_PRESS, TURN_OFF
+            case self.Action.Pressed_on:
+                return COMMAND_M_INITIAL_PRESS, TURN_ON
+            case self.Action.Released_off:
+                return COMMAND_M_SHORT_RELEASE, TURN_OFF
+            case self.Action.Released_on:
+                return COMMAND_M_SHORT_RELEASE, TURN_ON
+            case self.Action.Double_off:
+                return COMMAND_M_MULTI_PRESS_COMPLETE, TURN_OFF
+            case self.Action.Double_on:
+                return COMMAND_M_MULTI_PRESS_COMPLETE, TURN_ON
+            case self.Action.Long_off:
+                return COMMAND_M_LONG_RELEASE, TURN_OFF
+            case self.Action.Long_on:
+                return COMMAND_M_LONG_RELEASE, TURN_ON
             case _:
-                return None
+                self.debug("SINOPE unhandled action: %s", action)
+                return None, None
 
 
 class LightManufacturerCluster(EventableCluster, SinopeTechnologiesManufacturerCluster):


Test patch:
diff --git a/requirements_test.txt b/requirements_test.txt
index 0480358..0159e5f 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -13,5 +13,5 @@ pytest-sugar
 pytest-timeout
 pytest-asyncio
 pytest>=7.1.3
-zigpy>=0.69
+zigpy>=0.70
 ruff==0.0.261
diff --git a/tests/test_sinope.py b/tests/test_sinope.py
index 0680d45..3f377a8 100644
--- a/tests/test_sinope.py
+++ b/tests/test_sinope.py
@@ -11,7 +11,14 @@ from zigpy.zcl.clusters.measurement import FlowMeasurement
 
 from tests.common import ClusterListener
 import zhaquirks
-from zhaquirks.const import COMMAND_BUTTON_DOUBLE, COMMAND_BUTTON_HOLD
+from zhaquirks.const import (
+    COMMAND_M_INITIAL_PRESS,
+    COMMAND_M_LONG_RELEASE,
+    COMMAND_M_MULTI_PRESS_COMPLETE,
+    COMMAND_M_SHORT_RELEASE,
+    TURN_OFF,
+    TURN_ON,
+)
 from zhaquirks.sinope import SINOPE_MANUFACTURER_CLUSTER_ID
 from zhaquirks.sinope.light import (
     SinopeTechnologieslight,
@@ -92,20 +99,22 @@ def _get_packet_data(
 
 @pytest.mark.parametrize("quirk", (SinopeTechnologieslight,))
 @pytest.mark.parametrize(
-    "press_type,exp_event",
+    "press_type,button,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),
+        (ButtonAction.Pressed_off, TURN_OFF, COMMAND_M_INITIAL_PRESS),
+        (ButtonAction.Pressed_on, TURN_ON, COMMAND_M_INITIAL_PRESS),
+        (ButtonAction.Released_off, TURN_OFF, COMMAND_M_SHORT_RELEASE),
+        (ButtonAction.Released_on, TURN_ON, COMMAND_M_SHORT_RELEASE),
+        (ButtonAction.Double_on, TURN_ON, COMMAND_M_MULTI_PRESS_COMPLETE),
+        (ButtonAction.Double_off, TURN_OFF, COMMAND_M_MULTI_PRESS_COMPLETE),
+        (ButtonAction.Long_on, TURN_ON, COMMAND_M_LONG_RELEASE),
+        (ButtonAction.Long_off, TURN_OFF, COMMAND_M_LONG_RELEASE),
         # Should gracefully handle broken actions.
-        (t.uint8_t(0x00), None),
+        (t.uint8_t(0x00), None, None),
     ),
 )
 async def test_sinope_light_switch(
-    zigpy_device_from_quirk, quirk, press_type, exp_event
+    zigpy_device_from_quirk, quirk, press_type, button, exp_event
 ):
     """Test that button presses are sent as events."""
     device: Device = zigpy_device_from_quirk(quirk)
@@ -126,7 +135,16 @@ async def test_sinope_light_switch(
         ),
     )
     data = _get_packet_data(foundation.GeneralCommand.Report_Attributes, attr)
-    device.handle_message(260, cluster_id, endpoint_id, endpoint_id, data)
+
+    device.packet_received(
+        t.ZigbeePacket(
+            profile_id=260,
+            cluster_id=cluster_id,
+            src_ep=endpoint_id,
+            dst_ep=endpoint_id,
+            data=t.SerializableBytes(data),
+        )
+    )
 
     if exp_event is None:
         assert cluster_listener.zha_send_event.call_count == 0
@@ -137,6 +155,8 @@ async def test_sinope_light_switch(
             {
                 "attribute_id": 84,
                 "attribute_name": "action_report",
+                "button": button,
+                "description": press_type.name,
                 "value": press_type.value,
             },
         )
@@ -162,7 +182,15 @@ async def test_sinope_light_switch_non_action_report(zigpy_device_from_quirk, qu
 
     # read attributes general command
     data = _get_packet_data(foundation.GeneralCommand.Read_Attributes)
-    device.handle_message(260, cluster_id, endpoint_id, endpoint_id, data)
+    device.packet_received(
+        t.ZigbeePacket(
+            profile_id=260,
+            cluster_id=cluster_id,
+            src_ep=endpoint_id,
+            dst_ep=endpoint_id,
+            data=t.SerializableBytes(data),
+        )
+    )
     # no ZHA events emitted because we only handle Report_Attributes
     assert cluster_listener.zha_send_event.call_count == 0
 
@@ -174,7 +202,15 @@ async def test_sinope_light_switch_non_action_report(zigpy_device_from_quirk, qu
         ),  # 0x29 = t.int16s
     )
     data = _get_packet_data(foundation.GeneralCommand.Report_Attributes, attr)
-    device.handle_message(260, cluster_id, endpoint_id, endpoint_id, data)
+    device.packet_received(
+        t.ZigbeePacket(
+            profile_id=260,
+            cluster_id=cluster_id,
+            src_ep=endpoint_id,
+            dst_ep=endpoint_id,
+            data=t.SerializableBytes(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