Repository: zigpy/zha-device-handlers
Pull request #3888 (2025-02-17 18:10:54)

Add Tuya ultrasonic valve `_TZE200_zlwr0raf`
## Proposed change
<!--
  Explain your proposed change below.
-->

Add Tuya ultrasonic valve `_TZE200_zlwr0raf`

Based on: https://github.com/make-all/tuya-local/blob/main/custom_components/tuya_local/devices/zigbee_ultrasonic_watermeter.yaml

## Additional information
<!--
  Please include any additional information that is important to this PR.
  For example, if this PR is a potentially breaking change, mention that here.
  If this PR requires other PRs to be merged in HA Core or other projects, mention that.
  Lastly, if this PR fixes a specific issue, please include "Fixes #xxxx".
-->

Closes: #3301

## Checklist
<!--
  Put an 'x' in all boxes that apply.
  Note: You do not need to tick all boxes before creating a PR.
-->

- [ ] 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/3888?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.95%. Comparing base [(`4e96fae`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/4e96faee20f96b7021b747edeef1fcda575fc487?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`d788380`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/d7883808ed5d3ea59a933ac84716654320a4806c?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    #3888   +/-   ##
=======================================
  Coverage   90.95%   90.95%           
=======================================
  Files         326      326           
  Lines       10597    10598    +1     
=======================================
+ Hits         9638     9639    +1     
  Misses        959      959           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3888?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/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py
index 2fe2ceb..0c6ffcf 100644
--- a/zhaquirks/tuya/__init__.py
+++ b/zhaquirks/tuya/__init__.py
@@ -1533,35 +1533,43 @@ class TuyaNewManufCluster(CustomCluster):
         ),
     }
 
-    dp_to_attribute: dict[int, DPToAttributeMapping] = {}
+    dp_to_attribute: dict[int, DPToAttributeMapping | list[DPToAttributeMapping]] = {}
     data_point_handlers: dict[int, str] = {}
 
     def __init__(self, *args, **kwargs):
         """Initialize the cluster and mark attributes as valid on LocalDataClusters."""
         super().__init__(*args, **kwargs)
-        for dp_map in self.dp_to_attribute.values():
+
+        self._dp_to_attributes: dict[int, list[DPToAttributeMapping]] = {
+            dp: attr if isinstance(attr, list) else [attr]
+            for dp, attr in self.dp_to_attribute.items()
+        }
+        for dp_map in self._dp_to_attributes.values():
             # get the endpoint that is being mapped to
             endpoint = self.endpoint
