Repository: zigpy/zha-device-handlers
Pull request #3400 (2024-10-06 19:35:13)

Add support for QUAD-ZIG-SW from smarthjemmet.dk
## Proposed change
Add support for smarthjemmet.dk's QUAD-ZIG-SW device.


## Additional information
Works with V1 and V2 firmware.

## 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/3400?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
Attention: Patch coverage is `82.45614%` with `10 lines` in your changes missing coverage. Please review.
> Project coverage is 88.69%. Comparing base [(`7816838`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/781683848b43d1d100c82e34c90056a35b2fb090?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`1170d36`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/1170d3649604238135b59665929141b0fff45806?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

| [Files with missing lines](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3400?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | Patch % | Lines |
|---|---|---|
| [zhaquirks/smarthjemmet/quadzigsw.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3400?src=pr&el=tree&filepath=zhaquirks%2Fsmarthjemmet%2Fquadzigsw.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3NtYXJ0aGplbW1ldC9xdWFkemlnc3cucHk=) | 82.45% | [10 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3400?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    #3400      +/-   ##
==========================================
- Coverage   88.72%   88.69%   -0.04%     
==========================================
  Files         306      307       +1     
  Lines        9813     9870      +57     
==========================================
+ Hits         8707     8754      +47     
- Misses       1106     1116      +10     
```

</details>

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

mortenmoulder: All should be fixed now. Sorry for all the commits.
mortenmoulder: @TheJulianJES I see that a new release was created yesterday, and mine was not included. Any idea why? The only checks failing are code coverage, which shouldn't prevent it from being merged. Was really hoping to get this quirk implemented in the next release, as I'm guessing I have to wait a few more weeks now.
TheJulianJES: There's a few things on this PR that I'd likely change. I just haven't found the time to properly review this PR and comment all small change suggestions. I still plan to do that, of course, but there are also other PRs.
Yesterday's release was just to get some bigger ZHA related changes into nightly HA builds. The quirks related changes were mostly ones that didn't need feedback.
TheJulianJES: Ok, so I've pushed multiple commits now. The "refactor" commit changes the structure of the (so-called v1) quirk to have a  `v1` device class with `signature`, `replacement`, and device triggers.
Then, a class for firmware version 2 is added that inherits from the first device class. It just changes the `signature`.
This is similar to the structure we have in other quirks. 

The last commit (as of now) "Replace with v2 quirk" completely replaces the `CustomDevice` v1 quirks with a v2 quirk.
For it, we don't need to have a fixed `signature` and `replacement`. `.replaces(CR2032PowerConfigurationCluster)` will automatically replace all clusters with the same cluster id on endpoint 1. It's used to replace the default `PowerConfiguration` cluster with the custom `CR2032PowerConfigurationCluster`.

`.replace_cluster_occurrences(CustomMultistateInputCluster)` will look at all endpoints and replace all clusters with the same cluster id (of `MultistateInput`) it can find.

Please test if that v2 quirk works as expected.

---

With the v1 quirk before, the `MultistateInput` cluster was always an output/client cluster in the original signature. In the `replacement`, it was a input/server cluster then. It was also missing on endpoint 1 in the replacement. I don't think that was intentional?

If the replacement `CustomMultistateInputCluster` needs to be an input/server cluster (so the role swapped compared the original signature), we can do it like this in a v2 quirk (but for all endpoints):
```python
    .removes(MultistateInput.cluster_id, cluster_type=ClusterType.Client, endpoint_id=1)  # remove output cluster
    .adds(CustomMultistateInputCluster, endpoint_id=1)  # default cluster_type is server, so it'll add an input cluster
```

(I'll update the tests in a minute)
mortenmoulder: @TheJulianJES Hi and thanks for your effort. Unfortunately it's not working.

When I am talking about V1 and V2 in terms of firmware, it's because I have one physical device with two different firmware versions. The signature of the devices are completely different, which is why I need to be able to support both signatures on the same device. V1 of the firmware came out back in 2023, and now I've updated it in late 2024. Does that make sense?

For all I care, Quirk V1 or Quirk V2 doesn't matter to me. Quirk V2 looks better with the QuirkBuilder - definitely, but unfortunately it's not working as intended in this case.

Regards to your "It was also missing on endpoint 1 in the replacement. I don't think that was intentional" it actually was. The physical device has 5 buttons, but only 4 of them should work as MultistateInput. The last is used for resetting the device, which is done on a firmware level.
mortenmoulder: And for good measure, here's the old firmware (firmware V1 from 2023)'s device signature:

```
{
  "node_descriptor": {
    "logical_type": 2,
    "complex_descriptor_available": 0,
    "user_descriptor_available": 0,
    "reserved": 0,
    "aps_flags": 0,
    "frequency_band": 8,
    "mac_capability_flags": 128,
    "manufacturer_code": 4447,
    "maximum_buffer_size": 80,
    "maximum_incoming_transfer_size": 160,
    "server_mask": 0,
    "maximum_outgoing_transfer_size": 160,
    "descriptor_capability_field": 0
  },
  "endpoints": {
    "1": {
      "profile_id": "0x0104",
      "device_type": "0xfffe",
      "input_clusters": [
        "0x0000",
        "0x0001",
        "0x0007"
      ],
      "output_clusters": [
        "0x0000",
        "0x0001",
        "0x0012"
      ]
    },
    "2": {
      "profile_id": "0x0104",
      "device_type": "0xfffe",
      "input_clusters": [
        "0x0007"
      ],
      "output_clusters": [
        "0x0012"
      ]
    },
    "3": {
      "profile_id": "0x0104",
      "device_type": "0xfffe",
      "input_clusters": [
        "0x0007"
      ],
      "output_clusters": [
        "0x0012"
      ]
    },
    "4": {
      "profile_id": "0x0104",
      "device_type": "0xfffe",
      "input_clusters": [
        "0x0007"
      ],
      "output_clusters": [
        "0x0012"
      ]
    },
    "5": {
      "profile_id": "0x0104",
      "device_type": "0xfffe",
      "input_clusters": [
        "0x0007"
      ],
      "output_clusters": [
        "0x0012"
      ]
    }
  },
  "manufacturer": "smarthjemmet.dk",
  "model": "QUAD-ZIG-SW",
  "class": "zigpy.device.Device"
}
```

and here is the new firmware V2 (from late 2024) device signature:

```
{
  "node_descriptor": {
    "logical_type": 2,
    "complex_descriptor_available": 0,
    "user_descriptor_available": 0,
    "reserved": 0,
    "aps_flags": 0,
    "frequency_band": 8,
    "mac_capability_flags": 128,
    "manufacturer_code": 4447,
    "maximum_buffer_size": 80,
    "maximum_incoming_transfer_size": 160,
    "server_mask": 0,
    "maximum_outgoing_transfer_size": 160,
    "descriptor_capability_field": 0
  },
  "endpoints": {
    "1": {
      "profile_id": "0x0104",
      "device_type": "0x0006",
      "input_clusters": [
        "0x0000",
        "0x0001"
      ],
      "output_clusters": [
        "0x0000"
      ]
    },
    "2": {
      "profile_id": "0x0104",
      "device_type": "0x0006",
      "input_clusters": [
        "0x0007",
        "0x0012"
      ],
      "output_clusters": [
        "0x0012"
      ]
    },
    "3": {
      "profile_id": "0x0104",
      "device_type": "0x0006",
      "input_clusters": [
        "0x0007",
        "0x0012"
      ],
      "output_clusters": [
        "0x0012"
      ]
    },
    "4": {
      "profile_id": "0x0104",
      "device_type": "0x0006",
      "input_clusters": [
        "0x0007",
        "0x0012"
      ],
      "output_clusters": [
        "0x0012"
      ]
    },
    "5": {
      "profile_id": "0x0104",
      "device_type": "0x0006",
      "input_clusters": [
        "0x0007",
        "0x0012"
      ],
      "output_clusters": [
        "0x0012"
      ]
    }
  },
  "manufacturer": "smarthjemmet.dk",
  "model": "QUAD-ZIG-SW",
  "class": "QUAD-ZIG-SW.QUAD_ZIG_SW"
}
```
mortenmoulder: @TheJulianJES Help is not really wanted. What I originally had worked perfectly. Only thing not passing is the code coverage. Is that a requirement to get it merged?
mortenmoulder: Okay I see. Is it for you to look at "at some point" as a reminder? Or someone else? My question really is, do I need to take action?
TheJulianJES: I'll take another look again at this soon. In the mean time, you can try to see if you can make the v2 quirk work if you want.
E.g. by copying/pasting the lines at the bottom from [this comment](https://github.com/zigpy/zha-device-handlers/pull/3400#issuecomment-2420918826) into the quirk (replacing the `replace_cluster_occurrences`).
(Adding those two lines for endoints (1?) 2, 3, 4, 5.)

> Regards to your "It was also missing on endpoint 1 in the replacement. I don't think that was intentional" it actually was. The physical device has 5 buttons, but only 4 of them should work as MultistateInput.

`range(1, 6)` gives us device triggers for five buttons though. That was also in the [original code](https://github.com/zigpy/zha-device-handlers/blob/df836e275a64b6d719635e7a86a42c194ee5971e/zhaquirks/smarthjemmet/quadzigsw.py).
Isn't that one batch of device triggers too many then?
It's also a bit weird, because the original device signature has the MultistateInputCluster on all 5 endpoints (as an output cluster). Could be firmware thing of course, still weird though..

> Unfortunately it's not working.

On which HA version did you test the v2 quirk? Did you test it by replacing the whole quirks library or just inserting the one quirks file as a "custom quirk" (using the ZHA config option) (recommended for testing this).
Also, did the quirk apply at least? E.g., in device settings in HA or in the device signature, was there a `CustomDeviceV2` quirk used?
mortenmoulder: @TheJulianJES I am doing the custom quirk with the ZHA config option. It was matched to the signature of one of the devices I tested (the old V1 firmware), but no events were shown when I pressed the buttons.


OptionHenrik: > @TheJulianJES I am doing the custom quirk with the ZHA config option. It was matched to the signature of one of the devices I tested (the old V1 firmware), but no events were shown when I pressed the buttons.
> 
> Also, I appreciate the help. Let me know if there is anything I can do.

@mortenmoulder … was any of your quirk versions working when you added them as custom quirks? I think we are many users waiting for ZHA support to happen and I would like to try deploying a quirk file if your can point to a version you tested to be working.

@TheJulianJES what is missing yet before the PR can be accepted? Hope you will have the time to look at this again. Really appreciate the work you are doing.
mortenmoulder: @OptionHenrik Yes, the quirk was working perfectly before @TheJulianJES's changes. Feel free to go look at the commit just before his changes, and grab the file from there to give it a go yourself.
TheJulianJES: I've made the changes suggested in this comment: https://github.com/zigpy/zha-device-handlers/pull/3400#issuecomment-2420918826
So, I've replicated what the original quirk did (removes actual `MultistateInput` output clusters and inserts our virtual `MultistateInput` clusters as input clusters. This technically isn't correct, but let's see if that's the issue with the v2 quirk.)

The original PR also created a device trigger for endpoint 6, even though there the device only goes up to endpoint 5.

Please try and see if these changes work now. 

OptionHenrik: > @OptionHenrik Yes, the quirk was working perfectly before @TheJulianJES's changes. Feel free to go look at the commit just before his changes, and grab the file from there to give it a go yourself.

Works like a charm … great work so far even though it’s only a custom quirk. I hope you guys get it integrated into the standard ZHA soon
TheJulianJES: Please also test the v2 quirk now. It was changed again.
radhus: @mortenmoulder can confirm your quirk works with just a model name change for MULTI-ZIG-SW as well! Great job.

I'll try to find time to test out @TheJulianJES changes later.
bangert: Any news here on the current version in this PR?
OptionHenrik: > Any news here on the current version in this PR?

See my previous comment. Works like a charm with ZHA if you install it as a quirk.
TheJulianJES: It would also be ideal to know if the updated v2 quirk version works correctly now. Then, we'd be able to finally merge this (after I add some tests).
bangert: @mortenmoulder  would this quirk also work for MULTI-ZIG-SW?
bangert: trying to get the latest version of this quirk to work with MULTI-ZIG-SW it doesnt work, and i get the following in the HA logs:
```
2024-12-13 01:04:27.370 ERROR (MainThread) [homeassistant.components.logbook.processor] Error with zha describe event for zha_event
Traceback (most recent call last):
  File "/usr/src/homeassistant/homeassistant/components/logbook/processor.py", line 259, in _humanify
    data = describe_event(event_cache_get(row))
  File "/usr/src/homeassistant/homeassistant/components/zha/logbook.py", line 73, in async_describe_zha_event
    event_type = event_type.replace("_", " ").title()
                 ^^^^^^^^^^^^^^^^^^
AttributeError: 'bool' object has no attribute 'replace'
```

this is on HA 2024.12.2
bangert: with this patch the quadzigsw.py quirk works for me on a MULTI_ZIG_SW:
```patch
--- quadzigsw-orig.py   2025-02-01 06:26:31.211611131 +0100
+++ quadzigsw.py        2025-02-01 06:27:26.903118134 +0100
@@ -25,6 +25,7 @@
 
 MANUFACTURER = "smarthjemmet.dk"
 MODEL_ID = "QUAD-ZIG-SW"
+MODEL_ALT_ID = "MULTI-ZIG-SW"
 
 ACTION_TYPE = {
     0: COMMAND_RELEASE,
@@ -43,20 +44,19 @@
 
 
 class CustomMultistateInputCluster(CustomCluster, MultistateInput):
-    """Multistate input cluster."""
+    """CustomMultistate input cluster."""
 
     def _update_attribute(self, attrid, value):
         super()._update_attribute(attrid, value)
         if attrid == MultistateInput.AttributeDefs.present_value.id:
-            if action := ACTION_TYPE.get(value) is not None:
+            if (action := ACTION_TYPE.get(value)) is not None:
                 event_args = {VALUE: value}
                 self.listener_event(ZHA_SEND_EVENT, action, event_args)
 
-            super()._update_attribute(0, action)
-
 
 (
     QuirkBuilder(MANUFACTURER, MODEL_ID)
+    .applies_to(MANUFACTURER, MODEL_ALT_ID)
     .skip_configuration()
     .replaces(CR2032PowerConfigurationCluster)
     .removes(MultistateInput.cluster_id, cluster_type=ClusterType.Client, endpoint_id=2)
```
TheJulianJES: @bangert Thanks! The issue with the walrus operator was an oversight on my part. I'll merge these changes and add tests for this device soon, then we can finally merge this quirk.

Patch:
diff --git a/zhaquirks/ikea/twobtnremote.py b/zhaquirks/ikea/twobtnremote.py
index 9e4d8b3..e48bf08 100644
--- a/zhaquirks/ikea/twobtnremote.py
+++ b/zhaquirks/ikea/twobtnremote.py
@@ -262,3 +262,65 @@ class IkeaRodretRemote2Btn(CustomDevice):
     }
 
     device_automation_triggers = IkeaTradfriRemote2Btn.device_automation_triggers.copy()
+
+
+class IkeaRodretRemote2BtnNew(CustomDevice):
+    """Custom device representing IKEA of Sweden RODRET remote control."""
+
+    signature = {
+        # <SimpleDescriptor endpoint=1 profile=260 device_type=2080
+        # device_version=1
+        # input_clusters=[0, 1, 3, 4, 32, 4096, 64636]
+        # output_clusters=[3, 4, 6, 8, 258, 4096]>
+        MODELS_INFO: [(IKEA, "RODRET Dimmer")],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    PollControl.cluster_id,
+                    LightLink.cluster_id,
+                    IKEA_CLUSTER_ID,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Ota.cluster_id,
+                    LightLink.cluster_id,
+                ],
+            }
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfig1AAACluster,
+                    Identify.cluster_id,
+                    PollControl.cluster_id,
+                    LightLink.cluster_id,
+                    IKEA_CLUSTER_ID,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Ota.cluster_id,
+                    LightLink.cluster_id,
+                ],
+            }
+        }
+    }
+
+    device_automation_triggers = IkeaTradfriRemote2Btn.device_automation_triggers.copy()
diff --git a/zhaquirks/ikea/vindstyrka.py b/zhaquirks/ikea/vindstyrka.py
index db37746..63f313d 100644
--- a/zhaquirks/ikea/vindstyrka.py
+++ b/zhaquirks/ikea/vindstyrka.py
@@ -26,13 +26,13 @@ class VOCIndex(CustomCluster):
         """Attribute definitions."""
 
         measured_value: Final = ZCLAttributeDef(
-            type=t.Single, access="rp", is_manufacturer_specific=True
+            id=0x0000, type=t.Single, access="rp", is_manufacturer_specific=True
         )
         min_measured_value: Final = ZCLAttributeDef(
-            type=t.Single, access="r", is_manufacturer_specific=True
+            id=0x0001, type=t.Single, access="r", is_manufacturer_specific=True
         )
         max_measured_value: Final = ZCLAttributeDef(
-            type=t.Single, access="r", is_manufacturer_specific=True
+            id=0x0002, type=t.Single, access="r", is_manufacturer_specific=True
         )
 
 
diff --git a/zhaquirks/smarthjemmet/__init__.py b/zhaquirks/smarthjemmet/__init__.py
new file mode 100644
index 0000000..5ec1cdd
--- /dev/null
+++ b/zhaquirks/smarthjemmet/__init__.py
@@ -0,0 +1,1 @@
+"""smarthjemmet.dk devices."""
diff --git a/zhaquirks/smarthjemmet/quadzigsw.py b/zhaquirks/smarthjemmet/quadzigsw.py
new file mode 100644
index 0000000..977d09e
--- /dev/null
+++ b/zhaquirks/smarthjemmet/quadzigsw.py
@@ -0,0 +1,87 @@
+"""Device handler for smarthjemmet.dk QUAD-ZIG-SW."""
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.zcl import ClusterType
+from zigpy.zcl.clusters.general import MultistateInput
+
+from zhaquirks import PowerConfigurationCluster
+from zhaquirks.const import (
+    COMMAND,
+    COMMAND_DOUBLE,
+    COMMAND_HOLD,
+    COMMAND_RELEASE,
+    COMMAND_SINGLE,
+    COMMAND_TRIPLE,
+    DOUBLE_PRESS,
+    ENDPOINT_ID,
+    LONG_PRESS,
+    LONG_RELEASE,
+    SHORT_PRESS,
+    TRIPLE_PRESS,
+    VALUE,
+    ZHA_SEND_EVENT,
+)
+
+SMARTHJEMMET = "smarthjemmet.dk"
+
+ACTION_TYPE = {
+    0: COMMAND_RELEASE,
+    1: COMMAND_SINGLE,
+    2: COMMAND_DOUBLE,
+    3: COMMAND_TRIPLE,
+    4: COMMAND_HOLD,
+}
+
+
+class CR2032PowerConfigurationCluster(PowerConfigurationCluster):
+    """CR2032 Power Configuration Cluster."""
+
+    MIN_VOLTS = 2.2
+    MAX_VOLTS = 3.0
+
+
+class CustomMultistateInputCluster(CustomCluster, MultistateInput):
+    """Multistate input cluster."""
+
+    def _update_attribute(self, attrid, value):
+        super()._update_attribute(attrid, value)
+        if (
+            attrid == MultistateInput.AttributeDefs.present_value.id
+            and (action := ACTION_TYPE.get(value)) is not None
+        ):
+            event_args = {VALUE: value}
+            self.listener_event(ZHA_SEND_EVENT, action, event_args)
+
+
+(
+    QuirkBuilder(SMARTHJEMMET, "QUAD-ZIG-SW")
+    .applies_to(SMARTHJEMMET, "MULTI-ZIG-SW")
+    .skip_configuration()
+    .replaces(CR2032PowerConfigurationCluster)
+    .removes(MultistateInput.cluster_id, cluster_type=ClusterType.Client, endpoint_id=2)
+    .removes(MultistateInput.cluster_id, cluster_type=ClusterType.Client, endpoint_id=3)
+    .removes(MultistateInput.cluster_id, cluster_type=ClusterType.Client, endpoint_id=4)
+    .removes(MultistateInput.cluster_id, cluster_type=ClusterType.Client, endpoint_id=5)
+    .adds(CustomMultistateInputCluster, endpoint_id=2)
+    .adds(CustomMultistateInputCluster, endpoint_id=3)
+    .adds(CustomMultistateInputCluster, endpoint_id=4)
+    .adds(CustomMultistateInputCluster, endpoint_id=5)
+    .device_automation_triggers(
+        {
+            (press_type, f"button_{i}"): {
+                COMMAND: command,
+                ENDPOINT_ID: i + 1,
+            }
+            for i in range(1, 5)
+            for press_type, command in {
+                (SHORT_PRESS, COMMAND_SINGLE),
+                (DOUBLE_PRESS, COMMAND_DOUBLE),
+                (TRIPLE_PRESS, COMMAND_TRIPLE),
+                (LONG_PRESS, COMMAND_HOLD),
+                (LONG_RELEASE, COMMAND_RELEASE),
+            }
+        }
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/tuya_smoke.py b/zhaquirks/tuya/tuya_smoke.py
index 52c0dda..9818e04 100644
--- a/zhaquirks/tuya/tuya_smoke.py
+++ b/zhaquirks/tuya/tuya_smoke.py
@@ -102,6 +102,16 @@ class TuyaSmokeDetectorCluster(TuyaManufClusterAttributes):
     .add_to_registry()
 )
 
+
+(
+    TuyaQuirkBuilder("_TZE284_n4ttsck2", "TS0601")
+    .tuya_smoke(dp_id=1)
+    .tuya_battery(dp_id=15, battery_type=BatterySize.CR123A, battery_qty=1)
+    .skip_configuration()
+    .add_to_registry()
+)
+
+
 (
     TuyaQuirkBuilder("_TZE204_ntcy3xu1", "TS0601")
     .applies_to("_TZE200_ntcy3xu1", "TS0601")


Test patch:
diff --git a/tests/test_smarthjemmet.py b/tests/test_smarthjemmet.py
new file mode 100644
index 0000000..17c2c33
--- /dev/null
+++ b/tests/test_smarthjemmet.py
@@ -0,0 +1,57 @@
+"""Tests for SmartHjemmet quirks."""
+
+from unittest import mock
+
+import pytest
+from zigpy.zcl.clusters.general import MultistateInput
+
+import zhaquirks
+
+zhaquirks.setup()
+
+
+@pytest.mark.parametrize("endpoint", (2, 3, 4, 5))
+def test_quadzigsw(zigpy_device_from_v2_quirk, endpoint):
+    """Test the SmartHjemmet QUAD-ZIG-SW."""
+    device = zigpy_device_from_v2_quirk(
+        "smarthjemmet.dk", "QUAD-ZIG-SW", endpoint_ids=[1, 2, 3, 4, 5]
+    )
+
+    cluster = device.endpoints[endpoint].multistate_input
+    listener = mock.MagicMock()
+    cluster.add_listener(listener)
+
+    multistate_value = MultistateInput.AttributeDefs.present_value.id
+    multistate_text = MultistateInput.AttributeDefs.state_text.id
+
+    # test that attribute writes are passed through with no events
+    cluster.update_attribute(MultistateInput.AttributeDefs.state_text.id, "test")
+    assert listener.zha_send_event.call_count == 0
+    assert listener.attribute_updated.call_count == 1
+    assert listener.attribute_updated.call_args[0][0] == multistate_text
+    assert listener.attribute_updated.call_args[0][1] == "test"
+
+    # test that the cluster does not send events for unknown values
+    cluster.update_attribute(multistate_value, 5)
+    assert listener.zha_send_event.call_count == 0
+
+    # test that the cluster sends the correct events
+    cluster.update_attribute(multistate_value, 0)
+    assert listener.zha_send_event.call_count == 1
+    listener.zha_send_event.assert_called_with("release", {"value": 0})
+
+    cluster.update_attribute(multistate_value, 1)
+    assert listener.zha_send_event.call_count == 2
+    listener.zha_send_event.assert_called_with("single", {"value": 1})
+
+    cluster.update_attribute(multistate_value, 2)
+    assert listener.zha_send_event.call_count == 3
+    listener.zha_send_event.assert_called_with("double", {"value": 2})
+
+    cluster.update_attribute(multistate_value, 3)
+    assert listener.zha_send_event.call_count == 4
+    listener.zha_send_event.assert_called_with("triple", {"value": 3})
+
+    cluster.update_attribute(multistate_value, 4)
+    assert listener.zha_send_event.call_count == 5
+    listener.zha_send_event.assert_called_with("hold", {"value": 4})