Repository: zigpy/zha-device-handlers
Pull request #3826 (2025-02-03 20:26:06)

Auto-update pre-commit hooks
<!--pre-commit.ci start-->
updates:
- [github.com/codespell-project/codespell: v2.4.0 → v2.4.1](https://github.com/codespell-project/codespell/compare/v2.4.0...v2.4.1)
- [github.com/astral-sh/ruff-pre-commit: v0.9.3 → v0.9.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.3...v0.9.4)
<!--pre-commit.ci end-->

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3826?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 [(`8b3560e`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/8b3560ee6d9cc105df7b698cec021ceacf14af1f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`56d5aec`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/56d5aecac8a988d355cf9d315d426cf8f58f6353?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    #3826   +/-   ##
=======================================
  Coverage   90.91%   90.91%           
=======================================
  Files         325      325           
  Lines       10566    10566           
=======================================
  Hits         9606     9606           
  Misses        960      960           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3826?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/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7a36800..f66d350 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,7 +5,7 @@ ci:
 
 repos:
   - repo: https://github.com/codespell-project/codespell
-    rev: v2.4.0
+    rev: v2.4.1
     hooks:
       - id: codespell
         additional_dependencies: [tomli]
@@ -17,7 +17,7 @@ repos:
       - id: mypy
   
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.9.3
+    rev: v0.9.4
     hooks:
       - id: ruff
         args: ["--fix", "--exit-non-zero-on-fix", "--config", "pyproject.toml"]
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/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index e8b40dc..fc11b93 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -502,6 +502,10 @@ class TuyaQuirkBuilder(QuirkBuilder):
         dp_handler: str = "_dp_2_attr_update",
     ) -> QuirkBuilder:  # fmt: skip
         """Add Tuya DP Converter."""
+
+        if dp_id in self.tuya_dp_to_attribute:
+            raise ValueError(f"DP {dp_id} is already mapped.")
+
         self.tuya_dp_to_attribute.update(
             {
                 dp_id: DPToAttributeMapping(
diff --git a/zhaquirks/tuya/tuya_motion.py b/zhaquirks/tuya/tuya_motion.py
index 840284f..d151c11 100644
--- a/zhaquirks/tuya/tuya_motion.py
+++ b/zhaquirks/tuya/tuya_motion.py
@@ -1192,7 +1192,7 @@ base_tuya_motion = (
         fallback_name="Maximum range",
     )
     .tuya_number(
-        dp_id=107,
+        dp_id=108,
         attribute_name="detection_distance_min",
         type=t.uint16_t,
         device_class=SensorDeviceClass.DISTANCE,
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")
diff --git a/zhaquirks/tuya/tuya_valve.py b/zhaquirks/tuya/tuya_valve.py
index 7f2197c..e2baad5 100644
--- a/zhaquirks/tuya/tuya_valve.py
+++ b/zhaquirks/tuya/tuya_valve.py
@@ -553,7 +553,11 @@ class GiexIrrigationStatus(t.enum8):
         fallback_name="Weather delay",
         initially_disabled=True,
     )
-    # Charstring normal timed, dp 38 omitted
+    .tuya_dp_attribute(
+        dp_id=38,
+        attribute_name="normal_timer",
+        type=t.CharacterString,
+    )
     .tuya_switch(
         dp_id=42,
         attribute_name="switch_enabled",
@@ -576,16 +580,6 @@ class GiexIrrigationStatus(t.enum8):
         translation_key="total_flow_reset_switch",
         fallback_name="Total flow reset switch",
     )
-    .tuya_sensor(
-        dp_id=102,
-        attribute_name="irrigation_duration",
-        type=t.uint32_t,
-        state_class=SensorStateClass.MEASUREMENT,
-        device_class=SensorDeviceClass.VOLUME,
-        unit=UnitOfVolume.LITERS,
-        translation_key="irrigation_duration",
-        fallback_name="Irrigation duration",
-    )
     .tuya_number(
         dp_id=102,
         attribute_name="quantitative_watering",
@@ -622,7 +616,7 @@ class GiexIrrigationStatus(t.enum8):
     .tuya_sensor(
         dp_id=106,
         attribute_name="single_watering_duration",
-        type=t.CharacterString,
+        type=t.uint32_t,
         state_class=SensorStateClass.MEASUREMENT,
         device_class=SensorDeviceClass.DURATION,
         unit=UnitOfTime.SECONDS,
@@ -632,7 +626,7 @@ class GiexIrrigationStatus(t.enum8):
     .tuya_sensor(
         dp_id=108,
         attribute_name="single_watering_amount",
-        type=t.CharacterString,
+        type=t.uint32_t,
         state_class=SensorStateClass.MEASUREMENT,
         device_class=SensorDeviceClass.VOLUME,
         unit=UnitOfVolume.LITERS,


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})
diff --git a/tests/test_tuya_builder.py b/tests/test_tuya_builder.py
index 97add16..b223b97 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -252,6 +252,23 @@ async def test_tuya_quirkbuilder(device_mock):
     assert tuya_listener.attribute_updates[0][1] == TestEnum.B
 
 
+async def test_tuya_quirkbuilder_duplicated_mappings(device_mock):
+    """Test that mapping the same DP multiple times will raise."""
+
+    registry = DeviceRegistry()
+
+    with pytest.raises(ValueError):
+        (
+            TuyaQuirkBuilder(
+                device_mock.manufacturer, device_mock.model, registry=registry
+            )
+            .tuya_battery(dp_id=1)
+            .tuya_onoff(dp_id=1)
+            .skip_configuration()
+            .add_to_registry()
+        )
+
+
 @pytest.mark.parametrize(
     "read_attr_spell,data_query_spell",
     [