-            if dp_map.endpoint_id:
-                endpoint = self.endpoint.device.endpoints.get(dp_map.endpoint_id)
-
-            # the endpoint to be mapped to might not actually exist within all quirks
-            if not endpoint:
-                continue
-
-            cluster = getattr(endpoint, dp_map.ep_attribute, None)
-            # the cluster to be mapped to might not actually exist within all quirks
-            if not cluster:
-                continue
-
-            # mark mapped to attribute as valid if existing and if on a LocalDataCluster
-            attr = cluster.attributes_by_name.get(dp_map.attribute_name)
-            if attr and isinstance(cluster, LocalDataCluster):
-                # _VALID_ATTRIBUTES is only a class variable, but as want to modify it
-                # per instance here, we need to create an instance variable first
-                if "_VALID_ATTRIBUTES" not in cluster.__dict__:
-                    cluster._VALID_ATTRIBUTES = set()
-                cluster._VALID_ATTRIBUTES.add(attr.id)
+            for mapped_attr in dp_map:
+                if mapped_attr.endpoint_id:
+                    endpoint = self.endpoint.device.endpoints.get(
+                        mapped_attr.endpoint_id
+                    )
+
+                # the endpoint to be mapped to might not actually exist within all quirks
+                if not endpoint:
+                    continue
+
+                cluster = getattr(endpoint, mapped_attr.ep_attribute, None)
+                # the cluster to be mapped to might not actually exist within all quirks
+                if not cluster:
+                    continue
+
+                # mark mapped to attribute as valid if existing and if on a LocalDataCluster
+                attr = cluster.attributes_by_name.get(mapped_attr.attribute_name)
+                if attr and isinstance(cluster, LocalDataCluster):
+                    # _VALID_ATTRIBUTES is only a class variable, but as want to modify it
+                    # per instance here, we need to create an instance variable first
+                    if "_VALID_ATTRIBUTES" not in cluster.__dict__:
+                        cluster._VALID_ATTRIBUTES = set()
+                    cluster._VALID_ATTRIBUTES.add(attr.id)
 
     def handle_cluster_request(
         self,
@@ -1640,27 +1648,29 @@ class TuyaNewManufCluster(CustomCluster):
     def _dp_2_attr_update(self, datapoint: TuyaDatapointData) -> None:
         """Handle data point to attribute report conversion."""
         try:
-            dp_map = self.dp_to_attribute[datapoint.dp]
+            dp_map = self._dp_to_attributes[datapoint.dp]
         except KeyError:
             self.debug("No attribute mapping for %s data point", datapoint.dp)
             return
 
         endpoint = self.endpoint
-        if dp_map.endpoint_id:
-            endpoint = self.endpoint.device.endpoints[dp_map.endpoint_id]
-        cluster = getattr(endpoint, dp_map.ep_attribute)
-        value = datapoint.data.payload
-        if dp_map.converter:
-            value = dp_map.converter(value)
-
-        if isinstance(dp_map.attribute_name, tuple):
-            for k, v in zip(dp_map.attribute_name, value):
-                if isinstance(v, AttributeWithMask):
-                    v = cluster.get(k, 0) & (~v.mask) | v.value
-                cluster.update_attribute(k, v)
-        else:
-            if isinstance(value, AttributeWithMask):
-                value = (
-                    cluster.get(dp_map.attribute_name, 0) & (~value.mask) | value.value
-                )
-            cluster.update_attribute(dp_map.attribute_name, value)
+        for mapped_attr in dp_map:
+            if mapped_attr.endpoint_id:
+                endpoint = self.endpoint.device.endpoints[mapped_attr.endpoint_id]
+            cluster = getattr(endpoint, mapped_attr.ep_attribute)
+            value = datapoint.data.payload
+            if mapped_attr.converter:
+                value = mapped_attr.converter(value)
+
+            if isinstance(mapped_attr.attribute_name, tuple):
+                for k, v in zip(mapped_attr.attribute_name, value):
+                    if isinstance(v, AttributeWithMask):
+                        v = cluster.get(k, 0) & (~v.mask) | v.value
+                    cluster.update_attribute(k, v)
+            else:
+                if isinstance(value, AttributeWithMask):
+                    value = (
+                        cluster.get(mapped_attr.attribute_name, 0) & (~value.mask)
+                        | value.value
+                    )
+                cluster.update_attribute(mapped_attr.attribute_name, value)
diff --git a/zhaquirks/tuya/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index d9e921d..475328b 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -196,7 +196,7 @@ class TuyaQuirkBuilder(QuirkBuilder):
     ) -> None:
         """Init the TuyaQuirkBuilder."""
         self.tuya_data_point_handlers: dict[int, str] = {}
-        self.tuya_dp_to_attribute: dict[int, DPToAttributeMapping] = {}
+        self.tuya_dp_to_attribute: dict[int, list[DPToAttributeMapping]] = {}
         self.new_attributes: set[foundation.ZCLAttributeDef] = set()
         super().__init__(manufacturer, model, registry)
         # quirk_file will point to the init call above if called from this QuirkBuilder,
@@ -503,20 +503,33 @@ class TuyaQuirkBuilder(QuirkBuilder):
     ) -> 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(
+        self.tuya_dp_multi(
+            dp_id,
+            [
+                DPToAttributeMapping(
                     ep_attribute,
                     attribute_name,
                     converter=converter,
                     dp_converter=dp_converter,
                     endpoint_id=endpoint_id,
                 )
-            }
+            ],
+            dp_handler,
         )
