Repository: zigpy/zha-device-handlers
Pull request #3571 (2024-12-02 01:47:46)

Add Tuya 3 phase power meter `_TZE204_ugekduaj`
## Proposed change
<!--
  Explain your proposed change below.
-->


## 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".
-->


Requires: https://github.com/home-assistant/core/pull/133972 and https://github.com/zigpy/zha/pull/339

## 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
- [ ] `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/3571?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.79%. Comparing base [(`97413e3`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/97413e3ec2e5fdbcd17239b810d573cec4d3964d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`a99f27e`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/a99f27e1316e41fbed5967ec65f4da53d6c4d6bb?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    #3571   +/-   ##
=======================================
  Coverage   89.79%   89.79%           
=======================================
  Files         323      323           
  Lines       10414    10421    +7     
=======================================
+ Hits         9351     9358    +7     
  Misses       1063     1063           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3571?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/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 1df59c4..30735df 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,6 +1,8 @@
 name: Bug report
 description: Report a bug in an existing quirk or a general issue with the project.
 title: "[BUG] "
+labels: ["possible bug"]
+type: "bug"
 body:
   - type: markdown
     attributes:
@@ -64,7 +66,9 @@ body:
         <details><summary>Device signature</summary>
 
         ```json
+
         [Paste the device signature here]
+
         ```
 
         </details>
@@ -79,7 +83,9 @@ body:
         <details><summary>Diagnostic information</summary>
 
         ```json
+
         [Paste the diagnostic information here]
+
         ```
 
         </details>
@@ -99,7 +105,9 @@ body:
         <details><summary>Logs</summary>
 
         ```python
+
         [Paste the logs here]
+
         ```
 
         </details>
diff --git a/.github/ISSUE_TEMPLATE/device_support_request.yml b/.github/ISSUE_TEMPLATE/device_support_request.yml
index f6817da..8446e5e 100644
--- a/.github/ISSUE_TEMPLATE/device_support_request.yml
+++ b/.github/ISSUE_TEMPLATE/device_support_request.yml
@@ -1,6 +1,8 @@
 name: Device support request
 description: Request support for an unsupported device or a missing device feature.
 title: "[Device Support Request] "
+labels: ["device support request"]
+type: "feature"
 body:
   - type: markdown
     attributes:
@@ -53,7 +55,9 @@ body:
         <details><summary>Device signature</summary>
 
         ```json
+
         [Paste the device signature here]
+
         ```
 
         </details>
@@ -68,7 +72,9 @@ body:
         <details><summary>Diagnostic information</summary>
 
         ```json
+
         [Paste the diagnostic information here]
+
         ```
 
         </details>
@@ -88,7 +94,9 @@ body:
         <details><summary>Logs</summary>
 
         ```python
+
         [Paste the logs here]
+
         ```
 
         </details>
@@ -101,7 +109,9 @@ body:
         <details><summary>Custom quirk</summary>
 
         ```python
+
         [Paste your custom quirk here]
