Repository: zigpy/zha-device-handlers
Pull request #3951 (2025-03-11 10:57:59)

Add Tuya curtain motor `_TZE200_hojryzzd` variant
Add curtain motor _TZE200_hojryzzd produced by Roximo, model CRTZ02

## 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".
-->


## 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/3951?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 91.00%. Comparing base [(`b87ac89`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/b87ac8907765303c915b19d915a1ca5ea9a8bdae?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`6b46295`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/6b462959cdb628657abb1297bb5fa6e6984d8231?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    #3951   +/-   ##
=======================================
  Coverage   91.00%   91.00%           
=======================================
  Files         328      328           
  Lines       10656    10656           
=======================================
  Hits         9698     9698           
  Misses        958      958           
```

</details>

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

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/script/setup b/script/setup
index de03cd3..8cc1e30 100755
--- a/script/setup
+++ b/script/setup
@@ -14,10 +14,21 @@ if [ ! -f "$SETTINGS_FILE" ]; then
     cp "$SETTINGS_TEMPLATE_FILE" "$SETTINGS_FILE"
 fi
 
-python3 -m venv venv
-source venv/bin/activate
 
-pip install -r requirements_test.txt
-pre-commit install
+if [ ! -n "$VIRTUAL_ENV" ]; then
+  if [ -x "$(command -v uv)" ]; then
+    uv venv venv
+  else
+    python3 -m venv venv
+  fi
+  source venv/bin/activate
+fi
+
+if ! [ -x "$(command -v uv)" ]; then
+  python3 -m pip install uv
+fi
+
+uv pip install -r requirements_test.txt
+uv pip install -e .
 
-python3 -m pip install -e .
+pre-commit install
diff --git a/zhaquirks/nodon/roller_shutter.py b/zhaquirks/nodon/roller_shutter.py
new file mode 100644
index 0000000..f3b832c
--- /dev/null
+++ b/zhaquirks/nodon/roller_shutter.py
@@ -0,0 +1,154 @@
+"""NodOn Roller Shutter Relay Switch."""
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2.homeassistant import UnitOfTime
+from zigpy.quirks.v2.homeassistant.number import NumberDeviceClass
+import zigpy.types as t
+from zigpy.zcl.clusters.closures import WindowCovering
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
+
+NODON = "NodOn"
+
+
+class NodOnWindowCovering(WindowCovering, CustomCluster):
+    """NodOn custom WindowCovering cluster."""
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Attribute definitions."""
+
+        # IDs 0x0001-0x0004 redefined below
+        window_covering_type = (
+            WindowCovering.AttributeDefs.window_covering_type
+        )  # 0x0000
+        number_of_actuations_lift = (
+            WindowCovering.AttributeDefs.number_of_actuations_lift
+        )  # 0x0005
+        number_of_actuations_tilt = (
+            WindowCovering.AttributeDefs.number_of_actuations_tilt
+        )  # 0x0006
+        config_status = WindowCovering.AttributeDefs.config_status  # 0x0007
+        current_position_lift_percentage = (
+            WindowCovering.AttributeDefs.current_position_lift_percentage
+        )  # 0x0008
+        current_position_tilt_percentage = (
+            WindowCovering.AttributeDefs.current_position_tilt_percentage
+        )  # 0x0009
+        installed_open_limit_lift = (
+            WindowCovering.AttributeDefs.installed_open_limit_lift
+        )  # 0x0010
+        installed_closed_limit_lift = (
+            WindowCovering.AttributeDefs.installed_closed_limit_lift
+        )  # 0x0011
+        installed_open_limit_tilt = (
+            WindowCovering.AttributeDefs.installed_open_limit_tilt
+        )  # 0x0012
+        installed_closed_limit_tilt = (
+            WindowCovering.AttributeDefs.installed_closed_limit_tilt
+        )  # 0x0013
+        velocity_lift = WindowCovering.AttributeDefs.velocity_lift  # 0x0014
+        acceleration_time_lift = (
+            WindowCovering.AttributeDefs.acceleration_time_lift
+        )  # 0x0015
+        deceleration_time_lift = (
+            WindowCovering.AttributeDefs.deceleration_time_lift
+        )  # 0x0016
+        window_covering_mode = (
+            WindowCovering.AttributeDefs.window_covering_mode
+        )  # 0x0017
+        intermediate_setpoints_lift = (
+            WindowCovering.AttributeDefs.intermediate_setpoints_lift
+        )  # 0x0018
+        intermediate_setpoints_tilt = (
+            WindowCovering.AttributeDefs.intermediate_setpoints_tilt
+        )  # 0x0019
+
+        # porting https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/src/devices/nodon.ts#L16
+
+        # Set vertical run time up of the roller shutter.
+        calibration_vertical_run_time_up = ZCLAttributeDef(
+            id=0x0001,
+            type=t.uint16_t,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+
+        # Set vertical run time down of the roller shutter.
+        calibration_vertical_run_time_down = ZCLAttributeDef(
+            id=0x0002,
+            type=t.uint16_t,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+
+        # Set rotation run time up of the roller shutter.
+        calibration_rotation_run_time_up = ZCLAttributeDef(
+            id=0x0003,
+            type=t.uint16_t,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+
+        # Set rotation run time down of the roller shutter.
+        calibration_rotation_run_time_down = ZCLAttributeDef(
+            id=0x0004,
+            type=t.uint16_t,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+
+
+(
+    QuirkBuilder(NODON, "SIN-4-RS-20")
+    .applies_to(NODON, "SIN-4-RS-20_PRO")
+    .replaces(NodOnWindowCovering)
+    .number(
+        attribute_name=NodOnWindowCovering.AttributeDefs.calibration_vertical_run_time_up.name,
+        cluster_id=NodOnWindowCovering.cluster_id,
+        min_value=0,
+        max_value=65535,
+        step=10,
+        unit=UnitOfTime.MILLISECONDS,
+        device_class=NumberDeviceClass.DURATION,
+        initially_disabled=True,
+        translation_key="calibration_vertical_run_time_up",
+        fallback_name="Calibration vertical run time up",
+    )
+    .number(
+        attribute_name=NodOnWindowCovering.AttributeDefs.calibration_vertical_run_time_down.name,
+        cluster_id=NodOnWindowCovering.cluster_id,
+        min_value=0,
+        max_value=65535,
+        step=10,
+        unit=UnitOfTime.MILLISECONDS,
+        device_class=NumberDeviceClass.DURATION,
+        initially_disabled=True,
+        translation_key="calibration_vertical_run_time_down",
+        fallback_name="Calibration vertical run time down",
+    )
+    .number(
+        attribute_name=NodOnWindowCovering.AttributeDefs.calibration_rotation_run_time_up.name,
+        cluster_id=NodOnWindowCovering.cluster_id,
+        min_value=0,
+        max_value=65535,
+        step=10,
+        unit=UnitOfTime.MILLISECONDS,
+        device_class=NumberDeviceClass.DURATION,
+        initially_disabled=True,
+        translation_key="calibration_rotation_run_time_up",
+        fallback_name="Calibration rotation run time up",
+    )
+    .number(
+        attribute_name=NodOnWindowCovering.AttributeDefs.calibration_rotation_run_time_down.name,
+        cluster_id=NodOnWindowCovering.cluster_id,
+        min_value=0,
+        max_value=65535,
+        step=10,
+        unit=UnitOfTime.MILLISECONDS,
+        device_class=NumberDeviceClass.DURATION,
+        initially_disabled=True,
+        translation_key="calibration_rotation_run_time_down",
+        fallback_name="Calibration rotation run time down",
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/nodon/switch.py b/zhaquirks/nodon/switch.py
index 30e09e1..cbcb926 100644
--- a/zhaquirks/nodon/switch.py
+++ b/zhaquirks/nodon/switch.py
@@ -1,14 +1,118 @@
 """NodOn on/off switch two channels."""
 
+from zigpy.quirks import CustomCluster
 from zigpy.quirks.v2 import QuirkBuilder
-from zigpy.zcl.clusters.general import LevelControl
+from zigpy.quirks.v2.homeassistant import UnitOfTime
+from zigpy.quirks.v2.homeassistant.number import NumberDeviceClass
+import zigpy.types as t
+from zigpy.zcl.clusters.general import LevelControl, OnOff
+from zigpy.zcl.foundation import DataTypeId, ZCLAttributeDef
 
 NODON = "NodOn"
 
+
+class NodOnSwitchType(t.enum8):
+    """NodOn switch type."""
+
+    Bistable = 0x00
+    Monostable = 0x01
+    AutoDetect = 0x02
+
+
+class OnOffSIN4_1_20(OnOff, CustomCluster):
+    """NodOn custom OnOff cluster for SIN-4-1-20 and alike."""
+
+    class AttributeDefs(OnOff.AttributeDefs):
+        """Attribute definitions."""
+
+        """Select the switch type wire to the device. Available from version > V3.4.0."""
+        switch_type = ZCLAttributeDef(
+            id=0x1001,
+            type=NodOnSwitchType,
+            zcl_type=DataTypeId.enum8,  # need to explicitly set ZCL type
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+
+        """Set the impulse duration in milliseconds (set value to 0 to deactivate the impulse mode). """
+        impulse_mode_duration = ZCLAttributeDef(
+            id=0x0001,
+            type=t.uint16_t,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+
+
+class OnOffSIN4_2_20(OnOff, CustomCluster):
+    """NodOn custom OnOff cluster for SIN-4-2-20 and alike."""
+
+    class AttributeDefs(OnOff.AttributeDefs):
+        """Attribute definitions."""
+
+        """Select the switch type wire to the device. Available from version > V3.4.0."""
+        switch_type = ZCLAttributeDef(
+            id=0x1001,
+            type=NodOnSwitchType,
+            zcl_type=DataTypeId.enum8,  # need to explicitly set ZCL type
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+
+
+(
+    # quirk is similar to https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/src/devices/nodon.ts#L100
+    QuirkBuilder(NODON, "SIN-4-1-20")
+    .applies_to(NODON, "SIN-4-1-21")
+    .applies_to(NODON, "SIN-4-1-20_PRO")
+    .replaces(OnOffSIN4_1_20)
+    .enum(
+        attribute_name=OnOffSIN4_1_20.AttributeDefs.switch_type.name,
+        enum_class=NodOnSwitchType,
+        cluster_id=OnOffSIN4_1_20.cluster_id,
+        initially_disabled=True,
+        translation_key="switch_type",
+        fallback_name="Switch type",
+    )
+    .number(
+        attribute_name=OnOffSIN4_1_20.AttributeDefs.impulse_mode_duration.name,
+        cluster_id=OnOffSIN4_1_20.cluster_id,
+        min_value=0,
+        max_value=10000,
+        step=1,
+        unit=UnitOfTime.MILLISECONDS,
+        device_class=NumberDeviceClass.DURATION,
+        initially_disabled=True,
+        translation_key="impulse_mode_duration",
+        fallback_name="Impulse mode duration",
+    )
+    .add_to_registry()
+)
+
 (
     # this quirk is a v2 version of 7397b6a
     QuirkBuilder(NODON, "SIN-4-2-20")
+    .applies_to(NODON, "SIN-4-2-20_PRO")
     .removes(cluster_id=LevelControl.cluster_id, endpoint_id=1)
     .removes(cluster_id=LevelControl.cluster_id, endpoint_id=2)
+    .replaces(OnOffSIN4_2_20, endpoint_id=1)
+    .replaces(OnOffSIN4_2_20, endpoint_id=2)
+    .enum(
+        attribute_name=OnOffSIN4_1_20.AttributeDefs.switch_type.name,
+        enum_class=NodOnSwitchType,
+        cluster_id=OnOffSIN4_1_20.cluster_id,
+        endpoint_id=1,
+        initially_disabled=True,
+        translation_key="switch_type",
+        fallback_name="Switch type",
+    )
+    .enum(
+        attribute_name=OnOffSIN4_1_20.AttributeDefs.switch_type.name,
+        enum_class=NodOnSwitchType,
+        cluster_id=OnOffSIN4_1_20.cluster_id,
+        endpoint_id=2,
+        initially_disabled=True,
+        translation_key="switch_type",
+        fallback_name="Switch type",
+    )
     .add_to_registry()
 )
diff --git a/zhaquirks/sonoff/snzb02d.py b/zhaquirks/sonoff/snzb02d.py
new file mode 100644
index 0000000..ff85524
--- /dev/null
+++ b/zhaquirks/sonoff/snzb02d.py
@@ -0,0 +1,109 @@
+"""Sonoff SNZB-02D - Zigbee LCD smart temperature humidity sensor."""
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2.homeassistant import PERCENTAGE, UnitOfTemperature
+import zigpy.types as t
+from zigpy.zcl import foundation
+from zigpy.zcl.foundation import BaseAttributeDefs, DataTypeId, ZCLAttributeDef
+
+
+class TemperatureUnit(t.enum16):
+    """Temperature unit."""
+
+    Celsius = 0
+    Fahrenheit = 1
+
+
+class CustomSonoffCluster(CustomCluster):
+    """Sonoff custom cluster."""
+
+    cluster_id = 0xFC11
+    manufacturer_id_override: t.uint16_t = foundation.ZCLHeader.NO_MANUFACTURER_ID
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Attribute definitions."""
+
+        comfort_temperature_max = ZCLAttributeDef(
+            id=0x0003,
+            type=t.int16s,
+        )
+
+        comfort_temperature_min = ZCLAttributeDef(
+            id=0x0004,
+            type=t.int16s,
+        )
+
+        comfort_humidity_min = ZCLAttributeDef(
+            id=0x0005,
+            type=t.uint16_t,
+        )
+
+        comfort_humidity_max = ZCLAttributeDef(
+            id=0x0006,
+            type=t.uint16_t,
+        )
+
+        temperature_unit = ZCLAttributeDef(
+            id=0x0007,
+            type=TemperatureUnit,
+            zcl_type=DataTypeId.uint16,
+        )
+
+
+(
+    QuirkBuilder("SONOFF", "SNZB-02D")
+    .replaces(CustomSonoffCluster)
+    .number(
+        CustomSonoffCluster.AttributeDefs.comfort_temperature_min.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=-10,
+        max_value=60,
+        step=0.1,
+        unit=UnitOfTemperature.CELSIUS,
+        multiplier=0.01,
+        translation_key="comfort_temperature_min",
+        fallback_name="Comfort temperature min",
+    )
+    .number(
+        CustomSonoffCluster.AttributeDefs.comfort_temperature_max.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=-10,
+        max_value=60,
+        step=0.1,
+        unit=UnitOfTemperature.CELSIUS,
+        multiplier=0.01,
+        translation_key="comfort_temperature_max",
+        fallback_name="Comfort temperature max",
+    )
+    .number(
+        CustomSonoffCluster.AttributeDefs.comfort_humidity_min.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=5,
+        max_value=95,
+        step=0.1,
+        unit=PERCENTAGE,
+        multiplier=0.01,
+        translation_key="comfort_humidity_min",
+        fallback_name="Comfort humidity min",
+    )
+    .number(
+        CustomSonoffCluster.AttributeDefs.comfort_humidity_max.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=5,
+        max_value=95,
+        step=0.1,
+        unit=PERCENTAGE,
+        multiplier=0.01,
+        translation_key="comfort_humidity_max",
+        fallback_name="Comfort humidity max",
+    )
+    .enum(
+        CustomSonoffCluster.AttributeDefs.temperature_unit.name,
+        TemperatureUnit,
+        CustomSonoffCluster.cluster_id,
+        translation_key="display_unit",
+        fallback_name="Display unit",
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/sonoff/snzb04p.py b/zhaquirks/sonoff/snzb04p.py
new file mode 100644
index 0000000..df7f99a
--- /dev/null
+++ b/zhaquirks/sonoff/snzb04p.py
@@ -0,0 +1,52 @@
+"""Sonoff SNZB-04 device."""
+
+from zigpy import types
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import (
+    BinarySensorDeviceClass,
+    EntityType,
+    QuirkBuilder,
+    ReportingConfig,
+)
+from zigpy.zcl.clusters.general import OnOff
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
+
+
+class SonoffContactCluster(CustomCluster):
+    """Sonoff manufacturer specific cluster for contact sensor."""
+
+    cluster_id = 64529  # 0xfc11
+    name = "Sonoff contact cluster"
+    ep_attribute = "sonoff_contact_cluster"
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Attribute definitions."""
+
+        tamper = ZCLAttributeDef(
+            id=0x2000,
+            type=types.Bool,
+            is_manufacturer_specific=True,
+        )
+
+
+(
+    #  <SimpleDescriptor endpoint=1 profile=260 device_type=1026
+    #  device_version=0
+    #  input_clusters=[0, 1, 3, 32, 1280, 64529, 64567]
+    #  output_clusters=[3, 6, 25]>
+    QuirkBuilder("eWeLink", "SNZB-04P")
+    .prevent_default_entity_creation(endpoint_id=1, cluster_id=OnOff.cluster_id)
+    .replaces(SonoffContactCluster, endpoint_id=1)
+    .binary_sensor(
+        "tamper",
+        SonoffContactCluster.cluster_id,
+        endpoint_id=1,
+        reporting_config=ReportingConfig(
+            min_interval=0, max_interval=900, reportable_change=1
+        ),
+        device_class=BinarySensorDeviceClass.TAMPER,
+        entity_type=EntityType.DIAGNOSTIC,
+        fallback_name="Tamper",
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/sonoff/swv.py b/zhaquirks/sonoff/swv.py
index 3389b8b..74e2a6a 100644
--- a/zhaquirks/sonoff/swv.py
+++ b/zhaquirks/sonoff/swv.py
@@ -1,8 +1,10 @@
 """Sonoff SWV - Zigbee smart water valve."""
 
 from zigpy.quirks import CustomCluster
-from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2 import QuirkBuilder, ReportingConfig
+from zigpy.quirks.v2.homeassistant.binary_sensor import BinarySensorDeviceClass
 import zigpy.types as t
+from zigpy.zcl import foundation
 from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
 
 
@@ -15,10 +17,11 @@ class ValveState(t.enum8):
     Water_Shortage_And_Leakage = 3
 
 
-class EwelinkCluster(CustomCluster):
-    """Ewelink specific cluster."""
+class CustomSonoffCluster(CustomCluster):
+    """Custom Sonoff cluster."""
 
     cluster_id = 0xFC11
+    manufacturer_id_override: t.uint16_t = foundation.ZCLHeader.NO_MANUFACTURER_ID
 
     class AttributeDefs(BaseAttributeDefs):
         """Attribute definitions."""
@@ -28,13 +31,30 @@ class EwelinkCluster(CustomCluster):
             type=ValveState,
         )
 
-    @property
-    def _is_manuf_specific(self):
-        return False
-
 
 (
     QuirkBuilder("SONOFF", "SWV")
-    .replaces(EwelinkCluster)
+    .replaces(CustomSonoffCluster)
+    .binary_sensor(
+        CustomSonoffCluster.AttributeDefs.water_valve_state.name,
+        CustomSonoffCluster.cluster_id,
+        device_class=BinarySensorDeviceClass.MOISTURE,
+        attribute_converter=lambda x: x & ValveState.Water_Leakage,
+        unique_id_suffix="water_leak_status",
+        reporting_config=ReportingConfig(
+            min_interval=30, max_interval=900, reportable_change=1
+        ),
+        translation_key="water_leak",
+        fallback_name="Water leak",
+    )
+    .binary_sensor(
+        CustomSonoffCluster.AttributeDefs.water_valve_state.name,
+        CustomSonoffCluster.cluster_id,
+        device_class=BinarySensorDeviceClass.PROBLEM,
+        attribute_converter=lambda x: x & ValveState.Water_Shortage,
+        unique_id_suffix="water_supply_status",
+        translation_key="water_supply",
+        fallback_name="Water supply",
+    )
     .add_to_registry()
-)  # fmt: skip
+)
diff --git a/zhaquirks/thirdreality/radar_sensor.py b/zhaquirks/thirdreality/radar_sensor.py
new file mode 100644
index 0000000..5dad6d1
--- /dev/null
+++ b/zhaquirks/thirdreality/radar_sensor.py
@@ -0,0 +1,40 @@
+"""Third Reality radar sensor devices."""
+
+from typing import Final
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2.homeassistant import UnitOfTime
+import zigpy.types as t
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
+
+
+class ThirdRealityRadarCluster(CustomCluster):
+    """Third Reality's radar sensor private cluster."""
+
+    cluster_id = 0xFF01
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Define the attributes of a private cluster."""
+
+        detection_interval: Final = ZCLAttributeDef(
+            id=0x0001,
+            type=t.uint16_t,
+            is_manufacturer_specific=True,
+        )
+
+
+(
+    QuirkBuilder("Third Reality, Inc", "3RSMR01067Z")
+    .replaces(ThirdRealityRadarCluster)
+    .number(
+        attribute_name=ThirdRealityRadarCluster.AttributeDefs.detection_interval.name,
+        min_value=0,
+        max_value=3600,
+        unit=UnitOfTime.SECONDS,
+        cluster_id=ThirdRealityRadarCluster.cluster_id,
+        translation_key="detection_interval",
+        fallback_name="Detection interval",
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/thirdreality/watering_kit.py b/zhaquirks/thirdreality/watering_kit.py
new file mode 100644
index 0000000..fd0adba
--- /dev/null
+++ b/zhaquirks/thirdreality/watering_kit.py
@@ -0,0 +1,60 @@
+"""Third Reality watering kit devices."""
+
+from typing import Final
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2.homeassistant import UnitOfTime
+from zigpy.quirks.v2.homeassistant.number import NumberDeviceClass
+import zigpy.types as t
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
+
+
+class ThirdRealityWateringKitCluster(CustomCluster):
+    """Third Reality's watering kit private cluster."""
+
+    cluster_id = 0xFFF2
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Define the attributes of a private cluster."""
+
+        water_duration: Final = ZCLAttributeDef(
+            id=0x0000,
+            type=t.uint16_t,
+            is_manufacturer_specific=True,
+        )
+
+        water_interval: Final = ZCLAttributeDef(
+            id=0x0001,
+            type=t.uint8_t,
+            is_manufacturer_specific=True,
+        )
+
+
+(
+    QuirkBuilder("Third Reality, Inc", "3RWK0148Z")
+    .replaces(ThirdRealityWateringKitCluster)
+    .number(
+        attribute_name=ThirdRealityWateringKitCluster.AttributeDefs.water_duration.name,
+        min_value=1,
+        max_value=1800,
+        step=1,
+        device_class=NumberDeviceClass.DURATION,
+        unit=UnitOfTime.SECONDS,
+        cluster_id=ThirdRealityWateringKitCluster.cluster_id,
+        translation_key="water_duration",
+        fallback_name="Water duration",
+    )
+    .number(
+        attribute_name=ThirdRealityWateringKitCluster.AttributeDefs.water_interval.name,
+        min_value=0,
+        max_value=30,
+        step=1,
+        device_class=NumberDeviceClass.DURATION,
+        unit=UnitOfTime.DAYS,
+        cluster_id=ThirdRealityWateringKitCluster.cluster_id,
+        translation_key="water_interval",
+        fallback_name="Water interval",
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py
index b075e1a..8247468 100644
--- a/zhaquirks/tuya/__init__.py
+++ b/zhaquirks/tuya/__init__.py
@@ -1478,6 +1478,9 @@ class TuyaNewManufCluster(CustomCluster):
     cluster_id: t.uint16_t = TUYA_CLUSTER_ID
     ep_attribute: str = "tuya_manufacturer"
 
+    # command for writing datapoint values to the device, some use TUYA_SEND_DATA
+    mcu_write_command: foundation.GeneralCommand | int | t.uint8_t = TUYA_SET_DATA
+
     # remove manufacturer id for cluster, important for `TUYA_SET_DATA` commands
     manufacturer_id_override: t.uint16_t = foundation.ZCLHeader.NO_MANUFACTURER_ID
 
diff --git a/zhaquirks/tuya/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index b8e3477..12b3207 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -20,6 +20,7 @@ from zigpy.zcl import foundation
 from zigpy.zcl.clusters.measurement import (
     PM25,
     CarbonDioxideConcentration,
+    ElectricalConductivity,
     FormaldehydeConcentration,
     IlluminanceMeasurement,
     RelativeHumidity,
@@ -33,6 +34,7 @@ from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
 from zhaquirks.const import BatterySize
 from zhaquirks.tuya import (
     TUYA_CLUSTER_ID,
+    TUYA_SET_DATA,
     BaseEnchantedDevice,
     PowerConfiguration,
     TuyaLocalCluster,
@@ -65,6 +67,10 @@ class TuyaCO2Concentration(CarbonDioxideConcentration, TuyaLocalCluster):
     """Tuya Carbon Dioxide concentration measurement."""
 
 
+class TuyaElectricalConductivity(ElectricalConductivity, TuyaLocalCluster):
+    """Tuya Electrical Conductivity measurement."""
+
+
 class TuyaFormaldehydeConcentration(FormaldehydeConcentration, TuyaLocalCluster):
     """Tuya Formaldehyde concentration measurement."""
 
@@ -295,6 +301,22 @@ class TuyaQuirkBuilder(QuirkBuilder):
         self.adds(co2_cfg)
         return self
 
+    def tuya_electrical_conductivity(
+        self,
+        dp_id: int,
+        ec_cfg: TuyaLocalCluster = TuyaElectricalConductivity,
+        scale: float = 1,
+    ) -> QuirkBuilder:
+        """Add a Tuya Electrical Conductivity Configuration."""
+        self.tuya_dp(
+            dp_id,
+            ec_cfg.ep_attribute,
+            ElectricalConductivity.AttributeDefs.measured_value.name,
+            converter=lambda x: x * scale,
+        )
+        self.adds(ec_cfg)
+        return self
+
     def tuya_formaldehyde(
         self,
         dp_id: int,
@@ -807,8 +829,17 @@ class TuyaQuirkBuilder(QuirkBuilder):
         self,
         replacement_cluster: TuyaMCUCluster = TuyaMCUCluster,
         force_add_cluster: bool = False,
+        mcu_write_command: foundation.GeneralCommand | int | t.uint8_t = TUYA_SET_DATA,
     ) -> QuirksV2RegistryEntry:
-        """Build the quirks v2 registry entry."""
+        """Build the quirks v2 registry entry.
+
+        :param replacement_cluster: The cluster to add or replace the Tuya cluster with.
+        :param force_add_cluster: Force add the Tuya cluster,
+            even if no new Tuya attributes/datapoints were added before.
+        :param mcu_write_command: The MCU command to use for the Tuya MCU cluster.
+            Default is TUYA_SET_DATA. Few devices use TUYA_SEND_DATA instead.
+        :return: The quirks v2 registry entry.
+        """
 
         if (
             self.new_attributes
@@ -835,5 +866,7 @@ class TuyaQuirkBuilder(QuirkBuilder):
             TuyaReplacementCluster.data_point_handlers = self.tuya_data_point_handlers
             TuyaReplacementCluster.dp_to_attribute = self.tuya_dp_to_attribute
 
+            TuyaReplacementCluster.MCU_WRITE_COMMAND = mcu_write_command
+
             self.replaces(TuyaReplacementCluster)
         return super().add_to_registry()
diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py
index 0deed8b..927aa1c 100644
--- a/zhaquirks/tuya/mcu/__init__.py
+++ b/zhaquirks/tuya/mcu/__init__.py
@@ -17,7 +17,6 @@ from zhaquirks import Bus, DoublingPowerConfigurationCluster
 from zhaquirks.tuya import (
     TUYA_MCU_COMMAND,
     TUYA_MCU_VERSION_RSP,
-    TUYA_SET_DATA,
     TUYA_SET_TIME,
     EnchantedDevice,  # noqa: F401
     NoManufacturerCluster,
@@ -262,7 +261,7 @@ class TuyaMCUCluster(TuyaAttributesCluster, TuyaNewManufCluster):
         for tuya_command in tuya_commands:
             self.create_catching_task(
                 self.command(
-                    TUYA_SET_DATA,
+                    self.mcu_write_command,
                     tuya_command,
                     expect_reply=cluster_data.expect_reply,
                     manufacturer=cluster_data.manufacturer,
diff --git a/zhaquirks/tuya/ts0601_cover.py b/zhaquirks/tuya/ts0601_cover.py
index 6bf8e2a..9641da9 100644
--- a/zhaquirks/tuya/ts0601_cover.py
+++ b/zhaquirks/tuya/ts0601_cover.py
@@ -486,6 +486,7 @@ class TuyaMoesCover0601_alt_controls2(TuyaWindowCover):
         # }
         MODELS_INFO: [
             ("_TZE200_2odrmqwq", "TS0601"),
+            ("_TZE200_hojryzzd", "TS0601"),
         ],
         ENDPOINTS: {
             1: {
diff --git a/zhaquirks/tuya/ts0601_power.py b/zhaquirks/tuya/ts0601_power.py
new file mode 100644
index 0000000..2c22744
--- /dev/null
+++ b/zhaquirks/tuya/ts0601_power.py
@@ -0,0 +1,218 @@
+"""Tuya Power Meter."""
+
+from collections.abc import ByteString
+
+from zigpy.quirks.v2 import SensorDeviceClass, SensorStateClass
+from zigpy.quirks.v2.homeassistant import (
+    UnitOfElectricCurrent,
+    UnitOfEnergy,
+    UnitOfPower,
+)
+import zigpy.types as t
+from zigpy.zcl.clusters.general import LevelControl, OnOff
+from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
+
+from zhaquirks.tuya import DPToAttributeMapping, TuyaLocalCluster
+from zhaquirks.tuya.builder import TuyaQuirkBuilder
+
+
+def dp_to_power(data: ByteString) -> int:
+    """Convert DP data to power value."""
+    # From https://github.com/Koenkk/zigbee2mqtt/issues/18603#issuecomment-2277697295
+    power = int(data)
+    if power > 0x0FFFFFFF:
+        power = (0x1999999C - power) * -1
+    return power
+
+
+def multi_dp_to_power(data: ByteString) -> int:
+    """Convert DP data to power value."""
+    # Support negative power readings
+    # From https://github.com/Koenkk/zigbee2mqtt/issues/18603#issuecomment-2277697295
+    power = data[7] | (data[6] << 8)
+    if power > 0x7FFF:
+        power = (0x999A - power) * -1
+    return power
+
+
+def multi_dp_to_current(data: ByteString) -> int:
+    """Convert DP data to current value."""
+    return data[4] | (data[3] << 8)
+
+
+def multi_dp_to_voltage(data: ByteString) -> int:
+    """Convert DP data to voltage value."""
+    return data[1] | (data[0] << 8)
+
+
+class Tuya3PhaseElectricalMeasurement(ElectricalMeasurement, TuyaLocalCluster):
+    """Tuya Electrical Measurement cluster."""
+
+    _CONSTANT_ATTRIBUTES = {
+        ElectricalMeasurement.AttributeDefs.ac_current_multiplier.id: 1,
+        ElectricalMeasurement.AttributeDefs.ac_current_divisor.id: 1000,
+        ElectricalMeasurement.AttributeDefs.ac_voltage_multiplier: 1,
+        ElectricalMeasurement.AttributeDefs.ac_voltage_divisor.id: 10,
+    }
+
+
+(
+    TuyaQuirkBuilder("_TZE200_nslr42tt", "TS0601")
+    .tuya_temperature(dp_id=133, scale=10)
+    .tuya_sensor(
+        dp_id=134,
+        attribute_name="device_status",
+        type=t.int32s,
+        fallback_name="Device status",
+        translation_key="device_status",
+    )
+    .tuya_dp(
+        dp_id=132,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="ac_frequency",
+    )
+    # Energy
+    .tuya_sensor(
+        dp_id=1,
+        attribute_name="energy",
+        type=t.int32s,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        fallback_name="Total energy",
+    )
+    .tuya_sensor(
+        dp_id=101,
+        attribute_name="energy_ph_a",
+        type=t.int32s,
+        divisor=1000,
+        state_class=SensorStateClass.TOTAL,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_ph_a",
+        fallback_name="Energy phase A",
+    )
+    .tuya_sensor(
+        dp_id=111,
+        attribute_name="energy_ph_b",
+        type=t.int32s,
+        divisor=1000,
+        state_class=SensorStateClass.TOTAL,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_ph_b",
+        fallback_name="Energy phase B",
+    )
+    .tuya_sensor(
+        dp_id=121,
+        attribute_name="energy_ph_c",
+        type=t.int32s,
+        divisor=1000,
+        state_class=SensorStateClass.TOTAL,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_ph_c",
+        fallback_name="Energy phase C",
+    )
+    .tuya_sensor(
+        dp_id=9,
+        attribute_name="power",
+        type=t.int32s,
+        state_class=SensorStateClass.MEASUREMENT,
+        device_class=SensorDeviceClass.POWER,
+        unit=UnitOfPower.WATT,
+        fallback_name="Total power",
+        converter=dp_to_power,
+    )
+    .tuya_sensor(
+        dp_id=131,
+        attribute_name="current",
+        type=t.int32s,
+        divisor=1000,
+        state_class=SensorStateClass.MEASUREMENT,
+        device_class=SensorDeviceClass.CURRENT,
+        unit=UnitOfElectricCurrent.AMPERE,
+        fallback_name="Total current",
+    )
+    .tuya_dp_multi(
+        dp_id=6,
+        attribute_mapping=[
+            DPToAttributeMapping(
+                ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                attribute_name="active_power",
+                converter=multi_dp_to_power,
+            ),
+            DPToAttributeMapping(
+                ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                attribute_name="rms_voltage",
+                converter=multi_dp_to_voltage,
+            ),
+            DPToAttributeMapping(
+                ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                attribute_name="rms_current",
+                converter=multi_dp_to_current,
+            ),
+        ],
+    )
+    .tuya_dp_multi(
+        dp_id=7,
+        attribute_mapping=[
+            DPToAttributeMapping(
+                ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                attribute_name="active_power_ph_b",
+                converter=multi_dp_to_power,
+            ),
+            DPToAttributeMapping(
+                ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                attribute_name="rms_voltage_ph_b",
+                converter=multi_dp_to_voltage,
+            ),
+            DPToAttributeMapping(
+                ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                attribute_name="rms_current_ph_b",
+                converter=multi_dp_to_current,
+            ),
+        ],
+    )
+    .tuya_dp_multi(
+        dp_id=8,
+        attribute_mapping=[
+            DPToAttributeMapping(
+                ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                attribute_name="active_power_ph_c",
+                converter=multi_dp_to_power,
+            ),
+            DPToAttributeMapping(
+                ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                attribute_name="rms_voltage_ph_c",
+                converter=multi_dp_to_voltage,
+            ),
+            DPToAttributeMapping(
+                ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+                attribute_name="rms_current_ph_c",
+                converter=multi_dp_to_current,
+            ),
+        ],
+    )
+    .tuya_dp(
+        dp_id=102,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="power_factor",
+    )
+    .tuya_dp(
+        dp_id=112,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="power_factor_ph_b",
+    )
+    .tuya_dp(
+        dp_id=122,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="power_factor_ph_c",
+    )
+    .adds(Tuya3PhaseElectricalMeasurement)
+    .removes(LevelControl.cluster_id)
+    .removes(OnOff.cluster_id)
+    .skip_configuration()
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/tuya_co.py b/zhaquirks/tuya/tuya_co.py
index 4462f7e..d33056e 100644
--- a/zhaquirks/tuya/tuya_co.py
+++ b/zhaquirks/tuya/tuya_co.py
@@ -1,10 +1,13 @@
 """Tuya Air Quality sensor."""
 
+from typing import Any
+
 import zigpy.types as t
 
 from zhaquirks.tuya.builder import (
     MOL_VOL_AIR_NTP,
     TuyaFormaldehydeConcentration,
+    TuyaPM25Concentration,
     TuyaQuirkBuilder,
     TuyaTemperatureMeasurement,
 )
@@ -22,6 +25,16 @@ class CustomTemperature(t.Struct):
         return cls.deserialize(value.serialize())[0]
 
 
+class TuyaPM25ConcentrationIgnoreValues(TuyaPM25Concentration):
+    """Tuya PM25 concentration measurement cluster that ignores invalid high values."""
+
+    def _update_attribute(self, attrid: int | t.uint16_t, value: Any) -> None:
+        """Update an attribute on this cluster and ignore values over 1000."""
+        if attrid == self.AttributeDefs.measured_value.id and value > 1000:
+            return
+        super()._update_attribute(attrid, value)
+
+
 base_air_quality = (
     TuyaQuirkBuilder()
     .tuya_dp(
@@ -41,7 +54,7 @@ base_air_quality = (
     # 18 and 19 from base
     .applies_to("_TZE200_dwcarsat", "TS0601")
     .applies_to("_TZE204_dwcarsat", "TS0601")
-    .tuya_pm25(dp_id=2)
+    .tuya_pm25(dp_id=2, pm25_cfg=TuyaPM25ConcentrationIgnoreValues)
     .tuya_formaldehyde(
         dp_id=20,
         converter=lambda x: round(
diff --git a/zhaquirks/tuya/tuya_fingerbot.py b/zhaquirks/tuya/tuya_fingerbot.py
index 6f4a413..c57f09e 100644
--- a/zhaquirks/tuya/tuya_fingerbot.py
+++ b/zhaquirks/tuya/tuya_fingerbot.py
@@ -4,6 +4,7 @@ from zigpy.quirks.v2.homeassistant import PERCENTAGE, UnitOfTime
 import zigpy.types as t
 
 from zhaquirks.const import BatterySize
+from zhaquirks.tuya import TUYA_SEND_DATA
 from zhaquirks.tuya.builder import TuyaQuirkBuilder
 
 
@@ -80,5 +81,5 @@ class FingerBotReverse(t.enum8):
         fallback_name="Touch control",
     )
     .tuya_enchantment()
-    .add_to_registry()
+    .add_to_registry(mcu_write_command=TUYA_SEND_DATA)
 )
diff --git a/zhaquirks/tuya/tuya_rain.py b/zhaquirks/tuya/tuya_rain.py
index 6221d0b..2821b24 100644
--- a/zhaquirks/tuya/tuya_rain.py
+++ b/zhaquirks/tuya/tuya_rain.py
@@ -6,14 +6,9 @@ from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateC
 import zigpy.types as t
 from zigpy.zcl.clusters.security import IasZone
 
-from zhaquirks.tuya import (
-    TUYA_CLUSTER_ID,
-    BatterySize,
-    DPToAttributeMapping,
-    TuyaLocalCluster,
-)
+from zhaquirks.tuya import TUYA_CLUSTER_ID, BatterySize, TuyaLocalCluster
 from zhaquirks.tuya.builder import TuyaQuirkBuilder
-from zhaquirks.tuya.mcu import TuyaMCUCluster
+from zhaquirks.tuya.mcu import DPToAttributeMapping, TuyaMCUCluster
 
 
 class TuyaIasZone(IasZone, TuyaLocalCluster):
diff --git a/zhaquirks/tuya/tuya_sensor.py b/zhaquirks/tuya/tuya_sensor.py
index 92778f0..b3eecee 100644
--- a/zhaquirks/tuya/tuya_sensor.py
+++ b/zhaquirks/tuya/tuya_sensor.py
@@ -266,6 +266,18 @@ class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
 )
 
 
+(
+    TuyaQuirkBuilder("_TZE284_rqcuwlsa", "TS0601")  # NEO NAS-STH02B2
+    .tuya_battery(dp_id=15)
+    .tuya_electrical_conductivity(dp_id=1)
+    .tuya_soil_moisture(dp_id=3)
+    .tuya_temperature(dp_id=5, scale=10)
+    .tuya_enchantment(data_query_spell=True)
+    .skip_configuration()
+    .add_to_registry()
+)
+
+
 (
     TuyaQuirkBuilder("_TZE200_myd45weu", "TS0601")
     .applies_to("_TZE200_ga1maeof", "TS0601")
diff --git a/zhaquirks/tuya/tuya_siren.py b/zhaquirks/tuya/tuya_siren.py
index fbef4f8..4f21d6b 100644
--- a/zhaquirks/tuya/tuya_siren.py
+++ b/zhaquirks/tuya/tuya_siren.py
@@ -134,6 +134,7 @@ class NeoBatteryState(t.enum8):
 (
     TuyaQuirkBuilder("_TZE204_nlrfgpny", "TS0601")
     .applies_to("TZE200_nlrfgpny", "TS0601")
+    .applies_to("_TZE284_nlrfgpny", "TS0601")
     .tuya_enum(
         dp_id=1,
         attribute_name="alarm_state",
diff --git a/zhaquirks/tuya/tuya_thermostat.py b/zhaquirks/tuya/tuya_thermostat.py
index d2442c2..55e0db1 100644
--- a/zhaquirks/tuya/tuya_thermostat.py
+++ b/zhaquirks/tuya/tuya_thermostat.py
@@ -400,9 +400,10 @@ base_avatto_quirk = (
         attribute_name="deadzone_temperature",
         type=t.uint16_t,
         unit=UnitOfTemperature.CELSIUS,
-        min_value=0.1,
+        min_value=0.5,
         max_value=10,
-        step=0.1,
+        step=0.5,
+        multiplier=0.1,
         translation_key="deadzone_temperature",
         fallback_name="Deadzone temperature",
     )
diff --git a/zhaquirks/xiaomi/aqara/roller_curtain_e1.py b/zhaquirks/xiaomi/aqara/roller_curtain_e1.py
index d59f9b1..d0d5466 100644
--- a/zhaquirks/xiaomi/aqara/roller_curtain_e1.py
+++ b/zhaquirks/xiaomi/aqara/roller_curtain_e1.py
@@ -2,64 +2,194 @@
 
 from __future__ import annotations
 
-from typing import Any
+from collections.abc import Callable
+from typing import Any, Final
 
 from zigpy import types as t
-from zigpy.profiles import zgp, zha
-from zigpy.zcl import foundation
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2.homeassistant.binary_sensor import BinarySensorDeviceClass
+from zigpy.zcl import Cluster, foundation
 from zigpy.zcl.clusters.closures import WindowCovering
-from zigpy.zcl.clusters.general import (
-    Alarms,
-    AnalogOutput,
-    Basic,
-    DeviceTemperature,
-    GreenPowerProxy,
-    Groups,
-    Identify,
-    MultistateOutput,
-    OnOff,
-    Ota,
-    Scenes,
-    Time,
-)
+from zigpy.zcl.clusters.general import AnalogOutput, MultistateOutput, OnOff
+from zigpy.zcl.foundation import BaseAttributeDefs, DataTypeId, ZCLAttributeDef
 
 from zhaquirks import CustomCluster
-from zhaquirks.const import (
-    DEVICE_TYPE,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
-    MODELS_INFO,
-    OUTPUT_CLUSTERS,
-    PROFILE_ID,
-)
 from zhaquirks.xiaomi import (
     LUMI,
     BasicCluster,
     XiaomiAqaraE1Cluster,
-    XiaomiCluster,
-    XiaomiCustomDevice,
     XiaomiPowerConfigurationPercent,
 )
 
 
+class AqaraRollerDriverCharging(t.enum8):
+    """Aqara roller driver charging status attribute values."""
+
+    Charging = 0x01
+    NotCharging = 0x02
+
+
+class AqaraRollerDriverSpeed(t.enum8):
+    """Aqara roller driver speed attribute values."""
+
+    Low = 0x00
+    Medium = 0x01
+    High = 0x02
+
+
+class AqaraRollerControl(t.enum8):
+    """Aqara roller control attribute values."""
+
+    Close = 0x00
+    Open = 0x01
+    Stop = 0x02
+
+
+class RedirectAttributes:
+    """Methods for redirecting attribute reads to another cluster."""
+
+    _REDIRECT_ATTRIBUTES: (
+        dict[ZCLAttributeDef, tuple[ZCLAttributeDef, Cluster, Callable | None]] | None
+    ) = None
+
+    async def read_attributes(
+        self,
+        attributes: list[int | str],
+        allow_cache: bool = False,
+        only_cache: bool = False,
+        manufacturer: int | t.uint16_t | None = None,
+    ):
+        """Redirect attribute reads to another cluster."""
+
+        successful, failed = {}, {}
+        remaining_attributes = attributes.copy()
+        redirect_attributes = self._REDIRECT_ATTRIBUTES or {}
+
+        for attr in redirect_attributes:
+            if attr.id not in attributes and attr.name not in attributes:
+                continue
+            if attr.id in attributes:
+                remaining_attributes.remove(attr.id)
+            if attr.name in attributes:
+                remaining_attributes.remove(attr.name)
+
+            target_attr, target_cluster, format_func = redirect_attributes[attr]
+            result_s, result_f = await getattr(
+                self.endpoint, target_cluster.ep_attribute
+            ).read_attributes(
+                [target_attr.id],
+                allow_cache,
+                only_cache,
+                manufacturer,
+            )
+
+            if target_attr.id in result_s:
+                value = result_s[target_attr.id]
+                successful[attr.id] = format_func(value) if format_func else value
+            if target_attr.id in result_f:
+                failed[attr.id] = result_f[target_attr.id]
+
+        if remaining_attributes:
+            result_s, result_f = await super().read_attributes(
+                remaining_attributes, allow_cache, only_cache, manufacturer
+            )
+            successful.update(result_s)
+            failed.update(result_f)
+
+        return successful, failed
+
+
+class WriteAwareUpdateAttribute:
+    """Methods providing 'is_write' arg to _update_attribute."""
+
+    async def write_attributes_raw(
+        self,
+        attrs: list[foundation.Attribute],
+        manufacturer: int | None = None,
+        **kwargs,
+    ) -> list:
+        """Provide the is_write=True flag when calling _update_attribute."""
+
+        result = await self._write_attributes(
+            attrs, manufacturer=manufacturer, **kwargs
+        )
+        if not isinstance(result[0], list):
+            return result
+
+        records = result[0]
+        if len(records) == 1 and records[0].status == foundation.Status.SUCCESS:
+            for attr_rec in attrs:
+                self._update_attribute(
+                    attr_rec.attrid, attr_rec.value.value, is_write=True
+                )
+        else:
+            failed = [rec.attrid for rec in records]
+            for attr_rec in attrs:
+                if attr_rec.attrid not in failed:
+                    self._update_attribute(
+                        attr_rec.attrid, attr_rec.value.value, is_write=True
+                    )
+
+        return result
+
+    def _update_attribute(
+        self, attrid: int, value: Any, is_write: bool | None = None
+    ) -> None:
+        super()._update_attribute(attrid, value)
+
+
 class XiaomiAqaraRollerE1(XiaomiAqaraE1Cluster):
-    """Xiaomi mfg cluster implementation specific for E1 Roller."""
-
-    attributes = XiaomiCluster.attributes.copy()
-    attributes.update(
-        {
-            0x0400: ("reverse_direction", t.Bool, True),
-            0x0402: ("positions_stored", t.Bool, True),
-            0x0407: ("store_position", t.uint8_t, True),
-            0x0408: ("speed", t.uint8_t, True),
-            0x0409: ("charging", t.uint8_t, True),
-            0x00F7: ("aqara_attributes", t.LVBytes, True),
-        }
-    )
+    """Aqara manufacturer cluster for the Roller Driver E1."""
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Manufacturer specific attributes."""
 
+        reverse_direction = ZCLAttributeDef(
+            id=0x0400,
+            type=t.Bool,
+            access="rwp",
+            is_manufacturer_specific=True,
+        )
 
-class AnalogOutputRollerE1(CustomCluster, AnalogOutput):
-    """Analog output cluster, only used to relay current_value to WindowCovering."""
+        positions_stored = ZCLAttributeDef(
+            id=0x0402,
+            type=t.Bool,
+            access="rwp",
+            is_manufacturer_specific=True,
+        )
+
+        store_position = ZCLAttributeDef(
+            id=0x0407,
+            type=t.uint8_t,
+            access="rwp",
+            is_manufacturer_specific=True,
+        )
+
+        speed = ZCLAttributeDef(
+            id=0x0408,
+            type=AqaraRollerDriverSpeed,
+            zcl_type=DataTypeId.uint8,
+            access="rwp",
+            is_manufacturer_specific=True,
+        )
+
+        charging = ZCLAttributeDef(
+            id=0x0409,
+            type=AqaraRollerDriverCharging,
+            zcl_type=DataTypeId.uint8,
+            access="rp",
+            is_manufacturer_specific=True,
+        )
+
+        aqara_attributes = ZCLAttributeDef(
+            id=0x00F7,
+            type=t.LVBytes,
+            is_manufacturer_specific=True,
+        )
+
+
+class AnalogOutputRollerE1(WriteAwareUpdateAttribute, CustomCluster, AnalogOutput):
+    """AnalogOutput cluster reporting current position and used for writing target position."""
 
     _CONSTANT_ATTRIBUTES = {
         AnalogOutput.AttributeDefs.description.id: "Current position",
@@ -70,18 +200,34 @@ class AnalogOutputRollerE1(CustomCluster, AnalogOutput):
         AnalogOutput.AttributeDefs.status_flags.id: 0x00,
     }
 
-    def _update_attribute(self, attrid: int, value: Any) -> None:
-        super()._update_attribute(attrid, value)
+    def _update_attribute(
+        self, attrid: int, value: Any, is_write: bool | None = None
+    ) -> None:
+        """Non-write 'present_value' updates should update the WindowCovering position."""
 
-        if attrid == self.AttributeDefs.present_value.id:
+        super()._update_attribute(attrid, value)
+        if attrid == self.AttributeDefs.present_value.id and not is_write:
             self.endpoint.window_covering.update_attribute(
                 WindowCovering.AttributeDefs.current_position_lift_percentage.id,
-                (100 - value),
+                t.uint8_t(100 - value),
             )
 
 
-class WindowCoveringRollerE1(CustomCluster, WindowCovering):
-    """Window covering cluster to receive commands that are sent to the AnalogOutput's present_value to move the motor."""
+class WindowCoveringRollerE1(RedirectAttributes, CustomCluster, WindowCovering):
+    """Window covering cluster for handling motor commands."""
+
+    _CONSTANT_ATTRIBUTES = {
+        WindowCovering.AttributeDefs.window_covering_type.id: WindowCovering.WindowCoveringType.Rollershade,
+    }
+
+    # This is used to redirect 'current_position_lift_percentage' reads to AnalogOutput 'present_value'
+    _REDIRECT_ATTRIBUTES = {
+        WindowCovering.AttributeDefs.current_position_lift_percentage: (
+            AnalogOutput.AttributeDefs.present_value,
+            AnalogOutput,
+            lambda x: t.uint8_t(100 - x),
+        ),
+    }
 
     async def command(
         self,
@@ -94,223 +240,119 @@ class WindowCoveringRollerE1(CustomCluster, WindowCovering):
     ) -> Any:
         """Overwrite the commands to make it work for both firmware 1425 and 1427.
 
-        We either overwrite analog_output's current_value or multistate_output's current
-        value to make the roller work.
+        Write to AnalogOutput current_value for go go_to_lift_percentage.
+        Write to MultistateOutput current_value for up_open/down_close/stop.
+
+        The current_position_lift_percentage is read prior to returning the command response
+        to ensure that ZHA has the correct position during changes in direction/stopping.
         """
         if command_id == WindowCovering.ServerCommandDefs.up_open.id:
             (res,) = await self.endpoint.multistate_output.write_attributes(
-                {"present_value": 1}
+                {
+                    MultistateOutput.AttributeDefs.present_value.name: AqaraRollerControl.Open
+                }
+            )
+            await self.read_attributes(
+                [self.AttributeDefs.current_position_lift_percentage.id]
             )
             return foundation.GENERAL_COMMANDS[
                 foundation.GeneralCommand.Default_Response
             ].schema(command_id=command_id, status=res[0].status)
+
         if command_id == WindowCovering.ServerCommandDefs.down_close.id:
             (res,) = await self.endpoint.multistate_output.write_attributes(
-                {"present_value": 0}
+                {
+                    MultistateOutput.AttributeDefs.present_value.name: AqaraRollerControl.Close
+                }
+            )
+            await self.read_attributes(
+                [self.AttributeDefs.current_position_lift_percentage.id]
             )
             return foundation.GENERAL_COMMANDS[
                 foundation.GeneralCommand.Default_Response
             ].schema(command_id=command_id, status=res[0].status)
+
         if command_id == WindowCovering.ServerCommandDefs.go_to_lift_percentage.id:
             (res,) = await self.endpoint.analog_output.write_attributes(
-                {"present_value": (100 - args[0])}
+                {AnalogOutput.AttributeDefs.present_value.name: (100 - args[0])}
+            )
+            await self.read_attributes(
+                [self.AttributeDefs.current_position_lift_percentage.id]
             )
             return foundation.GENERAL_COMMANDS[
                 foundation.GeneralCommand.Default_Response
             ].schema(command_id=command_id, status=res[0].status)
+
         if command_id == WindowCovering.ServerCommandDefs.stop.id:
             (res,) = await self.endpoint.multistate_output.write_attributes(
-                {"present_value": 2}
+                {
+                    MultistateOutput.AttributeDefs.present_value.name: AqaraRollerControl.Stop
+                }
+            )
+            await self.read_attributes(
+                [self.AttributeDefs.current_position_lift_percentage.id]
             )
             return foundation.GENERAL_COMMANDS[
                 foundation.GeneralCommand.Default_Response
             ].schema(command_id=command_id, status=res[0].status)
 
+        return foundation.GENERAL_COMMANDS[
+            foundation.GeneralCommand.Default_Response
+        ].schema(command_id=command_id, status=foundation.Status.UNSUP_CLUSTER_COMMAND)
+
 
 class MultistateOutputRollerE1(CustomCluster, MultistateOutput):
-    """Multistate Output cluster which overwrites present_value.
+    """MultistateOutput cluster used for writing commands (up_open, down_close, stop).
 
-    Otherwise, it gives errors of wrong datatype when using it in the commands.
+    This requires a change to the present_value attribute type because the device responds
+    with an error when using the standard t.Single type.
     """
 
-    attributes = MultistateOutput.attributes.copy()
-    attributes.update(
-        {
-            MultistateOutput.AttributeDefs.present_value.id: (
-                "present_value",
-                t.uint16_t,
-            ),
-        }
-    )
+    class AttributeDefs(MultistateOutput.AttributeDefs):
+        """Aqara attribute definition overrides."""
 
+        present_value: Final = ZCLAttributeDef(
+            id=0x0055, type=t.uint16_t, access="r*w", mandatory=True
+        )
 
-class RollerE1AQ(XiaomiCustomDevice):
-    """Aqara Roller Shade Driver E1 device."""
-
-    signature = {
-        MODELS_INFO: [(LUMI, "lumi.curtain.acn002")],
-        ENDPOINTS: {
-            # <SizePrefixedSimpleDescriptor endpoint=1 profile=260 device_type=256
-            # device_version=1
-            # input_clusters=[0, 2, 3, 4, 5, 6, 9, 64704, 13, 19, 258]
-            # output_clusters=[10, 25]>
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
-                INPUT_CLUSTERS: [
-                    Alarms.cluster_id,
-                    AnalogOutput.cluster_id,
-                    Basic.cluster_id,
-                    DeviceTemperature.cluster_id,
-                    Groups.cluster_id,
-                    Identify.cluster_id,
-                    XiaomiAqaraRollerE1.cluster_id,
-                    MultistateOutput.cluster_id,
-                    OnOff.cluster_id,
-                    Scenes.cluster_id,
-                    WindowCovering.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Ota.cluster_id,
-                    Time.cluster_id,
-                ],
-            },
-            # <SizePrefixedSimpleDescriptor endpoint=242 profile=41440 device_type=97
-            # device_version=0,
-            # input_clusters=[]
-            # output_clusters=[33]>
-            242: {
-                PROFILE_ID: zgp.PROFILE_ID,
-                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
-                INPUT_CLUSTERS: [],
-                OUTPUT_CLUSTERS: [
-                    GreenPowerProxy.cluster_id,
-                ],
-            },
-        },
-    }
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE,
-                INPUT_CLUSTERS: [
-                    Alarms.cluster_id,
-                    AnalogOutputRollerE1,
-                    BasicCluster,
-                    DeviceTemperature.cluster_id,
-                    Groups.cluster_id,
-                    Identify.cluster_id,
-                    XiaomiAqaraRollerE1,
-                    MultistateOutputRollerE1,
-                    Scenes.cluster_id,
-                    WindowCoveringRollerE1,
-                    XiaomiPowerConfigurationPercent,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Ota.cluster_id,
-                    Time.cluster_id,
-                ],
-            },
-            242: {
-                PROFILE_ID: zgp.PROFILE_ID,
-                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
-                INPUT_CLUSTERS: [],
-                OUTPUT_CLUSTERS: [
-                    GreenPowerProxy.cluster_id,
-                ],
-            },
-        },
-    }
 
-
-class RollerE1AQ_2(RollerE1AQ):
-    """Aqara Roller Shade Driver E1 (version 2) device."""
-
-    signature = {
-        MODELS_INFO: [(LUMI, "lumi.curtain.acn002")],
-        ENDPOINTS: {
-            # <SizePrefixedSimpleDescriptor endpoint=1 profile=260 device_type=256
-            # device_version=1
-            # input_clusters=[0, 2, 3, 4, 5, 6, 9, 13, 19, 258]
-            # output_clusters=[10, 25]>
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
-                INPUT_CLUSTERS: [
-                    Alarms.cluster_id,
-                    AnalogOutput.cluster_id,
-                    Basic.cluster_id,
-                    DeviceTemperature.cluster_id,
-                    Groups.cluster_id,
-                    Identify.cluster_id,
-                    MultistateOutput.cluster_id,
-                    OnOff.cluster_id,
-                    Scenes.cluster_id,
-                    WindowCovering.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Ota.cluster_id,
-                    Time.cluster_id,
-                ],
-            },
-            # <SizePrefixedSimpleDescriptor endpoint=242 profile=41440 device_type=97
-            # device_version=0,
-            # input_clusters=[]
-            # output_clusters=[33]>
-            242: {
-                PROFILE_ID: zgp.PROFILE_ID,
-                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
-                INPUT_CLUSTERS: [],
-                OUTPUT_CLUSTERS: [
-                    GreenPowerProxy.cluster_id,
-                ],
-            },
-        },
-    }
-
-
-class RollerE1AQ_3(RollerE1AQ):
-    """Aqara Roller Shade Driver E1 (version 3) device."""
-
-    signature = {
-        MODELS_INFO: [(LUMI, "lumi.curtain.acn002")],
-        ENDPOINTS: {
-            # <SizePrefixedSimpleDescriptor endpoint=1 profile=260 device_type=514
-            # device_version=1
-            # input_clusters=[0, 2, 3, 4, 5, 6, 9, 13, 19, 258]
-            # output_clusters=[10, 25]>
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE,
-                INPUT_CLUSTERS: [
-                    Alarms.cluster_id,
-                    AnalogOutput.cluster_id,
-                    Basic.cluster_id,
-                    DeviceTemperature.cluster_id,
-                    Groups.cluster_id,
-                    Identify.cluster_id,
-                    MultistateOutput.cluster_id,
-                    OnOff.cluster_id,
-                    Scenes.cluster_id,
-                    WindowCovering.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Ota.cluster_id,
-                    Time.cluster_id,
-                ],
-            },
-            # <SizePrefixedSimpleDescriptor endpoint=242 profile=41440 device_type=97
-            # device_version=0,
-            # input_clusters=[]
-            # output_clusters=[33]>
-            242: {
-                PROFILE_ID: zgp.PROFILE_ID,
-                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
-                INPUT_CLUSTERS: [],
-                OUTPUT_CLUSTERS: [
-                    GreenPowerProxy.cluster_id,
-                ],
-            },
-        },
-    }
+(
+    QuirkBuilder(LUMI, "lumi.curtain.acn002")
+    # temporarily commented out due to potentially breaking existing blueprints
+    #    .friendly_name(
+    #        manufacturer="Aqara", model="Roller Shade Driver E1"
+    #    )
+    .prevent_default_entity_creation(endpoint_id=1, cluster_id=AnalogOutput.cluster_id)
+    .prevent_default_entity_creation(
+        endpoint_id=1, cluster_id=MultistateOutput.cluster_id
+    )
+    .prevent_default_entity_creation(endpoint_id=1, cluster_id=OnOff.cluster_id)
+    .replaces(AnalogOutputRollerE1)
+    .replaces(BasicCluster)
+    .replaces(MultistateOutputRollerE1)
+    .replaces(XiaomiPowerConfigurationPercent)
+    .replaces(WindowCoveringRollerE1)
+    .replaces(XiaomiAqaraRollerE1)
+    .enum(
+        XiaomiAqaraRollerE1.AttributeDefs.speed.name,
+        AqaraRollerDriverSpeed,
+        XiaomiAqaraRollerE1.cluster_id,
+        translation_key="speed",
+        fallback_name="Speed",
+    )
+    .binary_sensor(
+        XiaomiAqaraRollerE1.AttributeDefs.charging.name,
+        XiaomiAqaraRollerE1.cluster_id,
+        device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
+        translation_key="charging",
+        fallback_name="Charging",
+        attribute_converter=lambda x: x == AqaraRollerDriverCharging.Charging,
+    )
+    .binary_sensor(
+        XiaomiAqaraRollerE1.AttributeDefs.positions_stored.name,
+        XiaomiAqaraRollerE1.cluster_id,
+        translation_key="calibrated",
+        fallback_name="Calibrated",
+    )
+    .add_to_registry()
+)


Test patch:
diff --git a/tests/test_tuya_air.py b/tests/test_tuya_air.py
index 52dd760..5cfd337 100644
--- a/tests/test_tuya_air.py
+++ b/tests/test_tuya_air.py
@@ -6,7 +6,9 @@ from unittest.mock import MagicMock
 import pytest
 import zigpy.profiles.zha
 import zigpy.types as t
+from zigpy.zcl.clusters.measurement import PM25
 
+from tests.common import ClusterListener
 import zhaquirks
 from zhaquirks.tuya import TuyaNewManufCluster
 
@@ -79,6 +81,7 @@ def test_co2_sensor(air_quality_device, data, ep_attr, expected_value):
     assert cluster.get("measured_value") == expected_value
 
 
+# XXX: variant 00 is not used, PM2.5 is untested for sensors
 TUYA_AIR_TEST_VAR00 = (
     (
         b"\t\x02\x01\x00\x00\x12\x02\x00\x04\x00\x00\x01 ",
@@ -310,3 +313,22 @@ def test_smart_air_sensor(zigpy_device_from_v2_quirk, model, manuf, test_plan):
             )
             cluster = getattr(dev.endpoints[1], ep_attr)
             assert cluster.get("measured_value") == expected_value
+
+
+async def test_smart_air_pm25_dropping_high_values(zigpy_device_from_v2_quirk):
+    """Test Tuya Smart Air Sensor _TZE200_dwcarsat dropping high PM2.5 values."""
+
+    dev = zigpy_device_from_v2_quirk("_TZE200_dwcarsat", "TS0601")
+    pm25_cluster = dev.endpoints[1].pm25
+    pm25_listener = ClusterListener(pm25_cluster)
+
+    # check that valid value updates the attribute
+    # We call the Tuya TuyaLocalCluster update_attribute method which accepts a string,
+    # it'll call the underlying _update_attribute method with the id then.
+    pm25_cluster.update_attribute(PM25.AttributeDefs.measured_value.name, 1000)
+    assert len(pm25_listener.attribute_updates) == 1
+    assert pm25_listener.attribute_updates[0][1] == 1000
+
+    # check that invalid value is ignored
+    pm25_cluster.update_attribute(PM25.AttributeDefs.measured_value.name, 1001)
+    assert len(pm25_listener.attribute_updates) == 1
diff --git a/tests/test_tuya_builder.py b/tests/test_tuya_builder.py
index 8041ae2..5c582e9 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -18,7 +18,6 @@ from zhaquirks.const import BatterySize
 from zhaquirks.tuya import (
     TUYA_QUERY_DATA,
     TUYA_SET_TIME,
-    DPToAttributeMapping,
     TuyaCommand,
     TuyaData,
     TuyaDatapointData,
@@ -42,7 +41,7 @@ from zhaquirks.tuya.builder import (
     TuyaTemperatureMeasurement,
     TuyaValveWaterConsumedNoInstDemand,
 )
-from zhaquirks.tuya.mcu import TuyaMCUCluster, TuyaOnOffNM
+from zhaquirks.tuya.mcu import DPToAttributeMapping, TuyaMCUCluster, TuyaOnOffNM
 from zhaquirks.tuya.tuya_sensor import NoManufTimeTuyaMCUCluster
 
 ZCL_TUYA_SET_TIME = b"\x09\x12\x24\x0d\x00"
diff --git a/tests/test_tuya_power.py b/tests/test_tuya_power.py
new file mode 100644
index 0000000..b0f5a52
--- /dev/null
+++ b/tests/test_tuya_power.py
@@ -0,0 +1,90 @@
+"""Tests for Tuya quirks."""
+
+import pytest
+from zigpy.zcl import foundation
+
+import zhaquirks
+import zhaquirks.tuya
+
+zhaquirks.setup()
+
+
+@pytest.mark.parametrize(
+    "msg,attr_suffix,expected_power,expected_current,expected_volt",
+    [
+        (b"\t0\x02\x00\xd3\x06\x00\x00\x08\tn\x00\nA\x00\x01\xa6", "", 422, 2625, 2414),
+        (b"\t2\x02\x00Z\x06\x00\x00\x08\ts\x00\n9\x00\x01\xa5", "", 421, 2617, 2419),
+        (b"\t2\x02\x00Z\x06\x00\x00\x08\ts\x00\n9\x00\x91\xa5", "", -2037, 2617, 2419),
+        (
+            b"\t0\x02\x00\xd3\x07\x00\x00\x08\tn\x00\nA\x00\x01\xa6",
+            "_ph_b",
+            422,
+            2625,
+            2414,
+        ),
+        (
+            b"\t2\x02\x00Z\x07\x00\x00\x08\ts\x00\n9\x00\x01\xa5",
+            "_ph_b",
+            421,
+            2617,
+            2419,
+        ),
+        (
+            b"\t0\x02\x00\xd3\x08\x00\x00\x08\tn\x00\nA\x00\x01\xa6",
+            "_ph_c",
+            422,
+            2625,
+            2414,
+        ),
+        (
+            b"\t2\x02\x00Z\x08\x00\x00\x08\ts\x00\n9\x00\x01\xa5",
+            "_ph_c",
+            421,
+            2617,
+            2419,
+        ),
+    ],
+)
+async def test_ts0601_electrical_measurement_multi_dp_converter(
+    zigpy_device_from_v2_quirk,
+    msg,
+    attr_suffix,
+    expected_power,
+    expected_current,
+    expected_volt,
+):
+    """Test converter for multiple electrical attributes mapped to the same tuya datapoint."""
+
+    quirked = zigpy_device_from_v2_quirk("_TZE200_nslr42tt", "TS0601")
+    ep = quirked.endpoints[1]
+
+    tuya_manufacturer = ep.tuya_manufacturer
+    hdr, data = tuya_manufacturer.deserialize(msg)
+    status = tuya_manufacturer.handle_get_data(data.data)
+    assert status == foundation.Status.SUCCESS
+
+    electrical_meas_cluster = ep.electrical_measurement
+    assert electrical_meas_cluster.get("active_power" + attr_suffix) == expected_power
+    assert electrical_meas_cluster.get("rms_current" + attr_suffix) == expected_current
+    assert electrical_meas_cluster.get("rms_voltage" + attr_suffix) == expected_volt
+
+
+@pytest.mark.parametrize(
+    "msg,expected_power",
+    [
+        (b"\x19\x8a\x02\x00\x0f\t\x02\x00\x04\x00\x00\x00\x80", 128),
+        (b"\x19\x8a\x02\x00\x0f\t\x02\x00\x04\x19\x99\x99\x00", -156),
+    ],
+)
+async def test_ts0601_power_converter(zigpy_device_from_v2_quirk, msg, expected_power):
+    """Test converter for power."""
+
+    quirked = zigpy_device_from_v2_quirk("_TZE200_nslr42tt", "TS0601")
+    ep = quirked.endpoints[1]
+
+    tuya_manufacturer = ep.tuya_manufacturer
+    hdr, data = tuya_manufacturer.deserialize(msg)
+    status = tuya_manufacturer.handle_get_data(data.data)
+    assert status == foundation.Status.SUCCESS
+
+    assert tuya_manufacturer.get("power") == expected_power
diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py
index fb6a452..546dfd9 100644
--- a/tests/test_xiaomi.py
+++ b/tests/test_xiaomi.py
@@ -8,7 +8,7 @@ from unittest import mock
 import pytest
 import zigpy.device
 import zigpy.types as t
-from zigpy.zcl import foundation
+from zigpy.zcl import Cluster, foundation
 from zigpy.zcl.clusters.closures import WindowCovering
 from zigpy.zcl.clusters.general import (
     AnalogInput,
@@ -1434,15 +1434,12 @@ async def test_xiaomi_power_cluster_not_used(zigpy_device_from_quirk, caplog, qu
     )
 
 
-@pytest.mark.parametrize(
-    "quirk", (zhaquirks.xiaomi.aqara.roller_curtain_e1.RollerE1AQ,)
-)
-async def test_xiaomi_e1_roller_curtain_battery(zigpy_device_from_quirk, quirk):
+def test_xiaomi_e1_roller_curtain_battery(zigpy_device_from_v2_quirk):
     """Test Aqara E1 roller curtain battery reporting."""
     # Ideally, get a real Xiaomi "heartbeat" message to test.
     # For now, fake the heartbeat message and check if battery parsing works.
 
-    device = zigpy_device_from_quirk(quirk)
+    device = zigpy_device_from_v2_quirk(LUMI, "lumi.curtain.acn002")
 
     basic_cluster = device.endpoints[1].basic
     ClusterListener(basic_cluster)
@@ -1640,64 +1637,413 @@ async def test_xiaomi_e1_driver_light_level(
 
 
 @pytest.mark.parametrize(
-    "command, value",
+    "command, value, read_current_position",
     [
-        (WindowCovering.ServerCommandDefs.up_open.id, 1),
-        (WindowCovering.ServerCommandDefs.down_close.id, 0),
-        (WindowCovering.ServerCommandDefs.stop.id, 2),
+        (WindowCovering.ServerCommandDefs.up_open.id, 1, True),
+        (WindowCovering.ServerCommandDefs.down_close.id, 0, True),
+        (WindowCovering.ServerCommandDefs.stop.id, 2, True),
     ],
 )
-async def test_xiaomi_e1_roller_commands_1(zigpy_device_from_quirk, command, value):
+async def test_xiaomi_e1_roller_commands_1(
+    zigpy_device_from_v2_quirk, command, value, read_current_position
+):
     """Test Aqara E1 roller commands for basic movement functions using MultistateOutput Cluster."""
-    device = zigpy_device_from_quirk(
-        zhaquirks.xiaomi.aqara.roller_curtain_e1.RollerE1AQ
-    )
+    device = zigpy_device_from_v2_quirk(LUMI, "lumi.curtain.acn002")
 
     window_covering_cluster = device.endpoints[1].window_covering
+    window_covering_listener = ClusterListener(window_covering_cluster)
+    window_covering_attr_id = (
+        WindowCovering.AttributeDefs.current_position_lift_percentage.id
+    )
+
+    analog_cluster = device.endpoints[1].analog_output
+    analog_attr_id = AnalogOutput.AttributeDefs.present_value.id
+
     multistate_cluster = device.endpoints[1].multistate_output
-    multistate_cluster._write_attributes = mock.AsyncMock(
-        return_value=(
-            [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],
-        )
+    multistate_attr_id = MultistateOutput.AttributeDefs.present_value.id
+
+    # fake read response for attributes: return 1 for all attributes
+    def mock_read(attributes, manufacturer=None):
+        records = [
+            foundation.ReadAttributeRecord(
+                attr, foundation.Status.SUCCESS, foundation.TypeValue(None, 1)
+            )
+            for attr in attributes
+        ]
+        return (records,)
+
+    # patch read commands
+    patch_window_covering_read = mock.patch.object(
+        window_covering_cluster,
+        "_read_attributes",
+        mock.AsyncMock(side_effect=mock_read),
+    )
+    patch_analog_read = mock.patch.object(
+        analog_cluster, "_read_attributes", mock.AsyncMock(side_effect=mock_read)
     )
-    attr_id = MultistateOutput.AttributeDefs.present_value.id
 
-    # test command
-    await window_covering_cluster.command(command)
-    assert multistate_cluster._write_attributes.call_count == 1
-    assert multistate_cluster._write_attributes.call_args[0][0][0].attrid == attr_id
-    assert multistate_cluster._write_attributes.call_args[0][0][0].value.value == value
+    # patch write commands
+    patch_multistate_write = mock.patch.object(
+        multistate_cluster,
+        "_write_attributes",
+        mock.AsyncMock(
+            return_value=(
+                [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],
+            )
+        ),
+    )
+
+    with (
+        patch_window_covering_read,
+        patch_analog_read,
+        patch_multistate_write,
+    ):
+        # test command
+        await window_covering_cluster.command(command)
+        assert multistate_cluster._write_attributes.call_count == 1
+        assert (
+            multistate_cluster._write_attributes.call_args[0][0][0].attrid
+            == multistate_attr_id
+        )
+        assert (
+            multistate_cluster._write_attributes.call_args[0][0][0].value.value == value
+        )
+        if read_current_position:
+            # confirm the window covering cluster read was redirected
+            assert len(window_covering_cluster._read_attributes.mock_calls) == 0
+
+            # confirm the analog output read occurs
+            assert len(analog_cluster._read_attributes.mock_calls) == 1
+            assert analog_cluster._read_attributes.mock_calls[0][1][0] == [
+                analog_attr_id
+            ]
+
+            # confirm the position was updated on the ZCL WindowCovering cluster
+            assert len(window_covering_listener.attribute_updates) == 1
+            assert window_covering_listener.attribute_updates[0] == (
+                window_covering_attr_id,
+                100 - 1,
+            )
+        else:
+            # confirm the command did not read the current position
+            assert len(analog_cluster._read_attributes.mock_calls) == 0
 
 
 @pytest.mark.parametrize(
-    "command, value",
+    "command, value, read_current_position",
     [
-        (WindowCovering.ServerCommandDefs.go_to_lift_percentage.id, 60),
+        (WindowCovering.ServerCommandDefs.go_to_lift_percentage.id, 60, True),
     ],
 )
-async def test_xiaomi_e1_roller_commands_2(zigpy_device_from_quirk, command, value):
+async def test_xiaomi_e1_roller_commands_2(
+    zigpy_device_from_v2_quirk, command, value, read_current_position
+):
     """Test Aqara E1 roller commands for go to lift percentage using AnalogOutput cluster."""
-    device = zigpy_device_from_quirk(
-        zhaquirks.xiaomi.aqara.roller_curtain_e1.RollerE1AQ
-    )
+    device = zigpy_device_from_v2_quirk(LUMI, "lumi.curtain.acn002")
 
     window_covering_cluster = device.endpoints[1].window_covering
+    window_covering_listener = ClusterListener(window_covering_cluster)
+    window_covering_attr_id = (
+        WindowCovering.AttributeDefs.current_position_lift_percentage.id
+    )
+
     analog_cluster = device.endpoints[1].analog_output
-    analog_cluster._write_attributes = mock.AsyncMock(
-        return_value=(
-            [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],
+    analog_listener = ClusterListener(analog_cluster)
+    analog_attr_id = AnalogOutput.AttributeDefs.present_value.id
+
+    # fake read response for attributes: return 1 for all attributes
+    def mock_read(attributes, manufacturer=None):
+        records = [
+            foundation.ReadAttributeRecord(
+                attr, foundation.Status.SUCCESS, foundation.TypeValue(None, 1)
+            )
+            for attr in attributes
+        ]
+        return (records,)
+
+    # patch read commands
+    patch_window_covering_read = mock.patch.object(
+        window_covering_cluster,
+        "_read_attributes",
+        mock.AsyncMock(side_effect=mock_read),
+    )
+    patch_analog_read = mock.patch.object(
+        analog_cluster, "_read_attributes", mock.AsyncMock(side_effect=mock_read)
+    )
+
+    # patch write commands
+    patch_analog_write = mock.patch.object(
+        analog_cluster,
+        "_write_attributes",
+        mock.AsyncMock(
+            return_value=(
+                [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],
+            )
+        ),
+    )
+
+    with (
+        patch_window_covering_read,
+        patch_analog_read,
+        patch_analog_write,
+    ):
+        # test go to lift percentage command
+        await window_covering_cluster.go_to_lift_percentage(value)
+        assert analog_cluster._write_attributes.call_count == 1
+        assert (
+            analog_cluster._write_attributes.call_args[0][0][0].attrid == analog_attr_id
         )
+        assert (
+            analog_cluster._write_attributes.call_args[0][0][0].value.value
+            == 100 - value
+        )
+
+        # confirm the AnalogOutput present_value was updated
+        assert analog_listener.attribute_updates[0] == (
+            analog_attr_id,
+            100 - value,
+        )
+        if read_current_position:
+            # confirm the window covering cluster read was redirected
+            assert len(window_covering_cluster._read_attributes.mock_calls) == 0
+
+            # confirm the analog output read occurs
+            assert len(analog_cluster._read_attributes.mock_calls) == 1
+            assert analog_cluster._read_attributes.mock_calls[0][1][0] == [
+                analog_attr_id
+            ]
+
+            # confirm the position was updated on the ZCL WindowCovering cluster with the read value
+            assert len(window_covering_listener.attribute_updates) == 1
+            assert window_covering_listener.attribute_updates[0] == (
+                window_covering_attr_id,
+                100 - 1,
+            )
+        else:
+            # confirm the command did not read the current position
+            assert len(analog_cluster._read_attributes.mock_calls) == 0
+
+            # confirm the AnalogOutput write did not update the current WindowCovering position
+            assert len(window_covering_listener.attribute_updates) == 0
+
+    # confirm non-mapped commands return status UNSUP_CLUSTER_COMMAND
+    _, status = await window_covering_cluster.go_to_tilt_percentage(value)
+    assert status == foundation.Status.UNSUP_CLUSTER_COMMAND
+
+
+@pytest.mark.parametrize(
+    "attr, expected_value, target_attr, target_cluster",
+    [
+        (
+            WindowCovering.AttributeDefs.current_position_lift_percentage,
+            99,
+            AnalogOutput.AttributeDefs.present_value,
+            AnalogOutput,
+        ),  # Redirect with read success
+        (
+            WindowCovering.AttributeDefs.current_position_lift_percentage,
+            None,
+            AnalogOutput.AttributeDefs.present_value,
+            AnalogOutput,
+        ),  # Redirect with read failure
+        (
+            WindowCovering.AttributeDefs.config_status,
+            1,
+            None,
+            None,
+        ),  # Regular read success
+        (
+            WindowCovering.AttributeDefs.config_status,
+            None,
+            None,
+            None,
+        ),  # Regular read failure
+    ],
+)
+async def test_xiaomi_e1_roller_window_covering_read_redirection(
+    zigpy_device_from_v2_quirk,
+    attr: foundation.ZCLAttributeDef,
+    expected_value: int | None,
+    target_attr: foundation.ZCLAttributeDef | None,
+    target_cluster: Cluster | None,
+):
+    """Test Aqara E1 roller WindowCovering attribute read redirection."""
+    device = zigpy_device_from_v2_quirk(LUMI, "lumi.curtain.acn002")
+
+    window_covering_cluster = device.endpoints[1].window_covering
+    window_covering_listener = ClusterListener(window_covering_cluster)
+
+    redirect = False
+    if target_attr and target_cluster:
+        target_cluster = getattr(device.endpoints[1], target_cluster.ep_attribute)
+        redirect = True
+
+    # fake read response for attributes, a value of 1 is returned if expected_value is not None
+    def mock_read(attributes, manufacturer=None):
+        records = [
+            foundation.ReadAttributeRecord(
+                attr,
+                foundation.Status.SUCCESS
+                if expected_value
+                else foundation.Status.FAILURE,
+                foundation.TypeValue(None, 1),
+            )
+            for attr in attributes
+        ]
+        return (records,)
+
+    # patch window covering read command
+    patch_window_covering_read = mock.patch.object(
+        window_covering_cluster,
+        "_read_attributes",
+        mock.AsyncMock(side_effect=(mock_read)),
     )
-    attr_id = AnalogOutput.AttributeDefs.present_value.id
 
-    # test go to lift percentage command
-    await window_covering_cluster.go_to_lift_percentage(value)
-    assert analog_cluster._write_attributes.call_count == 1
-    assert analog_cluster._write_attributes.call_args[0][0][0].attrid == attr_id
-    assert (
-        analog_cluster._write_attributes.call_args[0][0][0].value.value == 100 - value
+    if redirect:
+        # patch target cluster read command
+        patch_target_read = mock.patch.object(
+            target_cluster,
+            "_read_attributes",
+            mock.AsyncMock(side_effect=(mock_read)),
+        )
+        with (
+            patch_window_covering_read,
+            patch_target_read,
+        ):
+            # read attribute from WindowCovering cluster using id and name
+            await window_covering_cluster.read_attributes([attr.id])
+            await window_covering_cluster.read_attributes([attr.name])
+
+            # confirm the reads were redirected to the target cluster
+            assert len(window_covering_cluster._read_attributes.mock_calls) == 0
+            assert len(target_cluster._read_attributes.mock_calls) == 2
+            assert target_cluster._read_attributes.mock_calls[0][1][0] == [
+                target_attr.id
+            ]
+            assert target_cluster._read_attributes.mock_calls[1][1][0] == [
+                target_attr.id
+            ]
+    else:
+        with (
+            patch_window_covering_read,
+        ):
+            # read attribute from WindowCovering cluster using id and name
+            await window_covering_cluster.read_attributes([attr.id])
+            await window_covering_cluster.read_attributes([attr.name])
+
+            # confirm the reads occurred normally
+            assert len(window_covering_cluster._read_attributes.mock_calls) == 2
+            assert window_covering_cluster._read_attributes.mock_calls[0][1][0] == [
+                attr.id
+            ]
+            assert window_covering_cluster._read_attributes.mock_calls[1][1][0] == [
+                attr.id
+            ]
+
+    if not expected_value:
+        # check read fails do not trigger an attribute update
+        assert len(window_covering_listener.attribute_updates) == 0
+        return
+
+    # check the WindowCovering attribute was updated by the reads
+    assert len(window_covering_listener.attribute_updates) == 2
+    assert window_covering_listener.attribute_updates[0] == (
+        attr.id,
+        expected_value,
+    )
+    assert window_covering_listener.attribute_updates[1] == (
+        attr.id,
+        expected_value,
+    )
+
+
+async def test_xiaomi_e1_roller_write_aware_update_attribute(
+    zigpy_device_from_v2_quirk,
+):
+    """Test Aqara E1 roller AnalogOutput write-aware update_attribute method."""
+    device = zigpy_device_from_v2_quirk(LUMI, "lumi.curtain.acn002")
+
+    window_covering_cluster = device.endpoints[1].window_covering
+    window_covering_listener = ClusterListener(window_covering_cluster)
+
+    analog_cluster = device.endpoints[1].analog_output
+    analog_listener = ClusterListener(analog_cluster)
+    analog_attr = AnalogOutput.AttributeDefs.present_value
+    analog_attr_max = AnalogOutput.AttributeDefs.max_present_value
+
+    # patch write command for a success response
+    patch_analog_write = mock.patch.object(
+        analog_cluster,
+        "_write_attributes",
+        mock.AsyncMock(
+            return_value=(
+                [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],
+            )
+        ),
     )
 
+    # patch write command for a fail response
+    patch_analog_write_fail = mock.patch.object(
+        analog_cluster,
+        "_write_attributes",
+        mock.AsyncMock(
+            return_value=(
+                [
+                    foundation.WriteAttributesStatusRecord(
+                        foundation.Status.INVALID_VALUE, analog_attr.id
+                    ),
+                ],
+            )
+        ),
+    )
+
+    with (
+        patch_analog_write,
+    ):
+        # test writing valid and invalid values using name & id
+        await analog_cluster.write_attributes({analog_attr.id: 50})
+        await analog_cluster.write_attributes({analog_attr.name: 60})
+        assert analog_cluster._write_attributes.call_count == 2
+
+        # confirm the two successful writes updated the analog cluster
+        assert len(analog_listener.attribute_updates) == 2
+        assert analog_listener.attribute_updates[0] == (
+            analog_attr.id,
+            50,
+        )
+        assert analog_listener.attribute_updates[1] == (
+            analog_attr.id,
+            60,
+        )
+
+    with (
+        patch_analog_write_fail,
+    ):
+        # test writing valid and invalid values using name & id
+        await analog_cluster.write_attributes(
+            {analog_attr_max.id: 100, analog_attr.id: 150}
+        )
+        await analog_cluster.write_attributes(
+            {analog_attr_max.name: 100, analog_attr.name: 160}
+        )
+        assert analog_cluster._write_attributes.call_count == 2
+
+        # confirm the two failed attr writes did not update the analog cluster
+        assert len(analog_listener.attribute_updates) == 4
+
+        # confirm the two successful writes updated the analog cluster
+        assert analog_listener.attribute_updates[2] == (
+            analog_attr_max.id,
+            100,
+        )
+        assert analog_listener.attribute_updates[3] == (
+            analog_attr_max.id,
+            100,
+        )
+
+    # confirm the write invoked update_attributes did not update the covering cluster
+    assert len(window_covering_listener.attribute_updates) == 0
+
 
 @pytest.mark.parametrize("endpoint", [(1), (2)])
 async def test_aqara_t2_relay(zigpy_device_from_quirk, endpoint):
@@ -1826,11 +2172,11 @@ async def test_aqara_fp1e_sensor(
     expected_motion_status,
 ):
     """Test Aqara FP1E sensor."""
-    quirk = zigpy_device_from_v2_quirk("aqara", "lumi.sensor_occupy.agl1")
+    device = zigpy_device_from_v2_quirk("aqara", "lumi.sensor_occupy.agl1")
 
-    opple_cluster = quirk.endpoints[1].opple_cluster
-    ias_cluster = quirk.endpoints[1].ias_zone
-    occupancy_cluster = quirk.endpoints[1].occupancy
+    opple_cluster = device.endpoints[1].opple_cluster
+    ias_cluster = device.endpoints[1].ias_zone
+    occupancy_cluster = device.endpoints[1].occupancy
 
     opple_listener = ClusterListener(opple_cluster)
     ias_listener = ClusterListener(ias_cluster)
@@ -1861,15 +2207,15 @@ async def test_aqara_fp1e_sensor(
 def test_h1_wireless_remotes(zigpy_device_from_v2_quirk):
     """Test Aqara H1 wireless remote quirk adds missing endpoints."""
     # create device with endpoint 1 only and verify we don't get a KeyError
-    quirk = zigpy_device_from_v2_quirk(LUMI, "lumi.remote.b28ac1")
+    device = zigpy_device_from_v2_quirk(LUMI, "lumi.remote.b28ac1")
 
     # verify the quirk adds endpoints 2 and 3
-    assert 2 in quirk.endpoints
-    assert 3 in quirk.endpoints
+    assert 2 in device.endpoints
+    assert 3 in device.endpoints
 
     # verify the quirk adds the correct clusters to the new endpoints
-    assert OnOff.cluster_id in quirk.endpoints[2].out_clusters
-    assert OnOff.cluster_id in quirk.endpoints[3].out_clusters
+    assert OnOff.cluster_id in device.endpoints[2].out_clusters
+    assert OnOff.cluster_id in device.endpoints[3].out_clusters
 
-    assert MultistateInput.cluster_id in quirk.endpoints[2].in_clusters
-    assert MultistateInput.cluster_id in quirk.endpoints[3].in_clusters
+    assert MultistateInput.cluster_id in device.endpoints[2].in_clusters
+    assert MultistateInput.cluster_id in device.endpoints[3].in_clusters