+        return self
+
+    def tuya_dp_multi(
+        self,
+        dp_id: int,
+        attribute_mapping: list[DPToAttributeMapping],
+        dp_handler: str = "_dp_2_attr_update",
+    ) -> QuirkBuilder:  # fmt: skip
+        """Add Tuya DP Converter that maps to multiple attributes."""
+
+        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: attribute_mapping})
         self.tuya_data_point_handlers.update({dp_id: dp_handler})
         return self
 
@@ -814,7 +827,7 @@ class TuyaQuirkBuilder(QuirkBuilder):
                 """Replacement Tuya Cluster."""
 
                 data_point_handlers: dict[int, str]
-                dp_to_attribute: dict[int, DPToAttributeMapping]
+                dp_to_attribute: dict[int, list[DPToAttributeMapping]]
 
                 class AttributeDefs(NewAttributeDefs):
                     """Attribute Definitions."""
diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py
index 26a11fa..2dfd104 100644
--- a/zhaquirks/tuya/mcu/__init__.py
+++ b/zhaquirks/tuya/mcu/__init__.py
@@ -288,25 +288,26 @@ class TuyaMCUCluster(TuyaAttributesCluster, TuyaNewManufCluster):
     def get_dp_mapping(
         self, endpoint_id: int, attribute_name: str
     ) -> Optional[tuple[int, DPToAttributeMapping]]:
-        """Search for the DP in dp_to_attribute."""
+        """Search for the DP in _dp_to_attributes."""
 
         result = {}
-        for dp, dp_mapping in self.dp_to_attribute.items():
-            if (
-                attribute_name == dp_mapping.attribute_name
-                or (
-                    isinstance(dp_mapping.attribute_name, tuple)
-                    and attribute_name in dp_mapping.attribute_name
-                )
-            ) and (
-                (
-                    dp_mapping.endpoint_id is None
-                    and endpoint_id == self.endpoint.endpoint_id
-                )
-                or (endpoint_id == dp_mapping.endpoint_id)
-            ):
-                self.debug("get_dp_mapping --> found DP: %s", dp)
-                result[dp] = dp_mapping
+        for dp, dp_mapping in self._dp_to_attributes.items():
+            for mapped_attr in dp_mapping:
+                if (
+                    attribute_name == mapped_attr.attribute_name
+                    or (
+                        isinstance(mapped_attr.attribute_name, tuple)
+                        and attribute_name in mapped_attr.attribute_name
+                    )
+                ) and (
+                    (
+                        mapped_attr.endpoint_id is None
+                        and endpoint_id == self.endpoint.endpoint_id
+                    )
+                    or (endpoint_id == mapped_attr.endpoint_id)
+                ):
+                    self.debug("get_dp_mapping --> found DP: %s", dp)
+                    result[dp] = mapped_attr
         return result
 
     def handle_mcu_version_response(self, payload: MCUVersion) -> foundation.Status:
diff --git a/zhaquirks/tuya/ts0001_fingerbot.py b/zhaquirks/tuya/ts0001_fingerbot.py
deleted file mode 100644
index c5ffe3b..0000000
--- a/zhaquirks/tuya/ts0001_fingerbot.py
+++ /dev/null
@@ -1,150 +0,0 @@
-"""Tuya Fingerbot device."""
-
-from zigpy.profiles import zha
-import zigpy.types as t
-from zigpy.zcl import foundation
-from zigpy.zcl.clusters.general import Basic, OnOff, Ota, Time
-
-from zhaquirks.const import (
-    DEVICE_TYPE,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
-    MODELS_INFO,
-    OUTPUT_CLUSTERS,
-    PROFILE_ID,
-)
-from zhaquirks.tuya import EnchantedDevice
-from zhaquirks.tuya.mcu import (
-    DPToAttributeMapping,
-    TuyaMCUCluster,
-    TuyaPowerConfigurationCluster,
-)
-
-
-class FingerBotMode(t.enum8):
-    """FingerBot mode."""
-
-    CLICK = 0x00
-    SWITCH = 0x01
-    PROGRAM = 0x02
-
-
-class FingerBotReverse(t.enum8):
-    """FingerBot reverse."""
-
-    UP_ON = 0x00
-    UP_OFF = 0x01
-
-
-class TuyaFingerbotCluster(TuyaMCUCluster):
-    """Tuya Fingerbot cluster."""
-
-    class AttributeDefs(TuyaMCUCluster.AttributeDefs):
-        """Attribute Definitions."""
-
-        mode = foundation.ZCLAttributeDef(
-            id=101, type=FingerBotMode, is_manufacturer_specific=True
-        )
-        down_movement = foundation.ZCLAttributeDef(
-            id=102, type=t.uint16_t, is_manufacturer_specific=True
-        )
-        sustain_time = foundation.ZCLAttributeDef(
-            id=103, type=t.uint16_t, is_manufacturer_specific=True
-        )
-        reverse = foundation.ZCLAttributeDef(
-            id=104, type=FingerBotReverse, is_manufacturer_specific=True
-        )
-        battery = foundation.ZCLAttributeDef(
-            id=105, type=t.uint16_t, is_manufacturer_specific=True
-        )
-        up_movement = foundation.ZCLAttributeDef(
-            id=106, type=t.uint16_t, is_manufacturer_specific=True
-        )
-        touch_control = foundation.ZCLAttributeDef(
-            id=107, type=t.Bool, is_manufacturer_specific=True
-        )
-
-    dp_to_attribute: dict[int, DPToAttributeMapping] = {
-        # Mode
-        101: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "mode",
-            converter=lambda x: FingerBotMode(x),
-        ),
-        # Down Movement
-        102: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "down_movement",
-        ),
-        # Sustain Time
-        103: DPToAttributeMapping(TuyaMCUCluster.ep_attribute, "sustain_time"),
-        # Reverse
-        104: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "reverse",
-            converter=lambda x: FingerBotReverse(x),
-        ),
-        # Battery
-        105: DPToAttributeMapping(
-            TuyaPowerConfigurationCluster.ep_attribute,
-            "battery_percentage_remaining",
-        ),
-        # Up Movement
-        106: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "up_movement",
-        ),
-        107: DPToAttributeMapping(TuyaMCUCluster.ep_attribute, "touch_control"),
-    }
-
-    data_point_handlers = {
-        101: "_dp_2_attr_update",
-        102: "_dp_2_attr_update",
-        103: "_dp_2_attr_update",
-        104: "_dp_2_attr_update",
-        105: "_dp_2_attr_update",
-        106: "_dp_2_attr_update",
-        107: "_dp_2_attr_update",
-    }
-
-
-class TuyaFingerbot(EnchantedDevice):
-    """Tuya finger bot device."""
-
-    signature = {
-        MODELS_INFO: [("_TZ3210_dse8ogfy", "TS0001"), ("_TZ3210_j4pdtz9v", "TS0001")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    OnOff.cluster_id,
-                    TuyaFingerbotCluster.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Time.cluster_id,
-                    Ota.cluster_id,
-                ],
-            }
-        },
-    }
-
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    TuyaPowerConfigurationCluster,
-                    OnOff.cluster_id,
-                    TuyaFingerbotCluster,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Time.cluster_id,
-                    Ota.cluster_id,
-                ],
-            }
-        }
-    }
diff --git a/zhaquirks/tuya/ts0001_switch.py b/zhaquirks/tuya/ts0001_switch.py
new file mode 100644
index 0000000..c2d4a13
--- /dev/null
+++ b/zhaquirks/tuya/ts0001_switch.py
@@ -0,0 +1,51 @@
+"""Tuya switch device."""
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
+from zigpy.zcl.clusters.smartenergy import Metering
+
+from zhaquirks.tuya import TuyaZBOnOffAttributeCluster
+
+
+class CustomElectricalMeasurement(ElectricalMeasurement, CustomCluster):
+    """Custom electrical measurement cluster."""
+
+    _CONSTANT_ATTRIBUTES = {
+        ElectricalMeasurement.AttributeDefs.ac_current_multiplier.id: 1,
+        ElectricalMeasurement.AttributeDefs.ac_current_divisor.id: 1000,
+    }
+
+
+class CustomMetering(Metering, CustomCluster):
+    """Custom metering cluster."""
+
+    KILOWATT_HOURS = 0x0
+    ELECTRIC_METERING = 0x0
+
+    _CONSTANT_ATTRIBUTES = {
+        Metering.AttributeDefs.unit_of_measure.id: KILOWATT_HOURS,
+        Metering.AttributeDefs.metering_device_type.id: ELECTRIC_METERING,
+        Metering.AttributeDefs.multiplier.id: 1,
+        Metering.AttributeDefs.divisor.id: 100,
+    }
+
+
+(
+    QuirkBuilder("_TZ3000_xkap8wtb", "TS0001")
+    .applies_to("_TZ3000_qnejhcsu", "TS0001")
+    .applies_to("_TZ3000_x3ewpzyr", "TS0001")
+    .applies_to("_TZ3000_mkhkxx1p", "TS0001")
+    .applies_to("_TZ3000_tgddllx4", "TS0001")
+    .applies_to("_TZ3000_kqvb5akv", "TS0001")
+    .applies_to("_TZ3000_g92baclx", "TS0001")
+    .applies_to("_TZ3000_qlai3277", "TS0001")
+    .applies_to("_TZ3000_qaabwu5c", "TS0001")
+    .applies_to("_TZ3000_ikuxinvo", "TS0001")
+    .applies_to("_TZ3000_hzlsaltw", "TS0001")
+    .applies_to("_TZ3000_jsfzkftc", "TS0001")
+    .replaces(CustomMetering)
+    .replaces(CustomElectricalMeasurement)
+    .replaces(TuyaZBOnOffAttributeCluster)
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/tuya_fingerbot.py b/zhaquirks/tuya/tuya_fingerbot.py
new file mode 100644
index 0000000..6f4a413
--- /dev/null
+++ b/zhaquirks/tuya/tuya_fingerbot.py
@@ -0,0 +1,84 @@
+"""Tuya fingerbot."""
+
+from zigpy.quirks.v2.homeassistant import PERCENTAGE, UnitOfTime
+import zigpy.types as t
+
+from zhaquirks.const import BatterySize
+from zhaquirks.tuya.builder import TuyaQuirkBuilder
+
+
+class FingerBotMode(t.enum8):
+    """FingerBot mode."""
+
+    CLICK = 0x00
+    SWITCH = 0x01
+    PROGRAM = 0x02
+
+
+class FingerBotReverse(t.enum8):
+    """FingerBot reverse."""
+
+    UP_ON = 0x00
+    UP_OFF = 0x01
+
+
+(
+    TuyaQuirkBuilder("_TZ3210_dse8ogfy", "TS0001")
+    .applies_to("_TZ3210_j4pdtz9v", "TS0001")
+    .tuya_enum(
+        dp_id=101,
+        attribute_name="mode",
+        enum_class=FingerBotMode,
+        translation_key="mode",
+        fallback_name="Mode",
+    )
+    .tuya_number(
+        dp_id=102,
+        attribute_name="down_movement",
+        min_value=50,
+        type=t.uint16_t,
+        max_value=100,
+        step=1,
+        unit=PERCENTAGE,
+        translation_key="down_movement",
+        fallback_name="Down movement",
+    )
+    .tuya_number(
+        dp_id=103,
+        attribute_name="sustain_time",
+        min_value=0,
+        type=t.uint16_t,
+        max_value=10,
+        step=1,
+        unit=UnitOfTime.SECONDS,
+        translation_key="sustain_time",
+        fallback_name="Sustain time",
+    )
+    .tuya_enum(
+        dp_id=104,
+        attribute_name="reverse",
+        enum_class=FingerBotReverse,
+        translation_key="reverse",
+        fallback_name="Reverse",
+    )
+    .tuya_battery(dp_id=105, battery_type=BatterySize.CR2, battery_qty=1)
+    .tuya_number(
+        dp_id=106,
+        attribute_name="up_movement",
+        min_value=0,
+        type=t.uint16_t,
+        max_value=50,
+        step=1,
+        unit=PERCENTAGE,
+        translation_key="up_movement",
+        fallback_name="Up movement",
+    )
+    .tuya_switch(
+        dp_id=107,
+        attribute_name="touch_control",
+        translation_key="touch_control",
+        fallback_name="Touch control",
+    )
+    .tuya_enchantment()
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/tuya_siren.py b/zhaquirks/tuya/tuya_siren.py
index 001b91f..fbef4f8 100644
--- a/zhaquirks/tuya/tuya_siren.py
+++ b/zhaquirks/tuya/tuya_siren.py
@@ -133,6 +133,7 @@ class NeoBatteryState(t.enum8):
 
 (
     TuyaQuirkBuilder("_TZE204_nlrfgpny", "TS0601")
+    .applies_to("TZE200_nlrfgpny", "TS0601")
     .tuya_enum(
         dp_id=1,
         attribute_name="alarm_state",
diff --git a/zhaquirks/tuya/tuya_valve.py b/zhaquirks/tuya/tuya_valve.py
index e2baad5..89cd996 100644
--- a/zhaquirks/tuya/tuya_valve.py
+++ b/zhaquirks/tuya/tuya_valve.py
@@ -3,7 +3,11 @@
 from datetime import datetime, timedelta, timezone
 
 from zigpy.quirks.v2 import BinarySensorDeviceClass, EntityPlatform, EntityType
-from zigpy.quirks.v2.homeassistant import UnitOfTime, UnitOfVolume
+from zigpy.quirks.v2.homeassistant import (
+    UnitOfElectricPotential,
+    UnitOfTime,
+    UnitOfVolume,
+)
 from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass
 import zigpy.types as t
 from zigpy.zcl.clusters.smartenergy import Metering
@@ -666,3 +670,38 @@ class GiexIrrigationStatus(t.enum8):
     .skip_configuration()
     .add_to_registry()
 )
+
+
+# Tuya 214C Ultrasonic water meter valve
+(
+    TuyaQuirkBuilder("_TZE200_zlwr0raf", "TS0601")
+    .tuya_metering(dp_id=1, metering_cfg=TuyaValveWaterConsumed)
+    # Skipped DP 2,3,4,5,6,16,18
+    .tuya_onoff(dp_id=13)
+    .tuya_switch(
+        dp_id=14,
+        attribute_name="auto_clean",
+        entity_type=EntityType.CONFIG,
+        translation_key="auto_clean",
+        fallback_name="Auto clean",
+    )
+    .tuya_dp(
+        dp_id=21,
+        ep_attribute=TuyaValveWaterConsumed.ep_attribute,
+        attribute_name=Metering.AttributeDefs.instantaneous_demand.name,
+    )
+    .tuya_temperature(dp_id=22)
+    .tuya_sensor(
+        dp_id=26,
+        attribute_name="voltage",
+        type=t.uint16_t,
+        converter=lambda x: x * 100,
+        device_class=SensorDeviceClass.VOLTAGE,
+        state_class=SensorStateClass.MEASUREMENT,
+        unit=UnitOfElectricPotential.VOLT,
+        entity_type=EntityType.STANDARD,
+        fallback_name="Voltage",
+    )
+    .skip_configuration()
+    .add_to_registry()
+)


Test patch:
diff --git a/tests/test_tuya_builder.py b/tests/test_tuya_builder.py
index b223b97..8041ae2 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -1,5 +1,6 @@
 """Tests for TuyaQuirkBuilder."""
 
+from collections.abc import ByteString
 import datetime
 from unittest import mock
 
@@ -9,6 +10,7 @@ from zigpy.quirks.v2 import CustomDeviceV2
 import zigpy.types as t
 from zigpy.zcl import foundation
 from zigpy.zcl.clusters.general import Basic
+from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
 
 from tests.common import ClusterListener, MockDatetime, wait_for_zigpy_tasks
 import zhaquirks
@@ -16,6 +18,11 @@ from zhaquirks.const import BatterySize
 from zhaquirks.tuya import (
     TUYA_QUERY_DATA,
     TUYA_SET_TIME,
+    DPToAttributeMapping,
+    TuyaCommand,
+    TuyaData,
+    TuyaDatapointData,
+    TuyaLocalCluster,
     TuyaPowerConfigurationCluster,
     TuyaPowerConfigurationCluster2AAA,
 )
@@ -156,6 +163,18 @@ async def test_tuya_quirkbuilder(device_mock):
     class ModTuyaMCUCluster(TuyaMCUCluster):
         """Modified Cluster."""
 
+    class Tuya3PhaseElectricalMeasurement(ElectricalMeasurement, TuyaLocalCluster):
+        """Tuya Electrical Measurement cluster."""
+
+    def dpToPower(data: ByteString) -> int:
+        return data[0]
+
+    def dpToCurrent(data: ByteString) -> int:
+        return data[1]
+
+    def dpToVoltage(data: ByteString) -> int:
+        return data[2]
+
     entry = (
         TuyaQuirkBuilder(device_mock.manufacturer, device_mock.model, registry=registry)
         .tuya_battery(dp_id=1)
@@ -193,6 +212,27 @@ async def test_tuya_quirkbuilder(device_mock):
             translation_key="test_enum",
             fallback_name="Test enum",
         )
+        .tuya_dp_multi(
+            dp_id=11,
+            attribute_mapping=[
+                DPToAttributeMapping(
+                    ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                    attribute_name="active_power",
+                    converter=dpToPower,
+                ),
+                DPToAttributeMapping(
+                    ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                    attribute_name="rms_current",
+                    converter=dpToCurrent,
+                ),
+                DPToAttributeMapping(
+                    ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                    attribute_name="rms_voltage",
+                    converter=dpToVoltage,
+                ),
+            ],
+        )
+        .adds(Tuya3PhaseElectricalMeasurement)
         .skip_configuration()
         .add_to_registry(replacement_cluster=ModTuyaMCUCluster)
     )
@@ -251,6 +291,17 @@ async def test_tuya_quirkbuilder(device_mock):
     assert tuya_listener.attribute_updates[0][0] == 0xEF0A
     assert tuya_listener.attribute_updates[0][1] == TestEnum.B
 
+    electric_data = TuyaCommand(
+        status=0,
+        tsn=2,
+        datapoints=[TuyaDatapointData(11, TuyaData("345"))],
+    )
+    tuya_cluster.handle_get_data(electric_data)
+    electrical_meas_cluster = ep.electrical_measurement
+    assert electrical_meas_cluster.get("active_power") == "3"
+    assert electrical_meas_cluster.get("rms_current") == "4"
+    assert electrical_meas_cluster.get("rms_voltage") == "5"
+
 
 async def test_tuya_quirkbuilder_duplicated_mappings(device_mock):
     """Test that mapping the same DP multiple times will raise."""
@@ -268,6 +319,24 @@ async def test_tuya_quirkbuilder_duplicated_mappings(device_mock):
             .add_to_registry()
         )
 
+    with pytest.raises(ValueError):
+        (
+            TuyaQuirkBuilder(
+                device_mock.manufacturer, device_mock.model, registry=registry
+            )
+            .tuya_battery(dp_id=1)
+            .tuya_dp_multi(
+                dp_id=1,
+                attribute_mapping=[
+                    DPToAttributeMapping(
+                        ep_attribute=ElectricalMeasurement.ep_attribute,
+                        attribute_name="active_power",
+                    ),
+                ],
+            )
+            .add_to_registry()
+        )
+
 
 @pytest.mark.parametrize(
     "read_attr_spell,data_query_spell",