+
         ```
 
         </details>
diff --git a/zhaquirks/philips/__init__.py b/zhaquirks/philips/__init__.py
index 6b8b2df..9bad0f0 100644
--- a/zhaquirks/philips/__init__.py
+++ b/zhaquirks/philips/__init__.py
@@ -11,7 +11,12 @@ import zigpy.types as t
 from zigpy.zcl import foundation
 from zigpy.zcl.clusters.general import Basic
 from zigpy.zcl.clusters.measurement import OccupancySensing
-from zigpy.zcl.foundation import ZCLAttributeDef
+from zigpy.zcl.foundation import (
+    BaseCommandDefs,
+    Direction,
+    ZCLAttributeDef,
+    ZCLCommandDef,
+)
 
 from zhaquirks.const import (
     ARGS,
@@ -340,3 +345,21 @@ class PhilipsRwlRemoteCluster(PhilipsRemoteCluster):
         3: Button("down", DIM_DOWN),
         4: Button("off", TURN_OFF),
     }
+
+
+class PhilipsHueLightCluster(CustomCluster):
+    """Philips Hue manufacturer cluster."""
+
+    cluster_id: Final[t.uint16_t] = 0xFC03
+    ep_attribute: Final[str] = "philips_hue_light_cluster"
+    name: Final[str] = "PhilipsHueLightCluster"
+
+    class ServerCommandDefs(BaseCommandDefs):
+        """Server command definitions."""
+
+        multicolor: Final = ZCLCommandDef(
+            id=0x00,
+            schema={"data": t.SerializableBytes},
+            direction=Direction.Client_to_Server,
+            is_manufacturer_specific=True,
+        )
diff --git a/zhaquirks/philips/hue_light.py b/zhaquirks/philips/hue_light.py
new file mode 100644
index 0000000..8e4b016
--- /dev/null
+++ b/zhaquirks/philips/hue_light.py
@@ -0,0 +1,134 @@
+"""Philips Hue devices."""
+
+from zigpy.quirks.v2 import QuirkBuilder
+
+from zhaquirks.philips import PHILIPS, SIGNIFY, PhilipsHueLightCluster
+
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "LCX001")
+    .applies_to(SIGNIFY, "LCX002")
+    .applies_to(SIGNIFY, "LCX003")
+    .applies_to(SIGNIFY, "LCX005")
+    .applies_to(SIGNIFY, "LCX006")
+    .friendly_name(
+        model="Hue Play gradient lightstrip",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "LCX012")
+    .applies_to(SIGNIFY, "LCX015")
+    .applies_to(SIGNIFY, "LCX016")
+    .applies_to(SIGNIFY, "LCX017")
+    .friendly_name(
+        model="Hue Festavia gradient light string",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "LTB003")
+    .friendly_name(
+        model="Hue White Ambiance BR30 E26",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "929003116301")
+    .applies_to(SIGNIFY, "929003116401")
+    .applies_to(SIGNIFY, "929003116501")
+    .applies_to(SIGNIFY, "929003116601")
+    .friendly_name(
+        model="Hue Perifo light tube",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "4080248U9")
+    .applies_to(SIGNIFY, "915005987101")
+    .applies_to(SIGNIFY, "915005987201")
+    .applies_to(SIGNIFY, "915005987501")
+    .applies_to(SIGNIFY, "915005987601")
+    .applies_to(SIGNIFY, "915005987701")
+    .applies_to(SIGNIFY, "915005987801")
+    .applies_to(SIGNIFY, "929003479601")
+    .applies_to(SIGNIFY, "929003479701")
+    .friendly_name(
+        model="Hue Signe gradient floor lamp",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "915005986901")
+    .applies_to(SIGNIFY, "915005987001")
+    .applies_to(SIGNIFY, "915005987401")
+    .applies_to(SIGNIFY, "915005987301")
+    .friendly_name(
+        model="Hue Signe gradient table lamp",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "915005987901")
+    .applies_to(SIGNIFY, "915005988001")
+    .applies_to(SIGNIFY, "915005988101")
+    .applies_to(SIGNIFY, "915005988201")
+    .applies_to(SIGNIFY, "915005988401")
+    .applies_to(SIGNIFY, "915005988501")
+    .friendly_name(
+        model="Hue Play gradient light tube",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "929003116301")
+    .applies_to(SIGNIFY, "929003116401")
+    .applies_to(SIGNIFY, "929003116501")
+    .applies_to(SIGNIFY, "929003116601")
+    .friendly_name(
+        model="Hue Perifo light tube",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder()
+    .applies_to(PHILIPS, "7602031P7")
+    .applies_to(PHILIPS, "7602031U7")
+    .friendly_name(
+        model="Hue Go",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
diff --git a/zhaquirks/philips/soc001.py b/zhaquirks/philips/soc001.py
index 3a7bd84..87cf58e 100644
--- a/zhaquirks/philips/soc001.py
+++ b/zhaquirks/philips/soc001.py
@@ -6,6 +6,8 @@ from zigpy.quirks.v2 import BinarySensorDeviceClass, EntityType, QuirkBuilder
 from zigpy.zcl.clusters.general import OnOff
 from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
 
+from zhaquirks.philips import SIGNIFY
+
 
 class PhilipsContactCluster(CustomCluster):
     """Philips manufacturer specific cluster for contact sensor."""
@@ -60,7 +62,7 @@ class PhilipsContactCluster(CustomCluster):
     #  device_version=0
     #  input_clusters=[0, 1, 3, 64518]
     #  output_clusters=[0, 3, 6, 25]>
-    QuirkBuilder("Signify Netherlands B.V.", "SOC001")
+    QuirkBuilder(SIGNIFY, "SOC001")
     .replaces(PhilipsContactCluster, endpoint_id=2)
     .binary_sensor(
         "tamper",
diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py
index 9b2b32c..9d280fc 100644
--- a/zhaquirks/tuya/__init__.py
+++ b/zhaquirks/tuya/__init__.py
@@ -1609,6 +1609,15 @@ class TuyaNewManufCluster(CustomCluster):
                 dp_error = True
                 # return foundation.Status.UNSUPPORTED_ATTRIBUTE
 
+        _LOGGER.debug(
+            "[0x%04x:%s:0x%04x] Received value %s " "for attribute 0x%04x",
+            self.endpoint.device.nwk,
+            self.endpoint.endpoint_id,
+            self.cluster_id,
+            record.data.payload,
+            record.dp,
+        )
+
         return (
             foundation.Status.SUCCESS
             if not dp_error
diff --git a/zhaquirks/tuya/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index c8a92b8..fbcb50f 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -770,34 +770,43 @@ class TuyaQuirkBuilder(QuirkBuilder):
         return self
 
     def add_to_registry(
-        self, replacement_cluster: TuyaMCUCluster = TuyaMCUCluster
+        self,
+        replacement_cluster: TuyaMCUCluster = TuyaMCUCluster,
+        force_add_cluster: bool = False,
     ) -> QuirksV2RegistryEntry:
         """Build the quirks v2 registry entry."""
 
-        class NewAttributeDefs(TuyaMCUCluster.AttributeDefs):
-            """Attribute Definitions."""
+        if (
+            self.new_attributes
+            or self.tuya_data_point_handlers
+            or self.tuya_dp_to_attribute
+            or force_add_cluster
+        ):
 
-        for attr in self.new_attributes:
-            setattr(NewAttributeDefs, attr.name, attr)
+            class NewAttributeDefs(TuyaMCUCluster.AttributeDefs):
+                """Attribute Definitions."""
 
-        class TuyaReplacementCluster(replacement_cluster):  # type: ignore[valid-type]
-            """Replacement Tuya Cluster."""
+            for attr in self.new_attributes:
+                setattr(NewAttributeDefs, attr.name, attr)
 
-            data_point_handlers: dict[int, str]
-            dp_to_attribute: dict[int, DPToAttributeMapping]
+            class TuyaReplacementCluster(replacement_cluster):  # type: ignore[valid-type]
+                """Replacement Tuya Cluster."""
 
-            class AttributeDefs(NewAttributeDefs):
-                """Attribute Definitions."""
+                data_point_handlers: dict[int, str]
+                dp_to_attribute: dict[int, DPToAttributeMapping]
 
-            async def write_attributes(self, attributes, manufacturer=None):
-                """Overwrite to force manufacturer code."""
+                class AttributeDefs(NewAttributeDefs):
+                    """Attribute Definitions."""
 
-                return await super().write_attributes(
-                    attributes, manufacturer=foundation.ZCLHeader.NO_MANUFACTURER_ID
-                )
+                async def write_attributes(self, attributes, manufacturer=None):
+                    """Overwrite to force manufacturer code."""
+
+                    return await super().write_attributes(
+                        attributes, manufacturer=foundation.ZCLHeader.NO_MANUFACTURER_ID
+                    )
 
-        TuyaReplacementCluster.data_point_handlers = self.tuya_data_point_handlers
-        TuyaReplacementCluster.dp_to_attribute = self.tuya_dp_to_attribute
+            TuyaReplacementCluster.data_point_handlers = self.tuya_data_point_handlers
+            TuyaReplacementCluster.dp_to_attribute = self.tuya_dp_to_attribute
 
-        self.replaces(TuyaReplacementCluster)
+            self.replaces(TuyaReplacementCluster)
         return super().add_to_registry()
diff --git a/zhaquirks/tuya/ts011f_plug.py b/zhaquirks/tuya/ts011f_plug.py
index 99ea8ea..f4beb17 100644
--- a/zhaquirks/tuya/ts011f_plug.py
+++ b/zhaquirks/tuya/ts011f_plug.py
@@ -1295,6 +1295,57 @@ class Plug_v4(EnchantedDevice):
     }
 
 
+class Plug_v6(EnchantedDevice):
+    """Another TS011F Tuya plug. This one using this definition is _TZ3000_r6buo8ba."""
+
+    signature = {
+        MODEL: "TS011F",
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    Metering.cluster_id,
+                    ElectricalMeasurement.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    OnOff.cluster_id,
+                    Ota.cluster_id,
+                    TuyaZBExternalSwitchTypeCluster.cluster_id,
+                ],
+            },
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    TuyaZBOnOffAttributeCluster,
+                    TuyaZBMeteringClusterWithUnit,
+                    TuyaZBElectricalMeasurement,
+                    TuyaZBExternalSwitchTypeCluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Ota.cluster_id,
+                ],
+            },
+        },
+    }
+
+
 class Plug_v5(EnchantedDevice):
     """Another TS011F Tuya plug. This one using this definition is _TZ3000_okaz9tjs."""
 
diff --git a/zhaquirks/tuya/ts0601_din_power.py b/zhaquirks/tuya/ts0601_din_power.py
index d9c1b76..b478794 100644
--- a/zhaquirks/tuya/ts0601_din_power.py
+++ b/zhaquirks/tuya/ts0601_din_power.py
@@ -1,6 +1,8 @@
 """Tuya Din Power Meter."""
 
 from zigpy.profiles import zha
+from zigpy.quirks.v2 import SensorDeviceClass, SensorStateClass
+from zigpy.quirks.v2.homeassistant import PERCENTAGE, UnitOfEnergy
 import zigpy.types as t
 from zigpy.zcl.clusters.general import Basic, Groups, Ota, Scenes, Time
 from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
@@ -15,7 +17,13 @@ from zhaquirks.const import (
     OUTPUT_CLUSTERS,
     PROFILE_ID,
 )
-from zhaquirks.tuya import TuyaManufClusterAttributes, TuyaOnOff, TuyaSwitch
+from zhaquirks.tuya import (
+    TuyaLocalCluster,
+    TuyaManufClusterAttributes,
+    TuyaOnOff,
+    TuyaSwitch,
+)
+from zhaquirks.tuya.builder import TuyaQuirkBuilder
 
 TUYA_TOTAL_ENERGY_ATTR = 0x0211
 TUYA_CURRENT_ATTR = 0x0212
@@ -233,6 +241,197 @@ class TuyaPowerMeter(TuyaSwitch):
     }
 
 
+class Tuya3PhaseElectricalMeasurement(ElectricalMeasurement, TuyaLocalCluster):
+    """Tuya Electrical Measurement cluster."""
+
+    _CONSTANT_ATTRIBUTES = {
+        ElectricalMeasurement.AttributeDefs.ac_current_divisor.id: 1000,
+        ElectricalMeasurement.AttributeDefs.ac_voltage_divisor.id: 10,
+        ElectricalMeasurement.AttributeDefs.ac_frequency_divisor.id: 100,
+    }
+
+
+(
+    TuyaQuirkBuilder("_TZE204_ugekduaj", "TS0601")
+    .tuya_dp(
+        dp_id=101,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="ac_frequency",
+    )
+    # Energy consumed
+    .tuya_sensor(
+        dp_id=1,
+        attribute_name="energy_consumed",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        fallback_name="Total energy",
+    )
+    .tuya_sensor(
+        dp_id=112,
+        attribute_name="energy_consumed_ph_a",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_ph_a",
+        fallback_name="Energy phase A",
+    )
+    .tuya_sensor(
+        dp_id=114,
+        attribute_name="energy_consumed_ph_b",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_ph_b",
+        fallback_name="Energy phase B",
+    )
+    .tuya_sensor(
+        dp_id=116,
+        attribute_name="energy_consumed_ph_c",
+        divisor=100,
+        type=t.uint32_t,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_ph_c",
+        fallback_name="Energy phase C",
+    )
+    # Energy produced
+    .tuya_sensor(
+        dp_id=2,
+        attribute_name="energy_produced",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_produced",
+        fallback_name="Energy produced",
+    )
+    .tuya_sensor(
+        dp_id=113,
+        attribute_name="energy_produced_ph_a",
+        type=t.uint32_t,
+        converter=lambda x: x / 100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_produced_ph_a",
+        fallback_name="Energy produced phase A",
+    )
+    .tuya_sensor(
+        dp_id=115,
+        attribute_name="energy_produced_ph_b",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_produced_ph_b",
+        fallback_name="Energy produced phase B",
+    )
+    .tuya_sensor(
+        dp_id=117,
+        attribute_name="energy_produced_ph_c",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_produced_ph_c",
+        fallback_name="Energy produced phase C",
+    )
+    # Power
+    .tuya_dp(
+        dp_id=111,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="total_active_power",
+    )
+    .tuya_dp(
+        dp_id=104,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="active_power",
+    )
+    .tuya_dp(
+        dp_id=107,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="active_power_ph_b",
+    )
+    .tuya_dp(
+        dp_id=110,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="active_power_ph_c",
+    )
+    # Voltage
+    .tuya_dp(
+        dp_id=102,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_voltage",
+    )
+    .tuya_dp(
+        dp_id=105,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_voltage_ph_b",
+    )
+    .tuya_dp(
+        dp_id=108,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_voltage_ph_c",
+    )
+    # Current
+    .tuya_dp(
+        dp_id=103,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_current",
+    )
+    .tuya_dp(
+        dp_id=106,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_current_ph_b",
+    )
+    .tuya_dp(
+        dp_id=109,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_current_ph_c",
+    )
+    # Power factor
+    .tuya_sensor(
+        dp_id=15,
+        attribute_name="power_factor",
+        type=t.uint8_t,
+        state_class=SensorStateClass.MEASUREMENT,
+        device_class=SensorDeviceClass.POWER_FACTOR,
+        unit=PERCENTAGE,
+        translation_key="total_power_factor",
+        fallback_name="Total power factor",
+    )
+    .tuya_dp(
+        dp_id=118,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="power_factor",
+    )
+    .tuya_dp(
+        dp_id=119,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="power_factor_ph_b",
+    )
+    .tuya_dp(
+        dp_id=120,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="power_factor_ph_c",
+    )
+    .adds(Tuya3PhaseElectricalMeasurement)
+    .skip_configuration()
+    .add_to_registry()
+)
+
+
 class HikingPowerMeter(TuyaSwitch):
     """Hiking Power Meter Device - DDS238-2."""
 
diff --git a/zhaquirks/tuya/ts0601_motion.py b/zhaquirks/tuya/tuya_motion.py
similarity index 99%
rename from zhaquirks/tuya/ts0601_motion.py
rename to zhaquirks/tuya/tuya_motion.py
index 793fc68..ccbf691 100644
--- a/zhaquirks/tuya/ts0601_motion.py
+++ b/zhaquirks/tuya/tuya_motion.py
@@ -245,8 +245,8 @@ base_tuya_motion = (
         min_value=1,
         max_value=15000,
         step=1,
-        translation_key="presence_timeout",
-        fallback_name="Fade time",
+        translation_key="fading_time",
+        fallback_name="Fading time",
     )
     .add_to_registry()
 )
@@ -384,7 +384,6 @@ base_tuya_motion = (
         min_value=0.1,
         max_value=10,
         step=0.1,
-        multiplier=1,
         translation_key="detection_delay",
         fallback_name="Detection delay",
     )
@@ -397,7 +396,6 @@ base_tuya_motion = (
         min_value=5,
         max_value=1500,
         step=5,
-        multiplier=1,
         translation_key="fading_time",
         fallback_name="Fading time",
     )
@@ -982,15 +980,15 @@ base_tuya_motion = (
     )
     .tuya_number(
         dp_id=105,
-        attribute_name="presence_timeout",
+        attribute_name="fading_time",
         type=t.uint16_t,
         device_class=SensorDeviceClass.DURATION,
         unit=UnitOfTime.MINUTES,
         min_value=1,
         max_value=30,
         step=1,
-        translation_key="presence_timeout",
-        fallback_name="Fade time",
+        translation_key="fading_time",
+        fallback_name="Fading time",
     )
     .skip_configuration()
     .add_to_registry()
diff --git a/zhaquirks/tuya/tuya_sensor.py b/zhaquirks/tuya/tuya_sensor.py
index 486f518..f63f6b1 100644
--- a/zhaquirks/tuya/tuya_sensor.py
+++ b/zhaquirks/tuya/tuya_sensor.py
@@ -33,6 +33,26 @@ class TuyaNousTempHumiAlarm(t.enum8):
     Canceled = 0x02
 
 
+class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
+    """Tuya Manufacturer Cluster with set_time mod."""
+
+    set_time_offset = 1970
+    set_time_local_offset = 1970
+
+    # Deepcopy required to override 'set_time', without, it will revert
+    server_commands = copy.deepcopy(TuyaMCUCluster.server_commands)
+    server_commands.update(
+        {
+            TUYA_SET_TIME: foundation.ZCLCommandDef(
+                "set_time",
+                {"time": TuyaTimePayload},
+                False,
+                is_manufacturer_specific=False,
+            ),
+        }
+    )
+
+
 (
     TuyaQuirkBuilder("_TZE200_bjawzodf", "TS0601")
     .applies_to("_TZE200_zl1kmjqx", "TS0601")
@@ -89,26 +109,6 @@ class TuyaNousTempHumiAlarm(t.enum8):
 )
 
 
-class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
-    """Tuya Manufacturer Cluster with set_time mod."""
-
-    set_time_offset = 1970
-    set_time_local_offset = 1970
-
-    # Deepcopy required to override 'set_time', without, it will revert
-    server_commands = copy.deepcopy(TuyaMCUCluster.server_commands)
-    server_commands.update(
-        {
-            TUYA_SET_TIME: foundation.ZCLCommandDef(
-                "set_time",
-                {"time": TuyaTimePayload},
-                False,
-                is_manufacturer_specific=False,
-            ),
-        }
-    )
-
-
 # TH01Z - Temperature and humidity sensor with clock
 (
     TuyaQuirkBuilder("_TZE200_lve3dvpy", "TS0601")
@@ -311,3 +311,20 @@ class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
     .skip_configuration()
     .add_to_registry()
 )
+
+
+(
+    TuyaQuirkBuilder("_TZE204_upagmta9", "TS0601")
+    .tuya_temperature(dp_id=1, scale=10)
+    .tuya_humidity(dp_id=2)
+    .tuya_dp(
+        dp_id=3,
+        ep_attribute=TuyaPowerConfigurationCluster2AAA.ep_attribute,
+        attribute_name="battery_percentage_remaining",
+        converter=lambda x: {0: 50, 1: 100, 2: 200}[x],
+    )
+    .adds(TuyaPowerConfigurationCluster2AAA)
+    .tuya_enchantment(data_query_spell=True)
+    .skip_configuration()
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/tuya_smoke.py b/zhaquirks/tuya/tuya_smoke.py
index c4e9da4..9819331 100644
--- a/zhaquirks/tuya/tuya_smoke.py
+++ b/zhaquirks/tuya/tuya_smoke.py
@@ -9,7 +9,11 @@ from zigpy.zcl.clusters.security import IasZone
 from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
 
 from zhaquirks import LocalDataCluster
-from zhaquirks.tuya import TuyaManufClusterAttributes, TuyaPowerConfigurationCluster2AAA
+from zhaquirks.tuya import (
+    BatterySize,
+    TuyaManufClusterAttributes,
+    TuyaPowerConfigurationCluster2AAA,
+)
 from zhaquirks.tuya.builder import TuyaQuirkBuilder
 
 
@@ -60,8 +64,23 @@ class TuyaSmokeDetectorCluster(TuyaManufClusterAttributes):
     .applies_to("_TZE284_rccxox8p", "TS0601")
     .applies_to("_TZE200_vzekyi4c", "TS0601")
     .applies_to("_TZE204_vawy74yh", "TS0601")
+    .tuya_smoke(dp_id=1)
+    .skip_configuration()
+    .add_to_registry()
+)
+
+(
+    TuyaQuirkBuilder("TZE200_0zaf1cr8", "TS0601")
     .applies_to("_TZE284_0zaf1cr8", "TS0601")
     .tuya_smoke(dp_id=1)
+    .tuya_binary_sensor(
+        dp_id=14,
+        attribute_name="battery_low",
+        device_class=BinarySensorDeviceClass.BATTERY,
+        entity_type=EntityType.DIAGNOSTIC,
+        fallback_name="Battery low",
+    )
+    .tuya_battery(dp_id=15, battery_type=BatterySize.CR123A, battery_qty=1)
     .skip_configuration()
     .add_to_registry()
 )
diff --git a/zhaquirks/tuya/ty0201.py b/zhaquirks/tuya/ty0201.py
index a50822a..b5a637e 100644
--- a/zhaquirks/tuya/ty0201.py
+++ b/zhaquirks/tuya/ty0201.py
@@ -1,17 +1,14 @@
 """Tuya TY0201 temperature and humidity sensor."""
 
-from zigpy.quirks.v2 import QuirkBuilder
-
 from zhaquirks.tuya import TuyaPowerConfigurationCluster2AA
-from zhaquirks.tuya.builder import TuyaRelativeHumidity, TuyaTemperatureMeasurement
+from zhaquirks.tuya.builder import TuyaQuirkBuilder
 
 (
-    QuirkBuilder("_TZ3000_bjawzodf", "TY0201")
+    TuyaQuirkBuilder("_TZ3000_bjawzodf", "TY0201")
     .applies_to("_TZ3000_zl1kmjqx", "TY0201")
     .applies_to("_TZ3000_zl1kmjqx", "")
-    .replaces(TuyaRelativeHumidity)
-    .replaces(TuyaTemperatureMeasurement)
     .replaces(TuyaPowerConfigurationCluster2AA)
+    .tuya_enchantment()
     .skip_configuration()
     .add_to_registry()
 )


Test patch:
diff --git a/tests/test_tuya.py b/tests/test_tuya.py
index a3e637b..7cd634a 100644
--- a/tests/test_tuya.py
+++ b/tests/test_tuya.py
@@ -35,10 +35,10 @@ import zhaquirks.tuya.ts0043
 import zhaquirks.tuya.ts011f_plug
 import zhaquirks.tuya.ts0501_fan_switch
 import zhaquirks.tuya.ts0601_electric_heating
-import zhaquirks.tuya.ts0601_motion
 import zhaquirks.tuya.ts0601_trv
 import zhaquirks.tuya.ts601_door
 import zhaquirks.tuya.ts1201
+import zhaquirks.tuya.tuya_motion
 import zhaquirks.tuya.tuya_valve
 
 zhaquirks.setup()
diff --git a/tests/test_tuya_builder.py b/tests/test_tuya_builder.py
index 10ad800..a25fccb 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -364,3 +364,34 @@ async def test_tuya_mcu_set_time(device_mock):
         assert not res_hdr[0].frame_control.is_manufacturer_specific
 
     datetime.datetime = origdatetime  # restore datetime
+
+
+@pytest.mark.parametrize(
+    "force",
+    [
+        (False),
+        (True),
+    ],
+)
+async def test_tuya_quirkbuilder_force(device_mock, force):
+    """Test adding an empty TuyaQuirkBuilder doesn't add a MCU cluster unless forced to."""
+
+    registry = DeviceRegistry()
+
+    (
+        TuyaQuirkBuilder(device_mock.manufacturer, device_mock.model, registry=registry)
+        .skip_configuration()
+        .add_to_registry(force_add_cluster=force)
+    )
+
+    quirked = registry.get_device(device_mock)
+    assert isinstance(quirked, CustomDeviceV2)
+    assert quirked in registry
+
+    ep = quirked.endpoints[1]
+
+    if force:
+        assert ep.tuya_manufacturer is not None
+        assert isinstance(ep.tuya_manufacturer, TuyaMCUCluster)
+    else:
+        assert not hasattr(ep, "tuya_manufacturer")
diff --git a/tests/test_tuya_sensor.py b/tests/test_tuya_sensor.py
index 2628e54..58a672f 100644
--- a/tests/test_tuya_sensor.py
+++ b/tests/test_tuya_sensor.py
@@ -9,6 +9,14 @@ import zhaquirks
 from zhaquirks.tuya import TuyaLocalCluster
 from zhaquirks.tuya.mcu import TuyaMCUCluster
 
+# Temp DP 1, Humidity DP 2, Battery DP 3
+TUYA_TEMP01_HUM02_BAT03 = b"\x09\xe0\x02\x0b\x33\x01\x02\x00\x04\x00\x00\x00\xfd\x02\x02\x00\x04\x00\x00\x00\x47\x03\x02\x00\x04\x00\x00\x00\x01"
+# Temp DP 1, Humidity DP 2, Battery DP 4
+TUYA_TEMP01_HUM02_BAT04 = b"\x09\xe0\x02\x0b\x33\x01\x02\x00\x04\x00\x00\x00\xfd\x02\x02\x00\x04\x00\x00\x00\x47\x04\x02\x00\x04\x00\x00\x00\x01"
+TUYA_USP = b"\x09\xe0\x02\x0b\x33\x01\x02\x00\x04\x00\x00\x00\xfd\x02\x02\x00\x04\x00\x00\x00\x47\xff\x02\x00\x04\x00\x00\x00\x64"
+
+ZCL_TUYA_VERSION_RSP = b"\x09\x06\x11\x01\x6d\x82"
+
 zhaquirks.setup()
 
 
@@ -74,8 +82,7 @@ async def test_handle_get_data(
         == data.data.datapoints[2].data.payload * 2
     )
 
-    message = b"\x09\xe0\x02\x0b\x33\x01\x02\x00\x04\x00\x00\x00\xfd\x02\x02\x00\x04\x00\x00\x00\x47\xff\x02\x00\x04\x00\x00\x00\x64"
-    hdr, data = ep.tuya_manufacturer.deserialize(message)
+    hdr, data = ep.tuya_manufacturer.deserialize(TUYA_USP)
 
     status = ep.tuya_manufacturer.handle_get_data(data.data)
     assert status == foundation.Status.UNSUPPORTED_ATTRIBUTE
@@ -91,19 +98,21 @@ async def test_handle_get_data(
 
 
 @pytest.mark.parametrize(
-    "model,manuf,rh_scale,temp_scale",
+    "model,manuf,rh_scale,temp_scale,state_rpt",
     [
-        ("_TZE200_yjjdcqsq", "TS0601", 100, 10),
-        ("_TZE200_9yapgbuv", "TS0601", 100, 10),
-        ("_TZE204_yjjdcqsq", "TS0601", 100, 10),
-        ("_TZE200_utkemkbs", "TS0601", 100, 10),
-        ("_TZE204_utkemkbs", "TS0601", 100, 10),
-        ("_TZE204_yjjdcqsq", "TS0601", 100, 10),
-        ("_TZE204_ksz749x8", "TS0601", 100, 10),
+        ("_TZE200_yjjdcqsq", "TS0601", 100, 10, TUYA_TEMP01_HUM02_BAT04),
+        ("_TZE200_9yapgbuv", "TS0601", 100, 10, TUYA_TEMP01_HUM02_BAT04),
+        ("_TZE204_yjjdcqsq", "TS0601", 100, 10, TUYA_TEMP01_HUM02_BAT04),
+        ("_TZE200_utkemkbs", "TS0601", 100, 10, TUYA_TEMP01_HUM02_BAT04),
+        ("_TZE204_utkemkbs", "TS0601", 100, 10, TUYA_TEMP01_HUM02_BAT04),
+        ("_TZE204_yjjdcqsq", "TS0601", 100, 10, TUYA_TEMP01_HUM02_BAT04),
+        ("_TZE204_ksz749x8", "TS0601", 100, 10, TUYA_TEMP01_HUM02_BAT04),
+        ("_TZE204_upagmta9", "TS0601", 100, 10, TUYA_TEMP01_HUM02_BAT03),
+        ("_TZE204_upagmta9", "TS0601", 100, 10, TUYA_TEMP01_HUM02_BAT03),
     ],
 )
 async def test_handle_get_data_enum_batt(
-    zigpy_device_from_v2_quirk, model, manuf, rh_scale, temp_scale
+    zigpy_device_from_v2_quirk, model, manuf, rh_scale, temp_scale, state_rpt
 ):
     """Test handle_get_data for multiple attributes - enum battery."""
 
@@ -116,8 +125,7 @@ async def test_handle_get_data_enum_batt(
     assert ep.tuya_manufacturer is not None
     assert isinstance(ep.tuya_manufacturer, TuyaMCUCluster)
 
-    message = b"\x09\xe0\x02\x0b\x33\x01\x02\x00\x04\x00\x00\x00\xfd\x02\x02\x00\x04\x00\x00\x00\x47\x04\x02\x00\x04\x00\x00\x00\x01"
-    hdr, data = ep.tuya_manufacturer.deserialize(message)
+    hdr, data = ep.tuya_manufacturer.deserialize(state_rpt)
 
     status = ep.tuya_manufacturer.handle_get_data(data.data)
 
@@ -135,8 +143,7 @@ async def test_handle_get_data_enum_batt(
 
     assert ep.power.get("battery_percentage_remaining") == 100
 
-    message = b"\x09\xe0\x02\x0b\x33\x01\x02\x00\x04\x00\x00\x00\xfd\x02\x02\x00\x04\x00\x00\x00\x47\xff\x02\x00\x04\x00\x00\x00\x64"
-    hdr, data = ep.tuya_manufacturer.deserialize(message)
+    hdr, data = ep.tuya_manufacturer.deserialize(TUYA_USP)
 
     status = ep.tuya_manufacturer.handle_get_data(data.data)
     assert status == foundation.Status.UNSUPPORTED_ATTRIBUTE