Repository: zigpy/zha-device-handlers
Pull request #3905 (2025-02-26 10:51:19)

Add external switch type setting to various Candeo devices
## Proposed change
<!--
  Explain your proposed change below.
-->

Adds specific Quirks for:

Candeo C205 Switch Module
Candeo C-ZB-SM205-2G Switch Module
Candeo C203 Dimmer Module
Candeo C204 Dimmer Module
Candeo C-ZB-DM204 Dimmer Module

## 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".
-->

We've been providing Quirks to our customers for some time via our own Github repository, we're hoping now to have them officially directly integrated in order to alleviate customers needing to manually add them to their ZHA configuration.

## Checklist
<!--
  Put an 'x' in all boxes that apply.
  Note: You do not need to tick all boxes before creating a PR.
-->

- [x] The changes are tested and work correctly
- [x] `pre-commit` checks pass / the code has been formatted using Black
- [ ] Tests have been added to verify that the new code works

This is our first submission to the project, so hopefully we're doing things correctly.  

Please let us know if not!

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
Attention: Patch coverage is `93.75000%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 91.03%. 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 [(`3e40b6a`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/3e40b6a9fffee8c8bf5aab4cd47578a0fe2af47b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

| [Files with missing lines](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | Patch % | Lines |
|---|---|---|
| [...uirks/candeo/candeo\_c-zb-dm204\_dimmer\_module\_V2.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&filepath=zhaquirks%2Fcandeo%2Fcandeo_c-zb-dm204_dimmer_module_V2.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2NhbmRlby9jYW5kZW9fYy16Yi1kbTIwNF9kaW1tZXJfbW9kdWxlX1YyLnB5) | 93.75% | [1 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) |
| [...ks/candeo/candeo\_c-zb-sm205-2g\_switch\_module\_V2.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&filepath=zhaquirks%2Fcandeo%2Fcandeo_c-zb-sm205-2g_switch_module_V2.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2NhbmRlby9jYW5kZW9fYy16Yi1zbTIwNS0yZ19zd2l0Y2hfbW9kdWxlX1YyLnB5) | 93.75% | [1 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) |
| [zhaquirks/candeo/candeo\_c203\_dimmer\_module\_V2.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&filepath=zhaquirks%2Fcandeo%2Fcandeo_c203_dimmer_module_V2.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2NhbmRlby9jYW5kZW9fYzIwM19kaW1tZXJfbW9kdWxlX1YyLnB5) | 93.75% | [1 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) |
| [zhaquirks/candeo/candeo\_c204\_dimmer\_module\_V2.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&filepath=zhaquirks%2Fcandeo%2Fcandeo_c204_dimmer_module_V2.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2NhbmRlby9jYW5kZW9fYzIwNF9kaW1tZXJfbW9kdWxlX1YyLnB5) | 93.75% | [1 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) |
| [zhaquirks/candeo/candeo\_c205\_switch\_module\_V2.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&filepath=zhaquirks%2Fcandeo%2Fcandeo_c205_switch_module_V2.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2NhbmRlby9jYW5kZW9fYzIwNV9zd2l0Y2hfbW9kdWxlX1YyLnB5) | 93.75% | [1 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              dev    #3905      +/-   ##
==========================================
+ Coverage   91.00%   91.03%   +0.02%     
==========================================
  Files         328      333       +5     
  Lines       10656    10736      +80     
==========================================
+ Hits         9698     9773      +75     
- Misses        958      963       +5     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3905?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/README.md b/README.md
index 99e3c6f..cf6b0f2 100644
--- a/README.md
+++ b/README.md
@@ -456,7 +456,7 @@ The tests use the [pytest](https://docs.pytest.org/en/latest/) framework.
 To get set up, you need install the test dependencies:
 
 ```bash
