Repository: zigpy/zha-device-handlers
Pull request #3612 (2024-12-14 17:42:01)

Convert Tuya motion quirks to v2, add `_TZE200_ya4ft0w4`
## Proposed change
<!--
  Explain your proposed change below.
-->

Adds _TZE200_ya4ft0w4, converts rest of motion to v2 quirks

## Additional information
<!--
  Please include any additional information that is important to this PR.
  For example, if this PR is a potentially breaking change, mention that here.
  If this PR requires other PRs to be merged in HA Core or other projects, mention that.
  Lastly, if this PR fixes a specific issue, please include "Fixes #xxxx".
-->
Closes: https://github.com/zigpy/zha-device-handlers/issues/3584
Closes: https://github.com/zigpy/zha-device-handlers/issues/3359

## Checklist
<!--
  Put an 'x' in all boxes that apply.
  Note: You do not need to tick all boxes before creating a PR.
-->

- [ ] The changes are tested and work correctly
- [x] `pre-commit` checks pass / the code has been formatted using Black
- [x] Tests have been added to verify that the new code works


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3612?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 89.80%. Comparing base [(`979551d`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/979551d67135cf4ad68c4dbc447df76d5426b78c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`7bd92c7`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/7bd92c7f0ea86d3a127a828702ff0150e0b14294?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

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


```diff
@@            Coverage Diff             @@
##              dev    #3612      +/-   ##
==========================================
- Coverage   89.85%   89.80%   -0.06%     
==========================================
  Files         321      321              
  Lines       10374    10318      -56     
==========================================
- Hits         9322     9266      -56     
  Misses       1052     1052              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3612?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).


Patch:
diff --git a/zhaquirks/philips/__init__.py b/zhaquirks/philips/__init__.py
index e5d0231..6b8b2df 100644
--- a/zhaquirks/philips/__init__.py
+++ b/zhaquirks/philips/__init__.py
@@ -82,7 +82,6 @@ class ButtonPressQueue:
         self._ms_threshold = 300
         self._ms_last_click = 0
         self._click_counter = 1
-        self._button = None
         self._callback = lambda x: None
         self._task = None
 
@@ -90,19 +89,11 @@ class ButtonPressQueue:
         await asyncio.sleep(self._ms_threshold / 1000)
         self._callback(self._click_counter)
 
-    def _reset(self, button):
-        if self._task:
-            self._task.cancel()
-        self._click_counter = 1
-        self._button = button
-
-    def press(self, callback, button):
+    def press(self, callback):
         """Process a button press."""
         self._callback = callback
         now_ms = time.time() * 1000
-        if self._button != button:
-            self._reset(button)
-        elif now_ms - self._ms_last_click > self._ms_threshold:
+        if now_ms - self._ms_last_click > self._ms_threshold:
             self._click_counter = 1
         else:
             self._task.cancel()
@@ -188,7 +179,10 @@ class PhilipsRemoteCluster(CustomCluster):
         PressType(SHORT_RELEASE, COMMAND_M_SHORT_RELEASE),
     ]
 
-    button_press_queue = ButtonPressQueue()
+    def __init__(self, endpoint, is_server=True):
+        """Initialize button press queue for each button."""
+        super().__init__(endpoint, is_server)
+        self.button_press_queue = {k: ButtonPressQueue() for k in self.BUTTONS}
 
     def handle_cluster_request(
         self,
@@ -209,15 +203,20 @@ class PhilipsRemoteCluster(CustomCluster):
         )
 
         button = self.BUTTONS.get(args[0])
+        # Bail on unknown buttons. (This gets rid of dial button "presses")
+        if button is None:
+            _LOGGER.debug(
+                "%s - handle_cluster_request unknown button id [%s]",
+                self.__class__.__name__,
+                args[0],
+            )
+            return
         _LOGGER.debug(
             "%s - handle_cluster_request button id: [%s], button name: [%s]",
             self.__class__.__name__,
             args[0],
             button,
         )
-        # Bail on unknown buttons. (This gets rid of dial button "presses")
-        if button is None:
-            return
 
         press_type = self.PRESS_TYPES.get(args[2])
         if (
@@ -227,6 +226,11 @@ class PhilipsRemoteCluster(CustomCluster):
         ):
             press_type = self.SIMULATE_SHORT_EVENTS[1]
         if press_type is None:
+            _LOGGER.debug(
+                "%s - handle_cluster_request unknown button press type: [%s]",
+                self.__class__.__name__,
+                press_type,
+            )
             return
 
         duration = args[4]
@@ -288,15 +292,21 @@ class PhilipsRemoteCluster(CustomCluster):
                 sim_event_args[ARGS][2] = 2
                 action = f"{button.action}_{press_type.action}"
                 _LOGGER.debug(
-                    "%s - send_press_event emitting simulated action: [%s]",
+                    "%s - send_press_event emitting simulated action: [%s], event_args: %s",
                     self.__class__.__name__,
                     action,
+                    sim_event_args,
                 )
                 self.listener_event(ZHA_SEND_EVENT, action, sim_event_args)
 
         # Derive Multiple Presses
         if press_type.name == SHORT_RELEASE:
-            self.button_press_queue.press(send_press_event, button.id)
+            _LOGGER.debug(
+                "%s - handle_cluster_request handling short release. Push to button press queue for button %s",
+                self.__class__.__name__,
+                args[0],
+            )
+            self.button_press_queue[args[0]].press(send_press_event)
         else:
             action = f"{button.action}_{press_type.action}"
             self.listener_event(ZHA_SEND_EVENT, action, event_args)
diff --git a/zhaquirks/philips/wall_switch.py b/zhaquirks/philips/wall_switch.py
index 576d61a..e779e1e 100644
--- a/zhaquirks/philips/wall_switch.py
+++ b/zhaquirks/philips/wall_switch.py
@@ -41,6 +41,15 @@ from zhaquirks.philips import (
 )
 
 
+class SwitchMode(t.enum8):
+    """Wall switch modes. See https://zigbee.blakadder.com/Philips_RDM001.html."""
+
+    SingleRocker = 0x00
+    SinglePush = 0x01
+    DoubleRocker = 0x02
+    DoublePush = 0x03
+
+
 class PhilipsWallSwitchBasicCluster(PhilipsBasicCluster):
     """Philips wall switch Basic cluster."""
 
@@ -49,11 +58,14 @@ class PhilipsWallSwitchBasicCluster(PhilipsBasicCluster):
 
         mode: Final = ZCLAttributeDef(
             id=0x0034,
-            type=t.enum8,
+            type=SwitchMode,
             is_manufacturer_specific=True,
         )
 
-    attr_config = {**PhilipsBasicCluster.attr_config, AttributeDefs.mode.id: 0x02}
+    attr_config = {
+        **PhilipsBasicCluster.attr_config,
+        AttributeDefs.mode.id: SwitchMode.DoublePush,
+    }
 
 
 class PhilipsWallSwitchRemoteCluster(PhilipsRemoteCluster):
diff --git a/zhaquirks/schneiderelectric/thermostat.py b/zhaquirks/schneiderelectric/thermostat.py
index 537c9e8..1e16cf6 100644
--- a/zhaquirks/schneiderelectric/thermostat.py
+++ b/zhaquirks/schneiderelectric/thermostat.py
@@ -3,7 +3,14 @@
 from typing import Final
 
 from zigpy.quirks import CustomCluster
-from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2 import EntityType, QuirkBuilder
+from zigpy.quirks.v2.homeassistant import (
+    EntityPlatform,
+    UnitOfPower,
+    UnitOfTemperature,
+    UnitOfTime,
+)
+from zigpy.quirks.v2.homeassistant.number import NumberDeviceClass
 import zigpy.types as t
 from zigpy.zcl.clusters.hvac import SystemMode, Thermostat, UserInterface
 from zigpy.zcl.clusters.measurement import TemperatureMeasurement
@@ -285,7 +292,7 @@ class SEThermostat(CustomCluster, Thermostat):
             access="r",
             is_manufacturer_specific=True,
         )
-        se_local_temperature_souce_select: Final = ZCLAttributeDef(
+        se_local_temperature_source_select: Final = ZCLAttributeDef(
             id=0xE212,
             type=SELocalTemperatureSourceSelect,
             access="rw",
@@ -328,7 +335,7 @@ class SEThermostat(CustomCluster, Thermostat):
             is_manufacturer_specific=True,
         )
         se_heating_emitter_type: Final = ZCLAttributeDef(
-            id=0xE218,
+            id=0xE21A,
             type=SEHeatingEmitterType,
             access="rw",
             is_manufacturer_specific=True,
@@ -572,5 +579,213 @@ class SEHeatingCoolingOutput(CustomCluster):
     .replaces(SEMetering, endpoint_id=5)
     .replaces(SECycleTime)
     .replaces(SEHeatingCoolingOutput)
+    .number(
+        cluster_id=SEMetering.cluster_id,
+        endpoint_id=5,
+        attribute_name=SEMetering.AttributeDefs.se_fixed_load_demand.name,
+        translation_key="fixed_load_demand",
+        fallback_name="Fixed load demand",
+        device_class=NumberDeviceClass.POWER,
+        unit=UnitOfPower.WATT,
+        min_value=0,
+        max_value=10000,
+        step=1,
+    )
+    .number(
+        cluster_id=SEUserInterface.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEUserInterface.AttributeDefs.se_brightness.name,
+        translation_key="display_brightness",
+        fallback_name="Display brightness",
+        # unit="%",
+        min_value=0,
+        max_value=100,
+        step=1,
+    )
+    .number(
+        cluster_id=SEUserInterface.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEUserInterface.AttributeDefs.se_inactive_brightness.name,
+        translation_key="display_inactive_brightness",
+        fallback_name="Display inactive brightness",
+        # unit="%",
+        min_value=0,
+        max_value=100,
+        step=1,
+    )
+    .number(
+        cluster_id=SEUserInterface.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEUserInterface.AttributeDefs.se_activity_timeout.name,
+        translation_key="display_activity_timeout",
+        fallback_name="Display activity timeout",
+        device_class=NumberDeviceClass.DURATION,
+        unit=UnitOfTime.SECONDS,
+        min_value=0,
+        max_value=3600,
+        step=1,
+    )
+    .binary_sensor(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_open_window_detection_status.name,
+        translation_key="open_window_detection_status",
+        fallback_name="Open window detection status",
+        entity_type=EntityType.DIAGNOSTIC,
+    )
+    .number(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_open_window_detection_threshold.name,
+        translation_key="open_window_detection_threshold",
+        fallback_name="Open window detection threshold",
+        device_class=NumberDeviceClass.TEMPERATURE,
+        unit=UnitOfTemperature.CELSIUS,
+        min_value=0,
+        max_value=12,
+        multiplier=0.1,
+        step=0.1,
+    )
+    .number(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_open_window_event_duration.name,
+        translation_key="open_window_event_duration",
+        fallback_name="Open window event duration",
+        device_class=NumberDeviceClass.DURATION,
+        unit=UnitOfTime.SECONDS,
+        min_value=0,
+        max_value=7620,
+        step=1,
+    )
+    .number(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_open_window_detection_guard_period.name,
+        translation_key="open_window_detection_guard_period",
+        fallback_name="Open window detection guard period",
+        device_class=NumberDeviceClass.DURATION,
+        unit=UnitOfTime.SECONDS,
+        min_value=0,
+        max_value=7620,
+        step=1,
+    )
+    .number(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_fallback_timeout.name,
+        translation_key="fallback_timeout",
+        fallback_name="Fallback timeout",
+        device_class=NumberDeviceClass.DURATION,
+        unit=UnitOfTime.SECONDS,
+        min_value=30,
+        max_value=10800,
+        step=1,
+    )
+    .number(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_boost_amount.name,
+        translation_key="boost_amount",
+        fallback_name="Boost amount",
+        device_class=NumberDeviceClass.TEMPERATURE,
+        unit=UnitOfTemperature.CELSIUS,
+        min_value=0,
+        max_value=10,
+        multiplier=0.01,
+        step=0.5,
+    )
+    .enum(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_control_status.name,
+        translation_key="control_status",
+        fallback_name="Control status",
+        enum_class=SEControlStatus,
+        entity_platform=EntityPlatform.SENSOR,
+        entity_type=EntityType.DIAGNOSTIC,
+    )
+    .enum(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_local_temperature_source_select.name,
+        translation_key="local_temperature_source",
+        fallback_name="Local temperature source",
+        enum_class=SELocalTemperatureSourceSelect,
+    )
+    .enum(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_control_type.name,
+        translation_key="control_type",
+        fallback_name="Control type",
+        enum_class=SEControlType,
+    )
+    .enum(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_thermostat_application.name,
+        translation_key="thermostat_application",
+        fallback_name="Thermostat application",
+        enum_class=SEThermostatApplication,
+    )
+    .enum(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_heating_fuel.name,
+        translation_key="heating_fuel",
+        fallback_name="Heating fuel",
+        enum_class=SEHeatingFuel,
+    )
+    .enum(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_heat_transfer_medium.name,
+        translation_key="heat_transfer_medium",
+        fallback_name="Heat transfer medium",
+        enum_class=SEHeatTransferMedium,
+    )
+    .enum(
+        cluster_id=SEThermostat.cluster_id,
+        endpoint_id=1,
+        attribute_name=SEThermostat.AttributeDefs.se_heating_emitter_type.name,
+        translation_key="heating_emitter_type",
+        fallback_name="Heating emitter type",
+        enum_class=SEHeatingEmitterType,
+    )
+    .number(
+        cluster_id=SETemperatureMeasurement.cluster_id,
+        endpoint_id=2,
+        attribute_name=SETemperatureMeasurement.AttributeDefs.se_sensor_correction.name,
+        translation_key="ambient_sensor_correction",
+        fallback_name="Ambient sensor correction",
+        device_class=NumberDeviceClass.TEMPERATURE,
+        unit=UnitOfTemperature.CELSIUS,
+        min_value=-10,
+        max_value=10,
+        step=0.1,
+        multiplier=0.01,
+    )
+    .number(
+        cluster_id=SETemperatureMeasurementExternal.cluster_id,
+        endpoint_id=3,
+        attribute_name=SETemperatureMeasurementExternal.AttributeDefs.se_sensor_correction.name,
+        translation_key="external_sensor_correction",
+        fallback_name="External sensor correction",
+        device_class=NumberDeviceClass.TEMPERATURE,
+        unit=UnitOfTemperature.CELSIUS,
+        min_value=-10,
+        max_value=10,
+        step=0.1,
+        multiplier=0.01,
+    )
+    .enum(
+        cluster_id=SETemperatureMeasurementExternal.cluster_id,
+        endpoint_id=3,
+        attribute_name=SETemperatureMeasurementExternal.AttributeDefs.se_temperature_sensor_type.name,
+        translation_key="external_temperature_sensor_type",
+        fallback_name="External temperature sensor type",
+        enum_class=SETemperatureSensorType,
+    )
     .add_to_registry()
 )
diff --git a/zhaquirks/tuya/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index 1078d80..1c45989 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -145,12 +145,14 @@ class TuyaQuirkBuilder(QuirkBuilder):
         self,
         dp_id: int,
         metering_cfg: TuyaLocalCluster = TuyaValveWaterConsumed,
+        scale: float = 1,
     ) -> QuirkBuilder:
         """Add a Tuya Metering Configuration."""
         self.tuya_dp(
             dp_id,
             metering_cfg.ep_attribute,
-            "current_summ_delivered",
+            attribute_name="current_summ_delivered",
+            converter=lambda x: x * scale,
         )
         self.adds(metering_cfg)
         return self
diff --git a/zhaquirks/tuya/ts0601_dimmer.py b/zhaquirks/tuya/ts0601_dimmer.py
index 78012d1..4723a22 100644
--- a/zhaquirks/tuya/ts0601_dimmer.py
+++ b/zhaquirks/tuya/ts0601_dimmer.py
@@ -155,6 +155,7 @@ class TuyaSingleSwitchDimmerGP(TuyaDimmerSwitch):
             ("_TZE204_n9ctkb6j", "TS0601"),  # BSEED
             ("_TZE204_vevc4c6g", "TS0601"),  # BSEED
             ("_TZE204_5cuocqty", "TS0601"),  # Avattto ZDMS16-1
+            ("_TZE204_nqqylykc", "TS0601"),  # Avattto ZDMS16-1
         ],
         ENDPOINTS: {
             # <SimpleDescriptor endpoint=1 profile=260 device_type=0x0100
diff --git a/zhaquirks/tuya/ts0601_motion.py b/zhaquirks/tuya/ts0601_motion.py
index 443c8fd..91b14d9 100644
--- a/zhaquirks/tuya/ts0601_motion.py
+++ b/zhaquirks/tuya/ts0601_motion.py
@@ -1,473 +1,181 @@
 """BlitzWolf IS-3/Tuya motion rechargeable occupancy sensor."""
 
 import math
-from typing import Optional, Union
 
-from zigpy.profiles import zgp, zha
-from zigpy.quirks import CustomDevice
+from zigpy.quirks.v2 import EntityType
+from zigpy.quirks.v2.homeassistant import UnitOfLength, UnitOfTime
+from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass
 import zigpy.types as t
-from zigpy.zcl import foundation
-from zigpy.zcl.clusters.general import (
-    AnalogInput,
-    Basic,
-    GreenPowerProxy,
-    Groups,
-    Identify,
-    Ota,
-    Scenes,
-    Time,
-)
-from zigpy.zcl.clusters.measurement import (
-    IlluminanceMeasurement,
-    OccupancySensing,
-    RelativeHumidity,
-    TemperatureMeasurement,
-)
-from zigpy.zcl.clusters.security import IasZone
+from zigpy.zcl.clusters.measurement import IlluminanceMeasurement, OccupancySensing
 
-from zhaquirks import Bus, LocalDataCluster, MotionOnEvent
-from zhaquirks.const import (
-    DEVICE_TYPE,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
-    MODELS_INFO,
-    MOTION_EVENT,
-    OUTPUT_CLUSTERS,
-    PROFILE_ID,
-)
-from zhaquirks.tuya import (
-    DPToAttributeMapping,
-    TuyaLocalCluster,
-    TuyaManufCluster,
-    TuyaNewManufCluster,
-)
-from zhaquirks.tuya.mcu import TuyaMCUCluster
+from zhaquirks.tuya import TuyaLocalCluster
+from zhaquirks.tuya.builder import TuyaQuirkBuilder
 
-ZONE_TYPE = 0x0001
+
+class TuyaIlluminanceCluster(IlluminanceMeasurement, TuyaLocalCluster):
+    """Tuya Illuminance cluster."""
 
 
 class TuyaOccupancySensing(OccupancySensing, TuyaLocalCluster):
     """Tuya local OccupancySensing cluster."""
 
 
-class TuyaAnalogInput(AnalogInput, TuyaLocalCluster):
-    """Tuya local AnalogInput cluster."""
-
-
-class TuyaIlluminanceMeasurement(IlluminanceMeasurement, TuyaLocalCluster):
-    """Tuya local IlluminanceMeasurement cluster."""
-
-
-class TuyaTemperatureMeasurement(TemperatureMeasurement, TuyaLocalCluster):
-    """Tuya local TemperatureMeasurement cluster."""
-
-
-class TuyaRelativeHumidity(RelativeHumidity, TuyaLocalCluster):
-    """Tuya local RelativeHumidity cluster."""
-
-
-class NeoBatteryLevel(t.enum8):
-    """NEO battery level enum."""
-
-    BATTERY_FULL = 0x00
-    BATTERY_HIGH = 0x01
-    BATTERY_MEDIUM = 0x02
-    BATTERY_LOW = 0x03
-    USB_POWER = 0x04
-
-
-class NeoMotionManufCluster(TuyaNewManufCluster):
-    """Neo manufacturer cluster."""
-
-    class AttributeDefs(TuyaNewManufCluster.AttributeDefs):
-        """Attribute Definitions."""
-
-        dp_113 = foundation.ZCLAttributeDef(
-            id=0xEF0D,
-            type=t.enum8,
-            is_manufacturer_specific=True,
-        )  # random attribute ID
-
-    dp_to_attribute: dict[int, DPToAttributeMapping] = {
-        101: DPToAttributeMapping(
-            TuyaOccupancySensing.ep_attribute,
-            "occupancy",
-        ),
-        104: DPToAttributeMapping(
-            TuyaTemperatureMeasurement.ep_attribute,
-            "measured_value",
-            lambda x: x * 10,
-        ),
-        105: DPToAttributeMapping(
-            TuyaRelativeHumidity.ep_attribute,
-            "measured_value",
-            lambda x: x * 100,
-        ),
-        113: DPToAttributeMapping(
-            TuyaNewManufCluster.ep_attribute,
-            "dp_113",
-        ),
-    }
-
-    data_point_handlers = {
-        101: "_dp_2_attr_update",
-        104: "_dp_2_attr_update",
-        105: "_dp_2_attr_update",
-        113: "_dp_2_attr_update",
-    }
-
-
-class MmwRadarManufCluster(TuyaMCUCluster):
-    """Neo manufacturer cluster."""
-
-    # # Possible DPs and values
-    # presence_state: presence
-    # target distance: 1.61m
-    # illuminance: 250lux
-    # sensitivity: 9
-    # minimum_detection_distance: 0.00m
-    # maximum_detection_distance: 4.05m
-    # dp_detection_delay: 0.1
-    # dp_fading_time: 5.0
-    # ¿illuminance?: 255lux
-    # presence_brightness: no control
-    # no_one_brightness: no control
-    # current_brightness: off
-
-    class AttributeDefs(TuyaMCUCluster.AttributeDefs):
-        """Attribute Definitions."""
-
-        dp_2 = foundation.ZCLAttributeDef(
-            id=0xEF02, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        dp_3 = foundation.ZCLAttributeDef(
-            id=0xEF03, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        dp_4 = foundation.ZCLAttributeDef(
-            id=0xEF04, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        dp_6 = foundation.ZCLAttributeDef(
-            id=0xEF06, type=t.enum8, is_manufacturer_specific=True
-        )
-        dp_101 = foundation.ZCLAttributeDef(
-            id=0xEF65, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        dp_102 = foundation.ZCLAttributeDef(
-            id=0xEF66, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        dp_103 = foundation.ZCLAttributeDef(
-            id=0xEF67, type=t.CharacterString, is_manufacturer_specific=True
-        )
-        dp_105 = foundation.ZCLAttributeDef(
-            id=0xEF69, type=t.enum8, is_manufacturer_specific=True
-        )
-        dp_106 = foundation.ZCLAttributeDef(
-            id=0xEF6A, type=t.enum8, is_manufacturer_specific=True
-        )
-        dp_107 = foundation.ZCLAttributeDef(
-            id=0xEF6B, type=t.enum8, is_manufacturer_specific=True
-        )
-        dp_108 = foundation.ZCLAttributeDef(
-            id=0xEF6C, type=t.uint32_t, is_manufacturer_specific=True
-        )
-
-    dp_to_attribute: dict[int, DPToAttributeMapping] = {
-        1: DPToAttributeMapping(
-            TuyaOccupancySensing.ep_attribute,
-            "occupancy",
-        ),
-        2: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_2",
-        ),
-        3: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_3",
-        ),
-        4: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_4",
-        ),
-        6: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_6",
-        ),
-        9: DPToAttributeMapping(
-            TuyaAnalogInput.ep_attribute,
-            "present_value",
-            lambda x: x / 100,
-        ),
-        101: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_101",
-        ),
-        102: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_102",
-        ),
-        103: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_103",
-        ),
-        104: DPToAttributeMapping(
-            TuyaIlluminanceMeasurement.ep_attribute,
-            "measured_value",
-            lambda x: 10000 * math.log10(x) + 1 if x != 0 else 0,
-        ),
-        105: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_105",
-        ),
-        106: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_106",
-        ),
-        107: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_107",
-        ),
-        108: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "dp_108",
-        ),
-    }
-
-    data_point_handlers = {
-        1: "_dp_2_attr_update",
-        2: "_dp_2_attr_update",
-        3: "_dp_2_attr_update",
-        4: "_dp_2_attr_update",
-        6: "_dp_2_attr_update",
-        9: "_dp_2_attr_update",
-        101: "_dp_2_attr_update",
-        102: "_dp_2_attr_update",
-        103: "_dp_2_attr_update",
-        104: "_dp_2_attr_update",
-        105: "_dp_2_attr_update",
-        106: "_dp_2_attr_update",
-        107: "_dp_2_attr_update",
-        108: "_dp_2_attr_update",
-    }
-
-
-class MotionCluster(LocalDataCluster, MotionOnEvent):
-    """Tuya Motion Sensor."""
-
-    _CONSTANT_ATTRIBUTES = {ZONE_TYPE: IasZone.ZoneType.Motion_Sensor}
-    reset_s = 15
-
-
-class TuyaManufacturerClusterMotion(TuyaManufCluster):
-    """Manufacturer Specific Cluster of the Motion device."""
-
-    def handle_cluster_request(
-        self,
-        hdr: foundation.ZCLHeader,
-        args: tuple[TuyaManufCluster.Command],
-        *,
-        dst_addressing: Optional[
-            Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK]
-        ] = None,
-    ) -> None:
-        """Handle cluster request."""
-        tuya_cmd = args[0]
-        self.debug("handle_cluster_request--> hdr: %s, args: %s", hdr, args)
-        if hdr.command_id == 0x0001 and tuya_cmd.command_id == 1027:
-            self.endpoint.device.motion_bus.listener_event(MOTION_EVENT)
-
-
-class TuyaMotion(CustomDevice):
-    """BW-IS3 occupancy sensor."""
-
-    def __init__(self, *args, **kwargs):
-        """Init device."""
-        self.motion_bus = Bus()
-        super().__init__(*args, **kwargs)
-
-    signature = {
-        #  endpoint=1 profile=260 device_type=0 device_version=0 input_clusters=[0, 3]
-        #  output_clusters=[3, 25]>
-        MODELS_INFO: [("_TYST11_i5j6ifxj", "5j6ifxj"), ("_TYST11_7hfcudw5", "hfcudw5")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
-                INPUT_CLUSTERS: [Basic.cluster_id, Identify.cluster_id],
-                OUTPUT_CLUSTERS: [Identify.cluster_id, Ota.cluster_id],
-            }
-        },
-    }
-
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Identify.cluster_id,
-                    MotionCluster,
-                    TuyaManufacturerClusterMotion,
-                ],
-                OUTPUT_CLUSTERS: [Identify.cluster_id, Ota.cluster_id],
-            }
-        }
-    }
-
-
-class NeoMotion(CustomDevice):
-    """NAS-PD07 occupancy sensor."""
-
-    signature = {
-        #  endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184]
-        #  output_clusters=[10, 25]>
-        MODELS_INFO: [
-            ("_TZE200_7hfcudw5", "TS0601"),
-            ("_TZE200_ppuj1vem", "TS0601"),
-        ],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    NeoMotionManufCluster.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            }
-        },
-    }
-
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    NeoMotionManufCluster,
-                    TuyaOccupancySensing,
-                    TuyaTemperatureMeasurement,
-                    TuyaRelativeHumidity,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            }
-        }
-    }
-
-
-class MmwRadarMotion(CustomDevice):
-    """Millimeter wave occupancy sensor."""
-
-    signature = {
-        #  endpoint=1, profile=260, device_type=81, device_version=1,
-        #  input_clusters=[0, 4, 5, 61184], output_clusters=[25, 10]
-        MODELS_INFO: [
-            ("_TZE200_ar0slwnd", "TS0601"),
-            ("_TZE200_sfiy5tfs", "TS0601"),
-            ("_TZE200_mrf6vtua", "TS0601"),
-            ("_TZE200_ztc6ggyl", "TS0601"),
-            ("_TZE204_ztc6ggyl", "TS0601"),
-            ("_TZE200_wukb7rhc", "TS0601"),
-        ],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    TuyaNewManufCluster.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            },
-        },
-    }
+base_tuya_motion = (
+    TuyaQuirkBuilder()
+    .adds(TuyaOccupancySensing)
+    .tuya_number(
+        dp_id=2,
+        attribute_name="move_sensitivity",
+        type=t.uint16_t,
+        min_value=0,
+        max_value=10,
+        step=1,
+        translation_key="move_sensitivity",
+        fallback_name="Motion sensitivity",
+    )
+    .tuya_number(
+        dp_id=3,
+        attribute_name="detection_distance_min",
+        type=t.uint16_t,
+        device_class=SensorDeviceClass.DISTANCE,
+        unit=UnitOfLength.METERS,
+        min_value=0,
+        max_value=8.25,
+        step=0.75,
+        translation_key="detection_distance_min",
+        fallback_name="Minimum range",
+    )
+    .tuya_number(
+        dp_id=4,
+        attribute_name="detection_distance_max",
+        type=t.uint16_t,
+        device_class=SensorDeviceClass.DISTANCE,
+        unit=UnitOfLength.METERS,
+        min_value=0.75,
+        max_value=9.0,
+        step=0.75,
+        translation_key="detection_distance_max",
+        fallback_name="Maximum range",
+    )
+    .tuya_sensor(
+        dp_id=9,
+        attribute_name="distance",
+        type=t.uint16_t,
+        divisor=10,
+        state_class=SensorStateClass.MEASUREMENT,
+        device_class=SensorDeviceClass.DISTANCE,
+        unit=UnitOfLength.METERS,
+        entity_type=EntityType.STANDARD,
+        translation_key="distance",
+        fallback_name="Target distance",
+    )
+    .tuya_switch(
+        dp_id=101,
+        attribute_name="find_switch",
+        entity_type=EntityType.STANDARD,
+        translation_key="find_switch",
+        fallback_name="Distance switch",
+    )
+    .tuya_number(
+        dp_id=102,
+        attribute_name="presence_sensitivity",
+        type=t.uint16_t,
+        min_value=0,
+        max_value=10,
+        step=1,
+        translation_key="presence_sensitivity",
+        fallback_name="Presence sensitivity",
+    )
+    .adds(TuyaIlluminanceCluster)
+    .skip_configuration()
+)
 
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    MmwRadarManufCluster,
-                    TuyaOccupancySensing,
-                    TuyaAnalogInput,
-                    TuyaIlluminanceMeasurement,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            },
-        }
-    }
+(
+    base_tuya_motion.clone()
+    .applies_to("_TZE200_ya4ft0w4", "TS0601")
+    .tuya_dp(
+        dp_id=1,
+        ep_attribute=TuyaOccupancySensing.ep_attribute,
+        attribute_name=OccupancySensing.AttributeDefs.occupancy.name,
+        converter=lambda x: True if x in (1, 2) else False,
+    )
+    .tuya_dp(
+        dp_id=103,
+        ep_attribute=TuyaIlluminanceCluster.ep_attribute,
+        attribute_name=TuyaIlluminanceCluster.AttributeDefs.measured_value.name,
+        converter=lambda x: 10000 * math.log10(x) + 1 if x != 0 else 0,
+    )
+    .tuya_number(
+        dp_id=105,
+        attribute_name="presence_timeout",
+        type=t.uint16_t,
+        device_class=SensorDeviceClass.DURATION,
+        unit=UnitOfTime.SECONDS,
+        min_value=1,
+        max_value=15000,
+        step=1,
+        translation_key="presence_timeout",
+        fallback_name="Fade time",
+    )
+    .add_to_registry()
+)
 
+# Neo motion, NAS-PD07 occupancy sensor
+(
+    TuyaQuirkBuilder("_TZE200_7hfcudw5", "TS0601")
+    .applies_to("_TZE200_ppuj1vem", "TS0601")
+    .tuya_dp(
+        dp_id=101,
+        ep_attribute=TuyaOccupancySensing.ep_attribute,
+        attribute_name=OccupancySensing.AttributeDefs.occupancy.name,
+        converter=lambda x: x == 1,
+    )
+    .adds(TuyaOccupancySensing)
+    .tuya_temperature(dp_id=104, scale=10)
+    .tuya_humidity(dp_id=105)
+    .skip_configuration()
+    .add_to_registry()
+)
 
-class MmwRadarMotionGPP(CustomDevice):
-    """Millimeter wave occupancy sensor."""
+(
+    base_tuya_motion.clone()
+    .applies_to("_TZE200_ar0slwnd", "TS0601")
+    .applies_to("_TZE200_mrf6vtua", "TS0601")
+    .applies_to("_TZE200_sfiy5tfs", "TS0601")
+    .applies_to("_TZE204_sooucan5", "TS0601")
+    .applies_to("_TZE200_wukb7rhc", "TS0601")
+    .applies_to("_TZE204_qasjif9e", "TS0601")
+    .applies_to("_TZE200_ztc6ggyl", "TS0601")
+    .applies_to("_TZE204_ztc6ggyl", "TS0601")
+    .applies_to("_TZE204_ztqnh5cg", "TS0601")
+    .tuya_dp(
+        dp_id=1,
+        ep_attribute=TuyaOccupancySensing.ep_attribute,
+        attribute_name=OccupancySensing.AttributeDefs.occupancy.name,
+        converter=lambda x: x == 1,
+    )
+    # 103?
+    .tuya_dp(
+        dp_id=104,
+        ep_attribute=TuyaIlluminanceCluster.ep_attribute,
+        attribute_name=TuyaIlluminanceCluster.AttributeDefs.measured_value.name,
+        converter=lambda x: 10000 * math.log10(x) + 1 if x != 0 else 0,
+    )
+    # 106?
+    .add_to_registry()
+)
 
-    signature = {
-        #  endpoint=1, profile=260, device_type=81, device_version=1,
-        #  input_clusters=[4, 5, 61184, 0], output_clusters=[25, 10])
-        MODELS_INFO: [
-            ("_TZE200_ar0slwnd", "TS0601"),
-            ("_TZE200_sfiy5tfs", "TS0601"),
-            ("_TZE200_mrf6vtua", "TS0601"),
-            ("_TZE204_qasjif9e", "TS0601"),
-            ("_TZE204_ztqnh5cg", "TS0601"),
-            ("_TZE204_sooucan5", "TS0601"),
-        ],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    TuyaNewManufCluster.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            },
-            242: {
-                # <SimpleDescriptor endpoint=242 profile=41440 device_type=97
-                # input_clusters=[]
-                # output_clusters=[33]
-                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.OCCUPANCY_SENSOR,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    MmwRadarManufCluster,
-                    TuyaOccupancySensing,
-                    TuyaAnalogInput,
-                    TuyaIlluminanceMeasurement,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            },
-            242: {
-                PROFILE_ID: zgp.PROFILE_ID,
-                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
-                INPUT_CLUSTERS: [],
-                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
-            },
-        }
-    }
+(
+    TuyaQuirkBuilder("_TYST11_i5j6ifxj", "5j6ifxj")
+    .applies_to("_TYST11_7hfcudw5", "hfcudw5")
+    .tuya_dp(
+        dp_id=3,
+        ep_attribute=TuyaOccupancySensing.ep_attribute,
+        attribute_name=OccupancySensing.AttributeDefs.occupancy.name,
+        converter=lambda x: x == 2,
+    )
+    .adds(TuyaOccupancySensing)
+    .skip_configuration()
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/ts0601_sensor.py b/zhaquirks/tuya/ts0601_sensor.py
index 66113fd..02a3b11 100644
--- a/zhaquirks/tuya/ts0601_sensor.py
+++ b/zhaquirks/tuya/ts0601_sensor.py
@@ -1,7 +1,5 @@
 """Tuya temp and humidity sensors."""
 
-from zigpy.quirks.v2.homeassistant import EntityType
-from zigpy.quirks.v2.homeassistant.binary_sensor import BinarySensorDeviceClass
 from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass
 import zigpy.types as t
 
@@ -90,13 +88,7 @@ from zhaquirks.tuya.builder import TuyaPowerConfigurationCluster2AAA, TuyaQuirkB
         device_class=SensorDeviceClass.ILLUMINANCE,
         state_class=SensorStateClass.MEASUREMENT,
     )
-    .tuya_binary_sensor(
-        dp_id=1,
-        attribute_name="zone_status",
-        device_class=BinarySensorDeviceClass.OPENING,
-        fallback_name="Opening",
-        entity_type=EntityType.STANDARD,
-    )
+    .tuya_contact(dp_id=1)
     .tuya_battery(dp_id=2)
     .skip_configuration()
     .add_to_registry()
diff --git a/zhaquirks/tuya/ts0601_thermostat.py b/zhaquirks/tuya/ts0601_thermostat.py
new file mode 100644
index 0000000..6645db2
--- /dev/null
+++ b/zhaquirks/tuya/ts0601_thermostat.py
@@ -0,0 +1,221 @@
+"""Tuya TS0601 Thermostat."""
+
+from zigpy.quirks.v2.homeassistant import (
+    UnitOfElectricCurrent,
+    UnitOfElectricPotential,
+    UnitOfEnergy,
+    UnitOfPower,
+    UnitOfTemperature,
+)
+from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass
+from zigpy.types import t
+from zigpy.zcl import foundation
+from zigpy.zcl.clusters.hvac import Thermostat
+
+from zhaquirks.tuya.builder import TuyaQuirkBuilder
+from zhaquirks.tuya.mcu import TuyaAttributesCluster
+
+
+class RegulatorPeriod(t.enum8):
+    """Tuya Regulator Period enum."""
+
+    FifteenMin = 0x00
+    ThirtyMin = 0x01
+    FortyFiveMin = 0x02
+    SixtyMin = 0x03
+    NinetyMin = 0x04
+
+
+class ThermostatMode(t.enum8):
+    """Tuya Thermostat mode."""
+
+    Regulator = 0x00
+    Thermostat = 0x01
+
+
+class PresetMode(t.enum8):
+    """Tuya PresetMode enum."""
+
+    Manual = 0x00
+    Home = 0x01
+    Away = 0x02
+
+
+class SensorMode(t.enum8):
+    """Tuya SensorMode enum."""
+
+    Air = 0x00
+    Floor = 0x01
+    Both = 0x02
+
+
+class TuyaThermostat(Thermostat, TuyaAttributesCluster):
+    """Tuya local thermostat cluster."""
+
+    manufacturer_id_override: t.uint16_t = foundation.ZCLHeader.NO_MANUFACTURER_ID
+
+    _CONSTANT_ATTRIBUTES = {
+        Thermostat.AttributeDefs.ctrl_sequence_of_oper.id: Thermostat.ControlSequenceOfOperation.Heating_Only
+    }
+
+    def __init__(self, *args, **kwargs):
+        """Init a TuyaThermostat cluster."""
+        super().__init__(*args, **kwargs)
+        self.add_unsupported_attribute(
+            Thermostat.AttributeDefs.setpoint_change_source.id
+        )
+        self.add_unsupported_attribute(
+            Thermostat.AttributeDefs.setpoint_change_source_timestamp.id
+        )
+        self.add_unsupported_attribute(Thermostat.AttributeDefs.pi_heating_demand.id)
+
+
+(
+    TuyaQuirkBuilder("_TZE204_p3lqqy2r", "TS0601")
+    .tuya_dp(
+        dp_id=1,
+        ep_attribute=TuyaThermostat.ep_attribute,
+        attribute_name=TuyaThermostat.AttributeDefs.system_mode.name,
+        converter=lambda x: 0x00 if not x else 0x04,
+        dp_converter=lambda x: x != 0x00,
+    )
+    .tuya_enum(
+        dp_id=2,
+        attribute_name="preset_mode",
+        enum_class=PresetMode,
+        translation_key="preset_mode",
+        fallback_name="Preset mode",
+    )
+    .tuya_dp(
+        dp_id=16,
+        ep_attribute=TuyaThermostat.ep_attribute,
+        attribute_name=TuyaThermostat.AttributeDefs.occupied_heating_setpoint.name,
+        converter=lambda x: x * 100,
+        dp_converter=lambda x: x // 100,
+    )
+    .tuya_dp(
+        dp_id=24,
+        ep_attribute=TuyaThermostat.ep_attribute,
+        attribute_name=TuyaThermostat.AttributeDefs.local_temperature.name,
+        converter=lambda x: x * 100,
+    )
+    .tuya_dp(
+        dp_id=28,
+        ep_attribute=TuyaThermostat.ep_attribute,
+        attribute_name=Thermostat.AttributeDefs.local_temperature_calibration.name,
+        converter=lambda x: x * 100,
+        dp_converter=lambda x: x // 100,
+    )
+    .tuya_switch(
+        dp_id=30,
+        attribute_name="child_lock",
+        translation_key="child_lock",
+        fallback_name="Child lock",
+    )
+    .tuya_sensor(
+        dp_id=101,
+        attribute_name="local_temperature_floor",
+        type=t.int16s,
+        device_class=SensorDeviceClass.TEMPERATURE,
+        state_class=SensorStateClass.MEASUREMENT,
+        unit=UnitOfTemperature.CELSIUS,
+        translation_key="local_temperature_floor",
+        fallback_name="Floor temperature",
+    )
+    .tuya_enum(
+        dp_id=102,
+        attribute_name="temperature_sensor_select",
+        enum_class=SensorMode,
+        translation_key="sensor_mode",
+        fallback_name="Sensor mode",
+    )
+    .tuya_dp(
+        dp_id=104,
+        ep_attribute=TuyaThermostat.ep_attribute,
+        attribute_name=TuyaThermostat.AttributeDefs.running_state.name,
+        converter=lambda x: 0x00 if not x else 0x01,
+    )
+    .tuya_binary_sensor(
+        dp_id=106,
+        attribute_name="window_detection",
+        translation_key="window_detection",
+        fallback_name="Open window detection",
+    )
+    .tuya_dp(
+        dp_id=107,
+        ep_attribute=TuyaThermostat.ep_attribute,
+        attribute_name=TuyaThermostat.AttributeDefs.max_heat_setpoint_limit.name,
+        converter=lambda x: x * 100,
+        dp_converter=lambda x: x // 100,
+    )
+    .tuya_enum(
+        dp_id=108,
+        attribute_name="thermostat_mode",
+        enum_class=ThermostatMode,
+        translation_key="thermostat_mode",
+        fallback_name="Mode",
+    )
+    .tuya_enum(
+        dp_id=109,
+        attribute_name="regulator_period",
+        enum_class=RegulatorPeriod,
+        translation_key="regulator_period",
+        fallback_name="Regulator period",
+    )
+    .tuya_number(
+        dp_id=110,
+        attribute_name="regulator_set_point",
+        type=t.uint16_t,
+        unit=UnitOfTemperature.CELSIUS,
+        min_value=0,
+        max_value=100,
+        step=1,
+        translation_key="regulator_set_point",
+        fallback_name="Regulator set point",
+    )
+    .adds(TuyaThermostat)
+    .tuya_sensor(
+        dp_id=120,
+        attribute_name="current",
+        type=t.int16s,
+        divisor=10,
+        device_class=SensorDeviceClass.CURRENT,
+        state_class=SensorStateClass.MEASUREMENT,
+        unit=UnitOfElectricCurrent.AMPERE,
+        translation_key="current",
+        fallback_name="Current",
+    )
+    .tuya_sensor(
+        dp_id=121,
+        attribute_name="voltage",
+        type=t.int16s,
+        device_class=SensorDeviceClass.VOLTAGE,
+        state_class=SensorStateClass.MEASUREMENT,
+        unit=UnitOfElectricPotential.VOLT,
+        translation_key="voltage",
+        fallback_name="Voltage",
+    )
+    .tuya_sensor(
+        dp_id=122,
+        attribute_name="power",
+        type=t.int16s,
+        device_class=SensorDeviceClass.POWER,
+        state_class=SensorStateClass.MEASUREMENT,
+        unit=UnitOfPower.WATT,
+        translation_key="power",
+        fallback_name="Power",
+    )
+    .tuya_sensor(
+        dp_id=123,
+        attribute_name="energy",
+        type=t.int16s,
+        divisor=100,
+        device_class=SensorDeviceClass.ENERGY,
+        state_class=SensorStateClass.TOTAL,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy",
+        fallback_name="Energy",
+    )
+    .skip_configuration()
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/ts0601_valve.py b/zhaquirks/tuya/ts0601_valve.py
index 316f637..69ebc32 100644
--- a/zhaquirks/tuya/ts0601_valve.py
+++ b/zhaquirks/tuya/ts0601_valve.py
@@ -593,8 +593,8 @@ class RoyalGardineerTimerState(t.enum8):
     TuyaQuirkBuilder("_TZE200_2wg5qrjy", "TS0601")
     .tuya_onoff(dp_id=1)
     .tuya_battery(dp_id=7, power_cfg=TuyaPowerConfigurationCluster2AA)
-    # Might need a converter: x // 10
-    .tuya_metering(dp_id=5)
+    # Water consumed (value comes in deciliters - convert it to liters)
+    .tuya_metering(dp_id=5, scale=0.1)
     # Timer time left/remaining (raw value in seconds)
     .tuya_number(
         dp_id=11,
diff --git a/zhaquirks/xiaomi/aqara/remote_h1.py b/zhaquirks/xiaomi/aqara/remote_h1.py
index 5e5cf39..786bd15 100644
--- a/zhaquirks/xiaomi/aqara/remote_h1.py
+++ b/zhaquirks/xiaomi/aqara/remote_h1.py
@@ -1,16 +1,9 @@
 """Aqara H1-series wireless remote."""
 
-from zigpy.profiles import zha
-import zigpy.types as t
-from zigpy.zcl.clusters.general import (
-    Basic,
-    Identify,
-    LevelControl,
-    MultistateInput,
-    OnOff,
-    PowerConfiguration,
-)
-from zigpy.zcl.clusters.lighting import Color
+from zigpy import types
+from zigpy.quirks.v2 import ClusterType, QuirkBuilder
+from zigpy.zcl.clusters.general import Identify, OnOff
+from zigpy.zcl.foundation import BaseAttributeDefs, DataTypeId, ZCLAttributeDef
 
 from zhaquirks import PowerConfigurationCluster
 from zhaquirks.const import (
@@ -21,26 +14,15 @@ from zhaquirks.const import (
     COMMAND,
     COMMAND_OFF,
     COMMAND_TOGGLE,
-    DEVICE_TYPE,
     DOUBLE_PRESS,
     ENDPOINT_ID,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
     LEFT,
     LONG_PRESS,
-    MODELS_INFO,
-    OUTPUT_CLUSTERS,
-    PROFILE_ID,
     RIGHT,
     SHORT_PRESS,
     TRIPLE_PRESS,
 )
-from zhaquirks.xiaomi import (
-    LUMI,
-    BasicCluster,
-    XiaomiAqaraE1Cluster,
-    XiaomiCustomDevice,
-)
+from zhaquirks.xiaomi import LUMI, XiaomiAqaraE1Cluster
 from zhaquirks.xiaomi.aqara.opple_remote import (
     COMMAND_1_DOUBLE,
     COMMAND_1_HOLD,
@@ -60,336 +42,156 @@ from zhaquirks.xiaomi.aqara.opple_remote import (
 BOTH_BUTTONS = "both_buttons"
 
 
-class AqaraRemoteManuSpecificCluster(XiaomiAqaraE1Cluster):
-    """Aqara manufacturer specific settings."""
+class AqaraSwitchClickMode(types.enum8):
+    """Aqara switch click mode attribute values."""
 
-    # manufacture override code: 4447 (0x115f)
-    # to get/set these attributes, you might need to click the button 5 times
-    # quickly.
-    attributes = XiaomiAqaraE1Cluster.attributes.copy()
-    attributes.update(
-        {
-            # operation_mode:
-            # 0 means "command" mode.
-            # 1 means "event" mode.
-            0x0009: ("operation_mode", t.uint8_t, True),
-            # click_mode:
-            # 1 means single click mode, which is low latency (50ms) but only sends
-            #   single click events.
-            # 2 means multiple click mode, which has a slightly higher latency but
-            #   supports single/double/triple click and long press.
-            # default value after factory reset: 1.
-            0x0125: ("click_mode", t.uint8_t, True),
-        }
-    )
+    Single = 0x01  # Low latency (50ms) but only sends single click events.
+    Multiple = 0x02  # (default) Slightly higher latency but supports single/double/triple click and long press.
 
 
-class PowerConfigurationClusterH1Remote(PowerConfigurationCluster):
-    """Reports battery level."""
-
-    # Aqara H1 wireless remote uses one CR2450 battery.
-    # Values are copied from zigbee-herdsman-converters.
-    MIN_VOLTS = 2.5
-    MAX_VOLTS = 3.0
+class AqaraSwitchOperationMode(types.enum8):
+    """Aqara switch operation mode attribute values."""
 
+    Command = 0x00
+    Event = 0x01
 
-class RemoteH1SingleRocker(XiaomiCustomDevice):
-    """Aqara H1 Wireless Remote Single Rocker Version WRS-R01."""
 
-    signature = {
-        MODELS_INFO: [(LUMI, "lumi.remote.b18ac1")],
-        ENDPOINTS: {
-            # SizePrefixedSimpleDescriptor(
-            #   endpoint=1, profile=260, device_type=259, device_version=1,
-            #   input_clusters=[0, 3, 1], output_clusters=[3, 6])
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    PowerConfiguration.cluster_id,
-                    Identify.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            }
-        },
-    }
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    BasicCluster,
-                    Identify.cluster_id,
-                    PowerConfigurationClusterH1Remote,
-                    MultistateInputCluster,
-                    AqaraRemoteManuSpecificCluster,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            }
-        }
-    }
-    device_automation_triggers = {
-        # triggers when operation_mode == event
-        # the button doesn't send an release event after hold
-        (SHORT_PRESS, BUTTON): {COMMAND: COMMAND_1_SINGLE},
-        (DOUBLE_PRESS, BUTTON): {COMMAND: COMMAND_1_DOUBLE},
-        (TRIPLE_PRESS, BUTTON): {COMMAND: COMMAND_1_TRIPLE},
-        (LONG_PRESS, BUTTON): {COMMAND: COMMAND_1_HOLD},
-        # triggers when operation_mode == command
-        (ALT_SHORT_PRESS, BUTTON): {COMMAND: COMMAND_TOGGLE, ENDPOINT_ID: 1, ARGS: []},
-        (ALT_DOUBLE_PRESS, BUTTON): {COMMAND: COMMAND_OFF, ENDPOINT_ID: 1, ARGS: []},
-    }
+class AqaraRemoteManuSpecificCluster(XiaomiAqaraE1Cluster):
+    """Aqara manufacturer cluster for the presence sensor FP1E."""
 
+    class AttributeDefs(BaseAttributeDefs):
+        """Manufacturer specific attributes."""
 
-class RemoteH1DoubleRocker1(XiaomiCustomDevice):
-    """Aqara H1 Wireless Remote Double Rocker Version WRS-R02, variant 1."""
+        # To get/set these attributes, you might need to click the button 5 times quickly.
 
-    signature = {
-        MODELS_INFO: [(LUMI, "lumi.remote.b28ac1")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    PowerConfiguration.cluster_id,
-                    Identify.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            },
-            3: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [Identify.cluster_id],
-                OUTPUT_CLUSTERS: [OnOff.cluster_id],
-            },
-        },
-    }
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    BasicCluster,
-                    Identify.cluster_id,
-                    PowerConfigurationClusterH1Remote,
-                    MultistateInputCluster,
-                    AqaraRemoteManuSpecificCluster,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            },
-            2: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    MultistateInputCluster,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            },
-            3: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    MultistateInputCluster,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            },
-        }
-    }
-    device_automation_triggers = {
-        # triggers when operation_mode == event
-        # the button doesn't send a release event after hold
-        (SHORT_PRESS, LEFT): {COMMAND: COMMAND_1_SINGLE},
-        (DOUBLE_PRESS, LEFT): {COMMAND: COMMAND_1_DOUBLE},
-        (TRIPLE_PRESS, LEFT): {COMMAND: COMMAND_1_TRIPLE},
-        (LONG_PRESS, LEFT): {COMMAND: COMMAND_1_HOLD},
-        (SHORT_PRESS, RIGHT): {COMMAND: COMMAND_2_SINGLE},
-        (DOUBLE_PRESS, RIGHT): {COMMAND: COMMAND_2_DOUBLE},
-        (TRIPLE_PRESS, RIGHT): {COMMAND: COMMAND_2_TRIPLE},
-        (LONG_PRESS, RIGHT): {COMMAND: COMMAND_2_HOLD},
-        (SHORT_PRESS, BOTH_BUTTONS): {COMMAND: COMMAND_3_SINGLE},
-        (DOUBLE_PRESS, BOTH_BUTTONS): {COMMAND: COMMAND_3_DOUBLE},
-        (TRIPLE_PRESS, BOTH_BUTTONS): {COMMAND: COMMAND_3_TRIPLE},
-        (LONG_PRESS, BOTH_BUTTONS): {COMMAND: COMMAND_3_HOLD},
-        # triggers when operation_mode == command
-        # known issue: it seems impossible to know which button being pressed
-        # when operation_mode == command
-        (ALT_SHORT_PRESS, BUTTON): {COMMAND: COMMAND_TOGGLE, ENDPOINT_ID: 1, ARGS: []},
-    }
+        operation_mode = ZCLAttributeDef(
+            id=0x0009,
+            type=AqaraSwitchOperationMode,
+            zcl_type=DataTypeId.uint8,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
 
+        click_mode = ZCLAttributeDef(
+            id=0x0125,
+            type=AqaraSwitchClickMode,
+            zcl_type=DataTypeId.uint8,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
 
-class RemoteH1DoubleRocker2(RemoteH1DoubleRocker1):
-    """Aqara H1 Wireless Remote Double Rocker Version WRS-R02, variant 2."""
 
-    signature = {
-        MODELS_INFO: [(LUMI, "lumi.remote.b28ac1")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    PowerConfiguration.cluster_id,
-                    Identify.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            },
-            2: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            },
-            3: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [Identify.cluster_id],
-                OUTPUT_CLUSTERS: [OnOff.cluster_id],
-            },
-        },
-    }
+class PowerConfigurationClusterH1Remote(PowerConfigurationCluster):
+    """Reports battery level."""
 
+    # Aqara H1 wireless remote uses one CR2450 battery.
+    # Values are copied from zigbee-herdsman-converters.
+    MIN_VOLTS = 2.5
+    MAX_VOLTS = 3.0
 
-class RemoteH1DoubleRocker3(RemoteH1DoubleRocker1):
-    """Aqara H1 Wireless Remote Double Rocker Version WRS-R02, variant 3."""
 
-    signature = {
-        MODELS_INFO: [(LUMI, "lumi.remote.b28ac1")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.COLOR_DIMMER_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    PowerConfiguration.cluster_id,
-                    Identify.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                    LevelControl.cluster_id,
-                    Color.cluster_id,
-                ],
-            },
-            2: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            },
-            3: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [Identify.cluster_id],
-                OUTPUT_CLUSTERS: [OnOff.cluster_id],
+(
+    QuirkBuilder(LUMI, "lumi.remote.b18ac1")
+    # temporarily commented out due to potentially breaking existing blueprints
+    # .friendly_name(
+    #     manufacturer="Aqara", model="Wireless Remote Switch H1 (Single Rocker)"
+    # )
+    .replaces(AqaraRemoteManuSpecificCluster)
+    .replaces(MultistateInputCluster)
+    .replaces(PowerConfigurationClusterH1Remote)
+    .enum(
+        AqaraRemoteManuSpecificCluster.AttributeDefs.click_mode.name,
+        AqaraSwitchClickMode,
+        AqaraRemoteManuSpecificCluster.cluster_id,
+        translation_key="click_mode",
+        fallback_name="Click mode",
+    )
+    .enum(
+        AqaraRemoteManuSpecificCluster.AttributeDefs.operation_mode.name,
+        AqaraSwitchOperationMode,
+        AqaraRemoteManuSpecificCluster.cluster_id,
+        translation_key="operation_mode",
+        fallback_name="Operation mode",
+    )
+    .device_automation_triggers(
+        {
+            # triggers when operation_mode == event
+            # the button doesn't send an release event after hold
+            (SHORT_PRESS, BUTTON): {COMMAND: COMMAND_1_SINGLE},
+            (DOUBLE_PRESS, BUTTON): {COMMAND: COMMAND_1_DOUBLE},
+            (TRIPLE_PRESS, BUTTON): {COMMAND: COMMAND_1_TRIPLE},
+            (LONG_PRESS, BUTTON): {COMMAND: COMMAND_1_HOLD},
+            # triggers when operation_mode == command
+            (ALT_SHORT_PRESS, BUTTON): {
+                COMMAND: COMMAND_TOGGLE,
+                ENDPOINT_ID: 1,
+                ARGS: [],
+            },
+            (ALT_DOUBLE_PRESS, BUTTON): {
+                COMMAND: COMMAND_OFF,
+                ENDPOINT_ID: 1,
+                ARGS: [],
             },
-            4: {},
-            5: {},
-            6: {},
-        },
-    }
-
+        }
+    )
+    .add_to_registry()
+)
 
-class RemoteH1DoubleRocker4(RemoteH1DoubleRocker1):
-    """Aqara H1 Wireless Remote Double Rocker Version WRS-R02, variant 4."""
 
-    signature = {
-        MODELS_INFO: [(LUMI, "lumi.remote.b28ac1")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.COLOR_DIMMER_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    PowerConfiguration.cluster_id,
-                    Identify.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                    LevelControl.cluster_id,
-                    Color.cluster_id,
-                ],
-            },
-            2: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    OnOff.cluster_id,
-                ],
-            },
-            3: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [Identify.cluster_id],
-                OUTPUT_CLUSTERS: [OnOff.cluster_id],
-            },
-            4: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    MultistateInput.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [OnOff.cluster_id],
-            },
-            5: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    MultistateInput.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [OnOff.cluster_id],
-            },
-            6: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT_SWITCH,
-                INPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    MultistateInput.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [OnOff.cluster_id],
+(
+    QuirkBuilder(LUMI, "lumi.remote.b28ac1")
+    # temporarily commented out due to potentially breaking existing blueprints
+    # .friendly_name(
+    #     manufacturer="Aqara", model="Wireless Remote Switch H1 (Double Rocker)"
+    # )
+    .replaces(AqaraRemoteManuSpecificCluster)
+    .adds(Identify)
+    .replaces(MultistateInputCluster)
+    .replaces(MultistateInputCluster, endpoint_id=2)
+    .replaces(MultistateInputCluster, endpoint_id=3)
+    .adds(OnOff, cluster_type=ClusterType.Client)
+    .adds(OnOff, endpoint_id=2, cluster_type=ClusterType.Client)
+    .adds(OnOff, endpoint_id=3, cluster_type=ClusterType.Client)
+    .replaces(PowerConfigurationClusterH1Remote)
+    .enum(
+        AqaraRemoteManuSpecificCluster.AttributeDefs.click_mode.name,
+        AqaraSwitchClickMode,
+        AqaraRemoteManuSpecificCluster.cluster_id,
+        translation_key="click_mode",
+        fallback_name="Click mode",
+    )
+    .enum(
+        AqaraRemoteManuSpecificCluster.AttributeDefs.operation_mode.name,
+        AqaraSwitchOperationMode,
+        AqaraRemoteManuSpecificCluster.cluster_id,
+        translation_key="operation_mode",
+        fallback_name="Operation mode",
+    )
+    .device_automation_triggers(
+        {
+            # triggers when operation_mode == event
+            # the button doesn't send a release event after hold
+            (SHORT_PRESS, LEFT): {COMMAND: COMMAND_1_SINGLE},
+            (DOUBLE_PRESS, LEFT): {COMMAND: COMMAND_1_DOUBLE},
+            (TRIPLE_PRESS, LEFT): {COMMAND: COMMAND_1_TRIPLE},
+            (LONG_PRESS, LEFT): {COMMAND: COMMAND_1_HOLD},
+            (SHORT_PRESS, RIGHT): {COMMAND: COMMAND_2_SINGLE},
+            (DOUBLE_PRESS, RIGHT): {COMMAND: COMMAND_2_DOUBLE},
+            (TRIPLE_PRESS, RIGHT): {COMMAND: COMMAND_2_TRIPLE},
+            (LONG_PRESS, RIGHT): {COMMAND: COMMAND_2_HOLD},
+            (SHORT_PRESS, BOTH_BUTTONS): {COMMAND: COMMAND_3_SINGLE},
+            (DOUBLE_PRESS, BOTH_BUTTONS): {COMMAND: COMMAND_3_DOUBLE},
+            (TRIPLE_PRESS, BOTH_BUTTONS): {COMMAND: COMMAND_3_TRIPLE},
+            (LONG_PRESS, BOTH_BUTTONS): {COMMAND: COMMAND_3_HOLD},
+            # triggers when operation_mode == command
+            # known issue: it seems impossible to know which button being pressed
+            # when operation_mode == command
+            (ALT_SHORT_PRESS, BUTTON): {
+                COMMAND: COMMAND_TOGGLE,
+                ENDPOINT_ID: 1,
+                ARGS: [],
             },
-        },
-    }
+        }
+    )
+    .add_to_registry()
+)


Test patch:
diff --git a/tests/test_philips.py b/tests/test_philips.py
index fa5cb72..da20697 100644
--- a/tests/test_philips.py
+++ b/tests/test_philips.py
@@ -317,15 +317,10 @@ class ManuallyFiredButtonPressQueue:
 
         self._click_counter = 0
         self._callback = None
-        self._button = None
 
-    def press(self, callback, button):
+    def press(self, callback):
         """Process a button press."""
-        if button != self._button:
-            self._click_counter = 1
-        else:
-            self._click_counter += 1
-        self._button = button
+        self._click_counter += 1
         self._callback = callback
 
 
@@ -374,11 +369,14 @@ def test_PhilipsRemoteCluster_short_press(
     cluster = device.endpoints[ep].philips_remote_cluster
     listener = mock.MagicMock()
     cluster.add_listener(listener)
-    cluster.button_press_queue = ManuallyFiredButtonPressQueue()
+    cluster.button_press_queue = {
+        k: ManuallyFiredButtonPressQueue() for k in cluster.BUTTONS
+    }
 
     cluster.handle_cluster_request(ZCLHeader(), [1, 0, 0, 0, 0])
     cluster.handle_cluster_request(ZCLHeader(), [1, 0, 2, 0, 0])
-    cluster.button_press_queue.fire()
+    for q in cluster.button_press_queue.values():
+        q.fire()
 
     assert listener.zha_send_event.call_count == 2
 
@@ -462,14 +460,17 @@ def test_PhilipsRemoteCluster_multi_press(
     cluster = device.endpoints[ep].philips_remote_cluster
     listener = mock.MagicMock()
     cluster.add_listener(listener)
-    cluster.button_press_queue = ManuallyFiredButtonPressQueue()
+    cluster.button_press_queue = {
+        k: ManuallyFiredButtonPressQueue() for k in cluster.BUTTONS
+    }
 
     for _ in range(0, count):
         # btn1 short press
         cluster.handle_cluster_request(ZCLHeader(), [1, 0, 0, 0, 0])
         # btn1 short release
         cluster.handle_cluster_request(ZCLHeader(), [1, 0, 2, 0, 0])
-    cluster.button_press_queue.fire()
+    for q in cluster.button_press_queue.values():
+        q.fire()
 
     assert listener.zha_send_event.call_count == 1
     args_button_id = count + 2
@@ -620,30 +621,21 @@ def test_PhilipsRemoteCluster_long_press(
 
 
 @pytest.mark.parametrize(
-    "button_presses, result_count",
+    "button_presses",
     (
-        (
-            [1],
-            1,
-        ),
-        (
-            [1, 1],
-            2,
-        ),
-        (
-            [1, 1, 3, 3, 3, 2, 2, 2, 2],
-            4,
-        ),
+        (1),
+        (2),
+        (4),
     ),
 )
-def test_ButtonPressQueue_presses_without_pause(button_presses, result_count):
+def test_ButtonPressQueue_presses_without_pause(button_presses):
     """Test ButtonPressQueue presses without pause in between presses."""
 
     q = ButtonPressQueue()
     q._ms_threshold = 50
     cb = mock.MagicMock()
-    for btn in button_presses:
-        q.press(cb, btn)
+    for _ in range(button_presses):
+        q.press(cb)
 
     # await cluster.button_press_queue._task
     # Instead of awaiting the job, significantly extending the time
@@ -653,33 +645,17 @@ def test_ButtonPressQueue_presses_without_pause(button_presses, result_count):
     q._task.cancel()
     q._ms_last_click = 0
     q._callback(q._click_counter)
-    cb.assert_called_once_with(result_count)
+    cb.assert_called_once_with(button_presses)
 
 
 @pytest.mark.parametrize(
-    "press_sequence, results",
+    "press_sequence",
     (
-        (
-            # switch buttons within a sequence,
-            # new sequence start with different button
-            (
-                [1, 1, 3, 3],
-                [2, 2, 2],
-            ),
-            (2, 3),
-        ),
-        (
-            # no button switch within a sequence,
-            # new sequence with same button
-            (
-                [1, 1, 1],
-                [1],
-            ),
-            (3, 1),
-        ),
+        ((2, 3)),
+        ((3, 1)),
     ),
 )
-async def test_ButtonPressQueue_presses_with_pause(press_sequence, results):
+async def test_ButtonPressQueue_presses_with_pause(press_sequence):
     """Test ButtonPressQueue with pauses in between button press sequences."""
 
     q = ButtonPressQueue()
@@ -687,14 +663,14 @@ async def test_ButtonPressQueue_presses_with_pause(press_sequence, results):
     cb = mock.MagicMock()
 
     for seq in press_sequence:
-        for btn in seq:
-            q.press(cb, btn)
+        for _ in range(seq):
+            q.press(cb)
         await q._task
 
-    assert cb.call_count == len(results)
+    assert cb.call_count == len(press_sequence)
 
     calls = []
-    for res in results:
+    for res in press_sequence:
         calls.append(mock.call(res))
 
     cb.assert_has_calls(calls)
@@ -768,3 +744,53 @@ def test_contact_sensor(zigpy_device_from_v2_quirk):
     # update again with the same value and except no new update
     hue_cluster.update_attribute(hue_cluster.AttributeDefs.contact.id, 1)
     assert len(on_off_listener.attribute_updates) == 2
+
+
+@pytest.mark.parametrize(
+    "dev, ep, button_events, expected_actions",
+    (
+        (
+            PhilipsWallSwitch,
+            1,
+            (
+                [
+                    b"\x1d\x0b\x106\x00\x01\x00\x000\x00!\x00\x00",
+                    b"\x1d\x0b\x107\x00\x01\x00\x000\x02!\x01\x00",
+                ],
+                [
+                    b"\x1d\x0b\x108\x00\x02\x00\x000\x00!\x00\x00",
+                    b"\x1d\x0b\x109\x00\x02\x00\x000\x02!\x01\x00",
+                ],
+            ),
+            ["left_press", "left_short_release", "right_press", "right_short_release"],
+        ),
+    ),
+)
+def test_PhilipsRemoteCluster_multi_button_press(
+    zigpy_device_from_quirk, dev, ep, button_events, expected_actions
+):
+    """Test PhilipsRemoteCluster short button press logic."""
+
+    device = zigpy_device_from_quirk(dev)
+
+    remote_cluster = device.endpoints[ep].philips_remote_cluster
+    remote_cluster.button_press_queue = {
+        k: ManuallyFiredButtonPressQueue() for k in remote_cluster.BUTTONS
+    }
+    remote_listener = mock.MagicMock()
+    remote_cluster.add_listener(remote_listener)
+
+    expected_event_count = 0
+    for button in button_events:
+        for eventData in button:
+            hdr, args = remote_cluster.deserialize(eventData)
+            remote_cluster.handle_message(hdr, args)
+            expected_event_count += 1
+
+    for q in remote_cluster.button_press_queue.values():
+        q.fire()
+
+    assert remote_listener.zha_send_event.call_count == expected_event_count
+
+    for i, expected_action in enumerate(expected_actions):
+        assert remote_listener.zha_send_event.call_args_list[i][0][0] == expected_action
diff --git a/tests/test_tuya.py b/tests/test_tuya.py
index 5ff64ba..2a97a05 100644
--- a/tests/test_tuya.py
+++ b/tests/test_tuya.py
@@ -1,6 +1,5 @@
 """Tests for Tuya quirks."""
 
-import asyncio
 import base64
 import datetime
 import struct
@@ -26,7 +25,6 @@ from zhaquirks.const import (
     ON,
     OUTPUT_CLUSTERS,
     PROFILE_ID,
-    ZONE_STATUS_CHANGE_COMMAND,
 )
 from zhaquirks.tuya import Data, TuyaManufClusterAttributes, TuyaNewManufCluster
 import zhaquirks.tuya.sm0202_motion
@@ -54,7 +52,6 @@ ZCL_TUYA_BUTTON_1_LONG_PRESS = b"\tk\x06\x03\x11\x01\x04\x00\x01\x02"
 ZCL_TUYA_BUTTON_2_SINGLE_PRESS = b"\tN\x06\x01\x1f\x02\x04\x00\x01\x00"
 ZCL_TUYA_BUTTON_2_DOUBLE_PRESS = b"\tj\x06\x03\x10\x02\x04\x00\x01\x01"
 ZCL_TUYA_BUTTON_2_LONG_PRESS = b"\tl\x06\x03\x12\x02\x04\x00\x01\x02"
-ZCL_TUYA_MOTION = b"\tL\x01\x00\x05\x03\x04\x00\x01\x02"
 ZCL_TUYA_SWITCH_ON = b"\tQ\x02\x006\x01\x01\x00\x01\x01"
 ZCL_TUYA_SWITCH_OFF = b"\tQ\x02\x006\x01\x01\x00\x01\x00"
 ZCL_TUYA_ATTRIBUTE_617_TO_179 = b"\tp\x02\x00\x02i\x02\x00\x04\x00\x00\x00\xb3"
@@ -100,33 +97,6 @@ ZCL_TUYA_EHEAT_TEMPERATURE = b"\tp\x02\x00\x02\x18\x02\x00\x04\x00\x00\x00\xb3"
 ZCL_TUYA_EHEAT_TARGET_TEMP = b"\t3\x01\x03\x05\x10\x02\x00\x04\x00\x00\x00\x15"
 
 
-@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_motion.TuyaMotion,))
-async def test_motion(zigpy_device_from_quirk, quirk):
-    """Test tuya motion sensor."""
-
-    motion_dev = zigpy_device_from_quirk(quirk)
-
-    motion_cluster = motion_dev.endpoints[1].ias_zone
-    motion_listener = ClusterListener(motion_cluster)
-
-    tuya_cluster = motion_dev.endpoints[1].tuya_manufacturer
-
-    # send motion on Tuya manufacturer specific cluster
-    hdr, args = tuya_cluster.deserialize(ZCL_TUYA_MOTION)
-    with mock.patch.object(motion_cluster, "reset_s", 0):
-        tuya_cluster.handle_message(hdr, args)
-
-    assert len(motion_listener.cluster_commands) == 1
-    assert motion_listener.cluster_commands[0][1] == ZONE_STATUS_CHANGE_COMMAND
-    assert motion_listener.cluster_commands[0][2][0] == ON
-
-    await asyncio.gather(asyncio.sleep(0), asyncio.sleep(0), asyncio.sleep(0))
-
-    assert len(motion_listener.cluster_commands) == 2
-    assert motion_listener.cluster_commands[1][1] == ZONE_STATUS_CHANGE_COMMAND
-    assert motion_listener.cluster_commands[1][2][0] == OFF
-
-
 @pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_switch.TuyaSingleSwitchTI,))
 async def test_singleswitch_state_report(zigpy_device_from_quirk, quirk):
     """Test tuya single switch."""
@@ -1628,25 +1598,6 @@ def test_ts0601_valve_signature(assert_signature_matches_quirk):
     assert_signature_matches_quirk(zhaquirks.tuya.ts0601_valve.TuyaValve, signature)
 
 
-def test_ts0601_motion_signature(assert_signature_matches_quirk):
-    """Test TS0601 motion by TreatLife remote signature is matched to its quirk."""
-    signature = {
-        "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.EndDevice: 2>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress: 128>, manufacturer_code=4417, maximum_buffer_size=66, maximum_incoming_transfer_size=66, server_mask=10752, maximum_outgoing_transfer_size=66, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)",
-        "endpoints": {
-            "1": {
-                "profile_id": 260,
-                "device_type": "0x0051",
-                "in_clusters": ["0x0000", "0x0004", "0x0005", "0xef00"],
-                "out_clusters": ["0x000a", "0x0019"],
-            }
-        },
-        "manufacturer": "_TZE200_ppuj1vem",
-        "model": "TS0601",
-        "class": "zigpy.device.Device",
-    }
-    assert_signature_matches_quirk(zhaquirks.tuya.ts0601_motion.NeoMotion, signature)
-
-
 def test_multiple_attributes_report():
     """Test a multi attribute report from Tuya device."""
 
diff --git a/tests/test_tuya_motion.py b/tests/test_tuya_motion.py
new file mode 100644
index 0000000..2128242
--- /dev/null
+++ b/tests/test_tuya_motion.py
@@ -0,0 +1,64 @@
+"""Tests for Tuya quirks."""
+
+import pytest
+from zigpy.zcl import foundation
+from zigpy.zcl.clusters.measurement import OccupancySensing
+
+from tests.common import ClusterListener
+import zhaquirks
+import zhaquirks.tuya
+from zhaquirks.tuya.mcu import TuyaMCUCluster
+
+ZCL_TUYA_MOTION = b"\tL\x01\x00\x05\x01\x01\x00\x01\x01"  # DP 1
+ZCL_TUYA_MOTION_V2 = b"\tL\x01\x00\x05\x65\x01\x00\x01\x01"  # DP 101
+ZCL_TUYA_MOTION_V3 = b"\tL\x01\x00\x05\x03\x04\x00\x01\x02"  # DP 3, enum
+
+
+zhaquirks.setup()
+
+
+@pytest.mark.parametrize(
+    "model,manuf,occ_msg",
+    [
+        ("_TZE200_ya4ft0w4", "TS0601", ZCL_TUYA_MOTION),
+        ("_TZE200_7hfcudw5", "TS0601", ZCL_TUYA_MOTION_V2),
+        ("_TZE200_ppuj1vem", "TS0601", ZCL_TUYA_MOTION_V2),
+        ("_TZE200_ar0slwnd", "TS0601", ZCL_TUYA_MOTION),
+        ("_TZE200_mrf6vtua", "TS0601", ZCL_TUYA_MOTION),
+        ("_TZE200_sfiy5tfs", "TS0601", ZCL_TUYA_MOTION),
+        ("_TZE204_sooucan5", "TS0601", ZCL_TUYA_MOTION),
+        ("_TZE200_wukb7rhc", "TS0601", ZCL_TUYA_MOTION),
+        ("_TZE204_qasjif9e", "TS0601", ZCL_TUYA_MOTION),
+        ("_TZE200_ztc6ggyl", "TS0601", ZCL_TUYA_MOTION),
+        ("_TZE204_ztc6ggyl", "TS0601", ZCL_TUYA_MOTION),
+        ("_TZE204_ztqnh5cg", "TS0601", ZCL_TUYA_MOTION),
+        ("_TYST11_i5j6ifxj", "5j6ifxj", ZCL_TUYA_MOTION_V3),
+        ("_TYST11_7hfcudw5", "hfcudw5", ZCL_TUYA_MOTION_V3),
+    ],
+)
+async def test_tuya_motion_quirk(zigpy_device_from_v2_quirk, model, manuf, occ_msg):
+    """Test Tuya Motion Quirks."""
+    quirked_device = zigpy_device_from_v2_quirk(model, manuf)
+    ep = quirked_device.endpoints[1]
+
+    assert ep.tuya_manufacturer is not None
+    assert isinstance(ep.tuya_manufacturer, TuyaMCUCluster)
+
+    assert ep.occupancy is not None
+    assert isinstance(ep.occupancy, OccupancySensing)
+
+    occupancy_listener = ClusterListener(ep.occupancy)
+
+    hdr, data = ep.tuya_manufacturer.deserialize(occ_msg)
+    status = ep.tuya_manufacturer.handle_get_data(data.data)
+
+    assert status == foundation.Status.SUCCESS
+
+    zcl_occupancy_id = OccupancySensing.AttributeDefs.occupancy.id
+
+    assert len(occupancy_listener.attribute_updates) == 1
+    assert occupancy_listener.attribute_updates[0][0] == zcl_occupancy_id
+    assert (
+        occupancy_listener.attribute_updates[0][1]
+        == OccupancySensing.Occupancy.Occupied
+    )
diff --git a/tests/test_tuya_thermostat.py b/tests/test_tuya_thermostat.py
new file mode 100644
index 0000000..4818ebf
--- /dev/null
+++ b/tests/test_tuya_thermostat.py
@@ -0,0 +1,71 @@
+"""Tests for Tuya Thermostat."""
+
+import pytest
+from zigpy.zcl import foundation
+from zigpy.zcl.clusters.hvac import Thermostat
+
+from tests.common import ClusterListener
+import zhaquirks
+from zhaquirks.tuya.mcu import TuyaMCUCluster
+
+zhaquirks.setup()
+
+
+@pytest.mark.parametrize(
+    "msg,attr,value",
+    [
+        (
+            b"\t\x13\x02\x00\x06\x01\x01\x00\x01\x01",
+            Thermostat.AttributeDefs.system_mode,
+            Thermostat.SystemMode.Heat,
+        ),  # Set to heat, dp 1
+        (
+            b"\t\x16\x02\x00\t\x18\x02\x00\x04\x00\x00\x00\x18",
+            Thermostat.AttributeDefs.local_temperature,
+            2400,
+        ),  # Current temp 24, dp 24
+        (
+            b"\t\x15\x02\x00\x08\x10\x02\x00\x04\x00\x00\x00\x19",
+            Thermostat.AttributeDefs.occupied_heating_setpoint,
+            2500,
+        ),  # Setpoint to 25, dp 16
+        (
+            b"\t\x17\x02\x00\n\x1c\x02\x00\x04\x00\x00\x00\x00",
+            Thermostat.AttributeDefs.local_temperature_calibration,
+            0,
+        ),  # Local calibration to 0, dp 28
+        (
+            b"\t\x1c\x02\x00\x0fh\x01\x00\x01\x01",
+            Thermostat.AttributeDefs.running_state,
+            Thermostat.RunningState.Heat_State_On,
+        ),  # Running state, dp 104
+        (
+            b"\t\x1d\x02\x00\x10k\x02\x00\x04\x00\x00\x00\x1b",
+            Thermostat.AttributeDefs.max_heat_setpoint_limit,
+            2700,
+        ),  # Max heat set point, dp 107
+    ],
+)
+async def test_handle_get_data(zigpy_device_from_v2_quirk, msg, attr, value):
+    """Test handle_get_data for multiple attributes."""
+
+    quirked = zigpy_device_from_v2_quirk("_TZE204_p3lqqy2r", "TS0601")
+    ep = quirked.endpoints[1]
+
+    assert ep.tuya_manufacturer is not None
+    assert isinstance(ep.tuya_manufacturer, TuyaMCUCluster)
+
+    assert ep.thermostat is not None
+    assert isinstance(ep.thermostat, Thermostat)
+
+    thermostat_listener = ClusterListener(ep.thermostat)
+
+    hdr, data = ep.tuya_manufacturer.deserialize(msg)
+    status = ep.tuya_manufacturer.handle_get_data(data.data)
+    assert status == foundation.Status.SUCCESS
+
+    assert len(thermostat_listener.attribute_updates) == 1
+    assert thermostat_listener.attribute_updates[0][0] == attr.id
+    assert thermostat_listener.attribute_updates[0][1] == value
+
+    assert ep.thermostat.get(attr.id) == value