-pip install -r requirements_test_all.txt
+pip install -r requirements_test.txt
 ```
 
 ### Running the tests
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/candeo/__init__.py b/zhaquirks/candeo/__init__.py
new file mode 100644
index 0000000..499d908
--- /dev/null
+++ b/zhaquirks/candeo/__init__.py
@@ -0,0 +1,30 @@
+"""Module for Candeo quirks implementations."""
+
+from zigpy.quirks import CustomCluster
+import zigpy.types as t
+from zigpy.zcl.clusters.general import Basic
+from zigpy.zcl.foundation import DataTypeId, ZCLAttributeDef
+
+CANDEO = "Candeo"
+
+
+class CandeoSwitchType(t.enum8):
+    """Candeo Switch Type."""
+
+    Momentary = 0x00
+    Toggle = 0x01
+
+
+class CandeoBasicCluster(Basic, CustomCluster):
+    """Candeo Basic Cluster."""
+
+    class AttributeDefs(Basic.AttributeDefs):
+        """Attribute Definitions."""
+
+        external_switch_type = ZCLAttributeDef(
+            id=0x8803,
+            type=CandeoSwitchType,
+            zcl_type=DataTypeId.uint8,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
diff --git a/zhaquirks/candeo/candeo_modules_V2.py b/zhaquirks/candeo/candeo_modules_V2.py
new file mode 100644
index 0000000..6797d20
--- /dev/null
+++ b/zhaquirks/candeo/candeo_modules_V2.py
@@ -0,0 +1,36 @@
+"""Candeo modules."""
+
+from zigpy.quirks.v2 import QuirkBuilder
+
+from zhaquirks.candeo import CANDEO, CandeoBasicCluster, CandeoSwitchType
+
+(
+    QuirkBuilder(CANDEO, "C203")
+    .applies_to(CANDEO, "HK-LN-DIM-A")
+    .applies_to(CANDEO, "C204")
+    .applies_to(CANDEO, "C-ZB-DM204")
+    .applies_to(CANDEO, "C205")
+    .replaces(CandeoBasicCluster)
+    .enum(
+        attribute_name=CandeoBasicCluster.AttributeDefs.external_switch_type.name,
+        enum_class=CandeoSwitchType,
+        cluster_id=CandeoBasicCluster.cluster_id,
+        translation_key="external_switch_type",
+        fallback_name="External switch type",
+    )
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder(CANDEO, "C-ZB-SM205-2G")
+    .replaces(CandeoBasicCluster, endpoint_id=11)
+    .enum(
+        attribute_name=CandeoBasicCluster.AttributeDefs.external_switch_type.name,
+        enum_class=CandeoSwitchType,
+        cluster_id=CandeoBasicCluster.cluster_id,
+        endpoint_id=11,
+        translation_key="external_switch_type",
+        fallback_name="External switch type",
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/eurotronic/__init__.py b/zhaquirks/eurotronic/__init__.py
index 28c2d0d..1753cd1 100644
--- a/zhaquirks/eurotronic/__init__.py
+++ b/zhaquirks/eurotronic/__init__.py
@@ -133,19 +133,19 @@ class ThermostatCluster(CustomCluster, Thermostat):
 
         return success, error
 
-    def write_attributes(self, attributes, manufacturer=None, **kwargs):
+    async def write_attributes(self, attributes, manufacturer=None, **kwargs):
         """Override wrong writes to thermostat attributes."""
         if "system_mode" in attributes:
             host_flags = self._attr_cache.get(HOST_FLAGS_ATTR, 1)
             _LOGGER.debug("current host_flags: %s", host_flags)
 
             if attributes.get("system_mode") == 0x0:
-                return super().write_attributes(
+                return await super().write_attributes(
                     {"host_flags": host_flags | SET_OFF_MODE_FLAG}, MANUFACTURER
                 )
             if attributes.get("system_mode") == 0x4:
-                return super().write_attributes(
+                return await super().write_attributes(
                     {"host_flags": host_flags | CLR_OFF_MODE_FLAG}, MANUFACTURER
                 )
 
-        return super().write_attributes(attributes, manufacturer, **kwargs)
+        return await super().write_attributes(attributes, manufacturer, **kwargs)
diff --git a/zhaquirks/legrand/wirelessswitch.py b/zhaquirks/legrand/wirelessswitch.py
new file mode 100644
index 0000000..5264c49
--- /dev/null
+++ b/zhaquirks/legrand/wirelessswitch.py
@@ -0,0 +1,42 @@
+"""Module for Legrand wireless radiant switch."""
+
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.zcl.clusters.general import BinaryInput
+
+from zhaquirks.const import (
+    BUTTON,
+    COMMAND,
+    COMMAND_MOVE,
+    COMMAND_OFF,
+    COMMAND_ON,
+    COMMAND_STOP,
+    LONG_PRESS,
+    LONG_RELEASE,
+    PARAMS,
+    SHORT_PRESS,
+    TURN_OFF,
+    TURN_ON,
+)
+from zhaquirks.legrand import LEGRAND, LegrandPowerConfigurationCluster
+
+(
+    QuirkBuilder(f" {LEGRAND}", " Remote switch")
+    .replaces(LegrandPowerConfigurationCluster)
+    .prevent_default_entity_creation(endpoint_id=1, cluster_id=BinaryInput.cluster_id)
+    .device_automation_triggers(
+        {
+            (SHORT_PRESS, TURN_ON): {COMMAND: COMMAND_ON},
+            (LONG_PRESS, TURN_ON): {
+                COMMAND: COMMAND_MOVE,
+                PARAMS: {"move_mode": 0, "rate": 255},
+            },
+            (SHORT_PRESS, TURN_OFF): {COMMAND: COMMAND_OFF},
+            (LONG_PRESS, TURN_OFF): {
+                COMMAND: COMMAND_MOVE,
+                PARAMS: {"move_mode": 1, "rate": 255},
+            },
+            (LONG_RELEASE, BUTTON): {COMMAND: COMMAND_STOP},
+        }
+    )
+    .add_to_registry()
+)
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/philips/hue_light.py b/zhaquirks/philips/hue_light.py
index 8e4b016..ef60d30 100644
--- a/zhaquirks/philips/hue_light.py
+++ b/zhaquirks/philips/hue_light.py
@@ -121,6 +121,28 @@ from zhaquirks.philips import PHILIPS, SIGNIFY, PhilipsHueLightCluster
     .add_to_registry()
 )
 
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "LTA001")
+    .friendly_name(
+        model="Hue white ambiance E27 with Bluetooth",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder()
+    .applies_to(SIGNIFY, "LWU001")
+    .friendly_name(
+        model="Hue P45 light bulb",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
+
 (
     QuirkBuilder()
     .applies_to(PHILIPS, "7602031P7")
@@ -132,3 +154,18 @@ from zhaquirks.philips import PHILIPS, SIGNIFY, PhilipsHueLightCluster
     .replaces(PhilipsHueLightCluster, endpoint_id=11)
     .add_to_registry()
 )
+
+(
+    QuirkBuilder()
+    .applies_to(PHILIPS, "1743130P7")
+    .applies_to(PHILIPS, "1743430P7")
+    .applies_to(PHILIPS, "1743230P7")
+    .applies_to(PHILIPS, "1745430A7")
+    .applies_to(PHILIPS, "1745430P7")
+    .friendly_name(
+        model="Hue Impress outdoor Pedestal",
+        manufacturer="Philips",
+    )
+    .replaces(PhilipsHueLightCluster, endpoint_id=11)
+    .add_to_registry()
+)
diff --git a/zhaquirks/plaid/soil.py b/zhaquirks/plaid/soil.py
index 8a2bd46..867823e 100644
--- a/zhaquirks/plaid/soil.py
+++ b/zhaquirks/plaid/soil.py
@@ -1,19 +1,8 @@
 """PLAID SYSTEMS PS-SPRZMS-SLP3 soil moisture sensor."""
 
-from zigpy.profiles import zha
-from zigpy.quirks import CustomDevice
-from zigpy.zcl.clusters.general import Basic, Identify, Ota, PowerConfiguration
-from zigpy.zcl.clusters.measurement import RelativeHumidity, TemperatureMeasurement
+from zigpy.quirks.v2 import QuirkBuilder
 
 from zhaquirks import PowerConfigurationCluster
-from zhaquirks.const import (
-    DEVICE_TYPE,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
-    MODELS_INFO,
-    OUTPUT_CLUSTERS,
-    PROFILE_ID,
-)
 from zhaquirks.plaid import PLAID_SYSTEMS
 
 
@@ -36,55 +25,21 @@ class PowerConfigurationClusterMains(PowerConfigurationCluster):
             return self.MAINS_VOLTAGE_ATTR
         return attr
 
-    def read_attributes(self, attributes, *args, **kwargs):  # pylint: disable=W0221
+    async def read_attributes(self, attributes, *args, **kwargs):
         """Replace battery voltage with mains voltage."""
-        return super().read_attributes(
+        return await super().read_attributes(
             [self._remap(attr) for attr in attributes], *args, **kwargs
         )
 
-    def configure_reporting(self, attribute, *args, **kwargs):  # pylint: disable=W0221
+    async def configure_reporting(self, attribute, *args, **kwargs):
         """Replace battery voltage with mains voltage."""
-        return super().configure_reporting(self._remap(attribute), *args, **kwargs)
-
-
-class SoilMoisture(CustomDevice):
-    """Custom device representing plaid systems soil sensors."""
+        return await super().configure_reporting(
+            self._remap(attribute), *args, **kwargs
+        )
 
-    signature = {
-        #  <SimpleDescriptor endpoint=1 profile=260 device_type=1029
-        # device_version=0
-        # input_clusters=[0, 1, 3, 1026, 1029]
-        # output_clusters=[3, 25]>
-        MODELS_INFO: [(PLAID_SYSTEMS, "PS-SPRZMS-SLP3")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: 1029,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    PowerConfiguration.cluster_id,
-                    Identify.cluster_id,
-                    TemperatureMeasurement.cluster_id,
-                    RelativeHumidity.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [Identify.cluster_id, Ota.cluster_id],
-            }
-        },
-    }
 
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: 1029,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    PowerConfigurationClusterMains,
-                    Identify.cluster_id,
-                    TemperatureMeasurement.cluster_id,
-                    RelativeHumidity.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [Identify.cluster_id, Ota.cluster_id],
-            }
-        }
-    }
+(
+    QuirkBuilder(PLAID_SYSTEMS, "PS-SPRZMS-SLP3")
+    .replaces(PowerConfigurationClusterMains, endpoint_id=1)
+    .add_to_registry()
+)
diff --git a/zhaquirks/schneiderelectric/smoke.py b/zhaquirks/schneiderelectric/smoke.py
new file mode 100644
index 0000000..bd1a0da
--- /dev/null
+++ b/zhaquirks/schneiderelectric/smoke.py
@@ -0,0 +1,173 @@
+"""Scneider Electric Smoke Alarm."""
+
+from typing import Final
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import EntityType, QuirkBuilder, ReportingConfig
+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 ZCLAttributeDef
+
+from zhaquirks.schneiderelectric import SE_MANUF_NAME, SEBasic
+
+
+class SELEDBrightness(t.enum8):
+    """LED brightness."""
+
+    Min = 0x00
+    Max = 0x01
+
+
+class SEAlarmSoundLevel(t.enum8):
+    """Alarm sound level."""
+
+    Min = 0x00
+    Max = 0x01
+
+
+class SEAlarmSoundMode(t.enum8):
+    """Alarm sound mode."""
+
+    PressureLow = 0x00
+    PressureMid = 0x01
+    PressureHigh = 0x02
+
+
+class SEAlarmConfiguration(CustomCluster):
+    """Schneider Electric Alarm Configuration cluster."""
+
+    cluster_id = 0xFC04
+    name = "SEAlarmConfiguration"
+
+    class AttributeDefs(CustomCluster.AttributeDefs):
+        """Attribute definitions."""
+
+        se_led_brightness: Final = ZCLAttributeDef(
+            id=0x0000,
+            type=SELEDBrightness,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+        se_alarm_sound_level: Final = ZCLAttributeDef(
+            id=0x0001,
+            type=SEAlarmSoundLevel,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+        se_alarm_sound_mode: Final = ZCLAttributeDef(
+            id=0x0002,
+            type=SEAlarmSoundMode,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+        se_lifetime: Final = ZCLAttributeDef(
+            id=0x0003,
+            type=t.uint8_t,
+            access="r",
+            is_manufacturer_specific=True,
+        )
+        se_hush_duration: Final = ZCLAttributeDef(
+            id=0x0004,
+            type=t.uint8_t,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+        se_test_mode: Final = ZCLAttributeDef(
+            id=0x0005,
+            type=t.Bool,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+        se_silence_alarm: Final = ZCLAttributeDef(
+            id=0x0006,
+            type=t.Bool,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+        se_cluster_revision: Final = ZCLAttributeDef(
+            id=0xFFFD,
+            type=t.uint16_t,
+            access="r",
+            is_manufacturer_specific=True,
+        )
+
+
+(
+    QuirkBuilder(SE_MANUF_NAME, "W599001")
+    .applies_to(SE_MANUF_NAME, "W599501")
+    .applies_to(SE_MANUF_NAME, "755WSA")
+    .replaces(SEBasic, endpoint_id=20)
+    .replaces(SEAlarmConfiguration, endpoint_id=20)
+    .enum(
+        cluster_id=SEAlarmConfiguration.cluster_id,
+        endpoint_id=20,
+        attribute_name=SEAlarmConfiguration.AttributeDefs.se_led_brightness.name,
+        translation_key="led_brightness",
+        fallback_name="LED brightness",
+        enum_class=SELEDBrightness,
+    )
+    .enum(
+        cluster_id=SEAlarmConfiguration.cluster_id,
+        endpoint_id=20,
+        attribute_name=SEAlarmConfiguration.AttributeDefs.se_alarm_sound_level.name,
+        translation_key="alarm_sound_level",
+        fallback_name="Alarm sound level",
+        enum_class=SEAlarmSoundLevel,
+    )
+    .enum(
+        cluster_id=SEAlarmConfiguration.cluster_id,
+        endpoint_id=20,
+        attribute_name=SEAlarmConfiguration.AttributeDefs.se_alarm_sound_mode.name,
+        translation_key="alarm_sound_mode",
+        fallback_name="Alarm sound mode",
+        enum_class=SEAlarmSoundMode,
+    )
+    .sensor(
+        cluster_id=SEAlarmConfiguration.cluster_id,
+        endpoint_id=20,
+        attribute_name=SEAlarmConfiguration.AttributeDefs.se_lifetime.name,
+        translation_key="lifetime",
+        fallback_name="Lifetime",
+        unit=UnitOfTime.YEARS,
+        multiplier=0.5,
+        entity_type=EntityType.DIAGNOSTIC,
+    )
+    .number(
+        cluster_id=SEAlarmConfiguration.cluster_id,
+        endpoint_id=20,
+        attribute_name=SEAlarmConfiguration.AttributeDefs.se_hush_duration.name,
+        translation_key="hush_duration",
+        fallback_name="Hush duration",
+        device_class=NumberDeviceClass.DURATION,
+        unit=UnitOfTime.MINUTES,
+        min_value=5,
+        max_value=15,
+        step=1,
+    )
+    .switch(
+        cluster_id=SEAlarmConfiguration.cluster_id,
+        endpoint_id=20,
+        attribute_name=SEAlarmConfiguration.AttributeDefs.se_test_mode.name,
+        translation_key="test_mode",
+        fallback_name="Test mode",
+        reporting_config=ReportingConfig(
+            min_interval=0,
+            max_interval=0,
+            reportable_change=1,
+        ),
+    )
+    .switch(
+        cluster_id=SEAlarmConfiguration.cluster_id,
+        endpoint_id=20,
+        attribute_name=SEAlarmConfiguration.AttributeDefs.se_silence_alarm.name,
+        translation_key="silence_alarm",
+        fallback_name="Silence alarm",
+        reporting_config=ReportingConfig(
+            min_interval=0,
+            max_interval=0,
+            reportable_change=1,
+        ),
+    )
+    .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/sonoff/trvzb.py b/zhaquirks/sonoff/trvzb.py
index 1c01f9a..a7e4525 100644
--- a/zhaquirks/sonoff/trvzb.py
+++ b/zhaquirks/sonoff/trvzb.py
@@ -1,7 +1,7 @@
 """Sonoff TRVZB - Zigbee Thermostatic Radiator Valve."""
 
 from zigpy.quirks import CustomCluster
-from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2 import NumberDeviceClass, QuirkBuilder
 from zigpy.quirks.v2.homeassistant import UnitOfTemperature
 import zigpy.types as t
 from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
@@ -70,6 +70,21 @@ class CustomSonoffCluster(CustomCluster):
             type=t.uint8_t,
         )
 
+        external_temperature_sensor_enable = ZCLAttributeDef(
+            id=0x600E,
+            type=t.uint8_t,
+        )
+
+        external_temperature_sensor_value = ZCLAttributeDef(
+            id=0x600D,
+            type=t.int16s,
+        )
+
+        temperature_control_accuracy = ZCLAttributeDef(
+            id=0x6011,
+            type=t.int16s,
+        )
+
     @property
     def _is_manuf_specific(self):
         return False
@@ -121,5 +136,35 @@ class CustomSonoffCluster(CustomCluster):
         fallback_name="Valve closing degree",
         initially_disabled=True,
     )
+    .number(
+        CustomSonoffCluster.AttributeDefs.temperature_control_accuracy.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=-1.0,
+        max_value=-0.2,
+        step=0.2,
+        device_class=NumberDeviceClass.TEMPERATURE,
+        unit=UnitOfTemperature.CELSIUS,
+        multiplier=0.01,
+        translation_key="temperature_control_accuracy",
+        fallback_name="Temperature control accuracy",
+    )
+    .switch(
+        CustomSonoffCluster.AttributeDefs.external_temperature_sensor_enable.name,
+        CustomSonoffCluster.cluster_id,
+        translation_key="external_temperature_sensor",
+        fallback_name="External temperature sensor",
+    )
+    .number(
+        CustomSonoffCluster.AttributeDefs.external_temperature_sensor_value.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=0.0,
+        max_value=99.9,
+        step=0.1,
+        device_class=NumberDeviceClass.TEMPERATURE,
+        unit=UnitOfTemperature.CELSIUS,
+        multiplier=0.01,
+        translation_key="external_temperature_sensor_value",
+        fallback_name="External temperature sensor value",
+    )
     .add_to_registry()
 )
diff --git a/zhaquirks/sonoff/zbminir2.py b/zhaquirks/sonoff/zbminir2.py
index 77ab5bb..5d0168b 100644
--- a/zhaquirks/sonoff/zbminir2.py
+++ b/zhaquirks/sonoff/zbminir2.py
@@ -13,41 +13,27 @@ class SonoffCluster(CustomCluster):
 
     cluster_id = 0xFC11
 
+    manufacturer_id_override = foundation.ZCLHeader.NO_MANUFACTURER_ID
+
     class AttributeDefs(BaseAttributeDefs):
         """Attribute definitions."""
 
         external_trigger_mode = ZCLAttributeDef(
             id=0x0016,
             type=t.uint8_t,
+            is_manufacturer_specific=True,
         )
         detach_relay = ZCLAttributeDef(
             id=0x0017,
             type=t.Bool,
+            is_manufacturer_specific=True,
         )
         turbo_mode = ZCLAttributeDef(
             id=0x0012,
             type=t.int16s,
+            is_manufacturer_specific=True,
         )
 
-    async def _read_attributes(
-        self,
-        attribute_ids: list[t.uint16_t],
-        *args,
-        manufacturer: int | t.uint16_t | None = None,
-        **kwargs,
-    ):
-        """Read attributes ZCL foundation command."""
-        return await super()._read_attributes(
-            attribute_ids,
-            *args,
-            manufacturer=foundation.ZCLHeader.NO_MANUFACTURER_ID,
-            **kwargs,
-        )
-
-    @property
-    def _is_manuf_specific(self):
-        return False
-
 
 class SonoffExternalSwitchTriggerType(types.enum8):
     """extern switch trigger type."""
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..397649c 100644
--- a/zhaquirks/tuya/__init__.py
+++ b/zhaquirks/tuya/__init__.py
@@ -499,12 +499,12 @@ class TuyaManufClusterAttributes(TuyaManufCluster):
         zvalue = ztype(tuya_data)
         self._update_attribute(tuya_cmd, zvalue)
 
-    def read_attributes(
+    async def read_attributes(
         self, attributes, allow_cache=False, only_cache=False, manufacturer=None
     ):
         """Ignore remote reads as the "get_data" command doesn't seem to do anything."""
 
-        return super().read_attributes(
+        return await super().read_attributes(
             attributes, allow_cache=True, only_cache=True, manufacturer=manufacturer
         )
 
@@ -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..f747951 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..8de9255 100644
--- a/zhaquirks/tuya/mcu/__init__.py
+++ b/zhaquirks/tuya/mcu/__init__.py
@@ -3,7 +3,6 @@
 from __future__ import annotations
 
 from collections.abc import Callable
-import dataclasses
 import datetime
 from typing import Any
 
@@ -17,8 +16,8 @@ from zhaquirks import Bus, DoublingPowerConfigurationCluster
 from zhaquirks.tuya import (
     TUYA_MCU_COMMAND,
     TUYA_MCU_VERSION_RSP,
-    TUYA_SET_DATA,
     TUYA_SET_TIME,
+    DPToAttributeMapping as DpToAttributeMappingBase,
     EnchantedDevice,  # noqa: F401
     NoManufacturerCluster,
     PowerOnState,
@@ -36,15 +35,20 @@ ATTR_MCU_VERSION = 0xEF00
 TUYA_MCU_CONNECTION_STATUS = 0x25
 
 
-@dataclasses.dataclass
-class DPToAttributeMapping:
+class DPToAttributeMapping(DpToAttributeMappingBase):
     """Container for datapoint to cluster attribute update mapping."""
 
-    ep_attribute: str
-    attribute_name: str | tuple[str, ...]
-    converter: Callable[[Any], Any] | None = None
-    dp_converter: Callable[[Any], Any] | None = None
-    endpoint_id: int | None = None
+    def __init__(
+        self,
+        ep_attribute: str,
+        attribute_name: str | tuple[str, ...],
+        converter: Callable[[Any], Any] | None = None,
+        dp_converter: Callable[[Any], Any] | None = None,
+        endpoint_id: int | None = None,
+    ):
+        """Init method for compatibility with previous quirks using positional arguments."""
+        super().__init__(ep_attribute, attribute_name, converter, endpoint_id)
+        self.dp_converter = dp_converter
 
 
 class TuyaClusterData(t.Struct):
@@ -76,13 +80,13 @@ class TuyaPowerConfigurationCluster(
 class TuyaAttributesCluster(TuyaLocalCluster):
     """Manufacturer specific cluster for Tuya converting attributes <-> commands."""
 
-    def read_attributes(
+    async def read_attributes(
         self, attributes, allow_cache=False, only_cache=False, manufacturer=None
     ):
         """Ignore remote reads as the "get_data" command doesn't seem to do anything."""
 
         self.debug("read_attributes --> attrs: %s", attributes)
-        return super().read_attributes(
+        return await super().read_attributes(
             attributes, allow_cache=True, only_cache=True, manufacturer=manufacturer
         )
 
@@ -262,7 +266,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..f6e5217 100644
--- a/zhaquirks/tuya/ts0601_cover.py
+++ b/zhaquirks/tuya/ts0601_cover.py
@@ -222,6 +222,7 @@ class TuyaZemismartSmartCover0601_3_inv_position(TuyaWindowCover):
         # <SimpleDescriptor endpoint=1 profile=260 device_type=51 input_clusters=[0, 4, 5, 61184] output_clusters=[25]>
         MODELS_INFO: [
             ("_TZE200_zpzndjez", "TS0601"),
+            ("_TZE200_ba69l9ol", "TS0601"),
         ],
         ENDPOINTS: {
             1: {
@@ -486,6 +487,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/ts0601_switch.py b/zhaquirks/tuya/ts0601_switch.py
index 5c702fd..f8baaa0 100644
--- a/zhaquirks/tuya/ts0601_switch.py
+++ b/zhaquirks/tuya/ts0601_switch.py
@@ -132,6 +132,7 @@ class TuyaSingleSwitch_GP(TuyaSwitch):
             ("_TZE204_6fk3gewc", "TS0601"),
             ("_TZE204_d0ypnbvn", "TS0601"),  # reported in #3220 & #3291
             ("_TZE204_ptaqh9tk", "TS0601"),  # reported in #2780
+            ("_TZE204_v5xjyphj", "TS0601"),  # reported in #4078
         ],
         ENDPOINTS: {
             # <SimpleDescriptor endpoint=1 profile=260 device_type=51 device_version=1
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..526fe14 100644
--- a/zhaquirks/tuya/tuya_sensor.py
+++ b/zhaquirks/tuya/tuya_sensor.py
@@ -76,7 +76,6 @@ class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
     .applies_to("_TZE200_qyflbnbj", "TS0601")
     .applies_to("_TZE284_qyflbnbj", "TS0601")
     .applies_to("_TZE200_44af8vyi", "TS0601")
-    .applies_to("_TZE200_vvmbj46n", "TS0601")
     # Not using tuya_temperature because device reports negative values incorrectly
     .tuya_dp(
         dp_id=1,
@@ -118,6 +117,9 @@ class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
     .applies_to("_TZE200_eanjj2pa", "TS0601")
     .applies_to("_TZE200_ydrdfkim", "TS0601")
     .applies_to("_TZE284_locansqn", "TS0601")
+    .applies_to("_TZE200_w6n8jeuu", "TS0601")
+    .applies_to("_TZE200_vvmbj46n", "TS0601")
+    .applies_to("_TZE284_vvmbj46n", "TS0601")
     .tuya_temperature(dp_id=1, scale=10)
     .tuya_humidity(dp_id=2)
     .tuya_battery(dp_id=4)
@@ -248,6 +250,7 @@ class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
         translation_key="humidity_sensitivity",
         fallback_name="Humidity sensitivity",
     )
+    .tuya_enchantment(data_query_spell=True)
     .skip_configuration()
     .add_to_registry(replacement_cluster=NoManufTimeTuyaMCUCluster)
 )
@@ -266,11 +269,24 @@ 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")
     .applies_to("_TZE200_9cqcpkgb", "TS0601")
     .applies_to("_TZE204_myd45weu", "TS0601")
+    .applies_to("_TZE284_myd45weu", "TS0601")
     .applies_to("_TZE200_2se8efxh", "TS0601")  # Immax Neo
     .tuya_temperature(dp_id=5)
     .tuya_battery(dp_id=15)
@@ -288,6 +304,7 @@ class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
     .applies_to("_TZE204_jygvp6fk", "TS0601")
     .applies_to("_TZE200_yjjdcqsq", "TS0601")
     .applies_to("_TZE204_yjjdcqsq", "TS0601")
+    .applies_to("_TZE284_yjjdcqsq", "TS0601")
     .applies_to("_TZE200_9yapgbuv", "TS0601")
     .applies_to("_TZE204_9yapgbuv", "TS0601")
     .applies_to("_TZE200_utkemkbs", "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/tuya/tuya_trv.py b/zhaquirks/tuya/tuya_trv.py
index 6df3c7c..bb2b116 100644
--- a/zhaquirks/tuya/tuya_trv.py
+++ b/zhaquirks/tuya/tuya_trv.py
@@ -658,7 +658,7 @@ class TuyaThermostatV2NoSchedule(TuyaThermostatV2):
         dp_id=3,
         ep_attribute=TuyaThermostatV2.ep_attribute,
         attribute_name=TuyaThermostatV2.AttributeDefs.running_state.name,
-        converter=lambda x: 0x01 if not x else 0x00,  # Heat, Idle
+        converter=lambda x: RunningState.Heat_State_On if x else RunningState.Idle,
     )
     .tuya_dp(
         dp_id=4,
diff --git a/zhaquirks/tuya/tuya_valve.py b/zhaquirks/tuya/tuya_valve.py
index 4aadc7b..8e0133b 100644
--- a/zhaquirks/tuya/tuya_valve.py
+++ b/zhaquirks/tuya/tuya_valve.py
@@ -14,7 +14,7 @@ import zigpy.types as t
 from zigpy.zcl.clusters.smartenergy import Metering
 
 from zhaquirks.const import BatterySize
-from zhaquirks.tuya import TUYA_CLUSTER_ID
+from zhaquirks.tuya import TUYA_CLUSTER_ID, TUYA_SEND_DATA
 from zhaquirks.tuya.builder import TuyaQuirkBuilder, TuyaValveWaterConsumed
 from zhaquirks.tuya.mcu import TuyaMCUCluster
 
@@ -669,7 +669,7 @@ class GiexIrrigationStatus(t.enum8):
     .tuya_battery(dp_id=115, battery_type=BatterySize.AA, battery_qty=2)
     .tuya_enchantment()
     .skip_configuration()
-    .add_to_registry()
+    .add_to_registry(mcu_write_command=TUYA_SEND_DATA)
 )
 
 
diff --git a/zhaquirks/xiaomi/aqara/roller_curtain_e1.py b/zhaquirks/xiaomi/aqara/roller_curtain_e1.py
index d59f9b1..e7a3b71 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,118 @@ 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,
+        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/conftest.py b/tests/conftest.py
index e0ce50b..0b792a7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,7 +8,7 @@ import zigpy.device
 from zigpy.device import Device
 import zigpy.quirks
 import zigpy.types
-from zigpy.zcl import foundation
+from zigpy.zcl import ClusterType, foundation
 from zigpy.zcl.clusters.general import Basic
 from zigpy.zdo.types import NodeDescriptor
 
@@ -163,29 +163,60 @@ def zigpy_device_from_quirk(MockAppController, ieee_mock):
 
 @pytest.fixture
 def zigpy_device_from_v2_quirk(MockAppController, ieee_mock):
-    """Create zigpy device from Quirk's signature."""
+    """Create zigpy device for v2 quirks test by manufacturer and model."""
 
     def _dev(
         manufacturer: str,
         model: str,
         endpoint_ids: list[int] = [1],
+        cluster_ids: dict[int, dict[int, ClusterType]] = {},
         ieee=None,
         nwk=zigpy.types.NWK(0x1234),
         apply_quirk=True,
-    ):
+    ) -> Device:
+        """Create zigpy device for v2 quirks test by manufacturer and model.
+
+        :param manufacturer: Manufacturer name.
+        :param model: Model name.
+        :param endpoint_ids: Endpoint ids to be added to the device, ep 1 by default.
+        :param cluster_ids: Dictionary of additional endpoints
+            and their cluster ids and types to be added to the device.
+            More advanced version of endpoint_ids argument.
+            Example: `cluster_ids={2: {OnOff.cluster_id: ClusterType.Client}}`
+        :param ieee: IEEE address of the device.
+        :param nwk: Network address of the device.
+        :param apply_quirk: Whether to apply the quirk to the device.
+        :return: Zigpy device object.
+        """
         if ieee is None:
             ieee = ieee_mock
 
+        # copy cluster_ids entries to endpoint_clusters dict
+        endpoint_clusters: dict[int, dict[int, ClusterType]] = {
+            ep_id: clusters.copy() for ep_id, clusters in cluster_ids.items()
+        }
+
+        # convert simple arg and add mandatory basic cluster to ep 1 if in endpoint_ids
+        for ep_id in endpoint_ids:
+            endpoint_clusters.setdefault(ep_id, {})
+            if ep_id == 1:
+                endpoint_clusters[ep_id][Basic.cluster_id] = ClusterType.Server
+
         raw_device = zigpy.device.Device(MockAppController, ieee, nwk)
         raw_device.manufacturer = manufacturer
         raw_device.model = model
         raw_device.node_desc = NodeDescriptor(manufacturer_code=1234)
 
-        for endpoint_id in endpoint_ids:
+        # add additional endpoints to the device
+        for endpoint_id, clusters in endpoint_clusters.items():
             ep = raw_device.add_endpoint(endpoint_id)
-            # basic is mandatory
-            if endpoint_id == 1:
-                ep.add_input_cluster(Basic.cluster_id)
+
+            # add custom cluster ids to test device
+            for cluster_id, cluster_type in clusters.items():
+                if cluster_type == ClusterType.Client:
+                    ep.add_output_cluster(cluster_id)
+                else:
+                    ep.add_input_cluster(cluster_id)
 
         quirked = zigpy.quirks.get_device(raw_device)
 
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..53aa3e2 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -17,8 +17,8 @@ import zhaquirks
 from zhaquirks.const import BatterySize
 from zhaquirks.tuya import (
     TUYA_QUERY_DATA,
+    TUYA_SEND_DATA,
     TUYA_SET_TIME,
-    DPToAttributeMapping,
     TuyaCommand,
     TuyaData,
     TuyaDatapointData,
@@ -42,7 +42,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"
@@ -462,7 +462,7 @@ async def test_tuya_mcu_set_time(device_mock):
     ],
 )
 async def test_tuya_quirkbuilder_force(device_mock, force):
-    """Test adding an empty TuyaQuirkBuilder doesn't add a MCU cluster unless forced to."""
+    """Test adding an empty TuyaQuirkBuilder doesn't add an MCU cluster unless forced to."""
 
     registry = DeviceRegistry()
 
@@ -483,3 +483,86 @@ async def test_tuya_quirkbuilder_force(device_mock, force):
         assert isinstance(ep.tuya_manufacturer, TuyaMCUCluster)
     else:
         assert not hasattr(ep, "tuya_manufacturer")
+
+
+@pytest.mark.parametrize(
+    "kwargs,expected_cmd,expected_data",
+    [
+        (
+            {},  # no kwarg should use default of TUYA_SET_DATA
+            0,
+            b"\x01\x01\x00\x00\x01\n\x04\x00\x01\x01",
+        ),
+        (
+            {"mcu_write_command": TUYA_SEND_DATA},
+            4,
+            b"\x01\x01\x04\x00\x01\n\x04\x00\x01\x01",
+        ),
+    ],
+)
+async def test_tuya_override_mcu_command(
+    device_mock, kwargs, expected_cmd, expected_data
+):
+    """Test TuyaQuirkBuilder overriding MCU datapoint write command."""
+
+    registry = DeviceRegistry()
+
+    class TestEnum(t.enum8):
+        """Test Enum."""
+
+        A = 0x00
+        B = 0x01
+
+    (
+        TuyaQuirkBuilder(device_mock.manufacturer, device_mock.model, registry=registry)
+        .tuya_enum(
+            dp_id=10,
+            attribute_name="test_enum",
+            enum_class=TestEnum,
+            translation_key="test_enum",
+            fallback_name="Test enum",
+        )
+        .skip_configuration()
+        .add_to_registry(**kwargs)
+    )
+
+    quirked = registry.get_device(device_mock)
+    assert isinstance(quirked, CustomDeviceV2)
+    assert quirked in registry
+
+    ep = quirked.endpoints[1]
+
+    assert ep.tuya_manufacturer is not None
+    assert isinstance(ep.tuya_manufacturer, TuyaMCUCluster)
+
+    tuya_cluster = ep.tuya_manufacturer
+    tuya_listener = ClusterListener(tuya_cluster)
+    assert tuya_cluster.attributes_by_name["test_enum"].id == 0xEF0A
+
+    with mock.patch.object(
+        tuya_cluster.endpoint, "request", return_value=foundation.Status.SUCCESS
+    ) as m1:
+        (status,) = await tuya_cluster.write_attributes(
+            {
+                "test_enum": 0x01,
+            },
+        )
+
+        await wait_for_zigpy_tasks()
+        m1.assert_called_with(
+            cluster=61184,
+            sequence=1,
+            data=expected_data,
+            command_id=expected_cmd,
+            timeout=5,
+            expect_reply=False,
+            use_ieee=False,
+            ask_for_ack=None,
+            priority=t.PacketPriority.NORMAL,
+        )
+        assert status == [
+            foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)
+        ]
+
+    assert tuya_listener.attribute_updates[0][0] == 0xEF0A
+    assert tuya_listener.attribute_updates[0][1] == TestEnum.B
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_tuya_sensor.py b/tests/test_tuya_sensor.py
index 606d782..2bbd613 100644
--- a/tests/test_tuya_sensor.py
+++ b/tests/test_tuya_sensor.py
@@ -44,7 +44,7 @@ zhaquirks.setup()
         ("_TZE200_eanjj2pa", "TS0601", 100, 10, False),
         ("_TZE200_ydrdfkim", "TS0601", 100, 10, False),
         ("_TZE284_locansqn", "TS0601", 100, 10, False),
-        ("_TZE200_vvmbj46n", "TS0601", 100, 10, True),
+        ("_TZE200_vvmbj46n", "TS0601", 100, 10, False),
     ],
 )
 async def test_handle_get_data(
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