Repository: zigpy/zha-device-handlers
Pull request #3575 (2024-12-03 11:23:59)

Add entities for Schneider Electric Thermostats
## Proposed change
Add most of the manufacturer specific attributes as Home Assistant entities (numbers, enums).

## Additional information
#1705 
https://github.com/zigpy/zha-device-handlers/pull/3473
https://github.com/Koenkk/zigbee2mqtt/issues/23739

## Screenshots
<img src="https://github.com/user-attachments/assets/75f983bd-b77e-4cfe-a385-187b06247a3e" width="50%"/>
<img src="https://github.com/user-attachments/assets/36c59916-4061-40b8-b086-ebc9323eb496" width="50%"/>
<img src="https://github.com/user-attachments/assets/855183f1-3116-4f0f-acfa-18834f185ef9" width="50%"/>

## 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


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3575?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 89.79%. Comparing base [(`97413e3`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/97413e3ec2e5fdbcd17239b810d573cec4d3964d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`b1412ed`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/b1412edd5c44cb2cf5913eb53675bf0fa0bf4dd4?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    #3575   +/-   ##
=======================================
  Coverage   89.79%   89.79%           
=======================================
  Files         323      323           
  Lines       10414    10416    +2     
=======================================
+ Hits         9351     9353    +2     
  Misses       1063     1063           
```

</details>

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

uvNikita: Tested the changes on my devices and everything is working good.

A couple of things I couldn't figure out:
- Missing percent unit, e.g. in `display_brightness` and `display_inactive_brightness`
- Also `control_status` enum should be readonly (see comment), but not sure how to achieve it with quirks v2

Patch:
diff --git a/zhaquirks/nimly/lock.py b/zhaquirks/nimly/lock.py
index d7d324b..6e762b2 100644
--- a/zhaquirks/nimly/lock.py
+++ b/zhaquirks/nimly/lock.py
@@ -27,16 +27,17 @@ NIMLY_LOCK_NODE_DESCRIPTOR = NodeDescriptor(
 
 (
     QuirkBuilder(NIMLY, "EasyFingerTouch")
-    .also_applies_to(NIMLY, "EasyCodeTouch")
+    .applies_to(NIMLY, "EasyCodeTouch")
+    .applies_to(NIMLY, "easyCodeTouch_v1")
     .node_descriptor(NIMLY_LOCK_NODE_DESCRIPTOR)
     .add_to_registry()
 )
 
 (
     QuirkBuilder(NIMLY, "NimlyPRO")
-    .also_applies_to(NIMLY, "NimlyCode")
-    .also_applies_to(NIMLY, "NimlyTouch")
-    .also_applies_to(NIMLY, "NimlyIn")
+    .applies_to(NIMLY, "NimlyCode")
+    .applies_to(NIMLY, "NimlyTouch")
+    .applies_to(NIMLY, "NimlyIn")
     .node_descriptor(NIMLY_LOCK_NODE_DESCRIPTOR)
     .replaces(DoublingPowerConfigurationCluster, endpoint_id=11)
     .add_to_registry()
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 148c5b2..1078d80 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -30,8 +30,16 @@ from zhaquirks.tuya import (
 from zhaquirks.tuya.mcu import DPToAttributeMapping, TuyaMCUCluster, TuyaOnOffNM
 
 
-class TuyaIASFire(IasZone, TuyaLocalCluster):
-    """Tuya local IAS cluster."""
+class TuyaIasContact(IasZone, TuyaLocalCluster):
+    """Tuya local IAS contact cluster."""
+
+    _CONSTANT_ATTRIBUTES = {
+        IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Contact_Switch
+    }
+
+
+class TuyaIasFire(IasZone, TuyaLocalCluster):
+    """Tuya local IAS smoke/fire cluster."""
 
     _CONSTANT_ATTRIBUTES = {
         IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Fire_Sensor
@@ -99,35 +107,54 @@ class TuyaQuirkBuilder(QuirkBuilder):
         self.adds(power_cfg)
         return self
 
-    def tuya_metering(
-        self,
-        dp_id: int,
-        metering_cfg: TuyaLocalCluster = TuyaValveWaterConsumed,
-    ) -> QuirkBuilder:
-        """Add a Tuya Metering Configuration."""
-        self.tuya_dp(
-            dp_id,
-            metering_cfg.ep_attribute,
-            "current_summ_delivered",
+    def tuya_contact(self, dp_id: int):
+        """Add a Tuya IAS contact sensor."""
+        self.tuya_ias(
+            dp_id=dp_id,
+            ias_cfg=TuyaIasContact,
+            converter=lambda x: IasZone.ZoneStatus.Alarm_1 if x != 0 else 0,
+        )
+        return self
+
+    def tuya_smoke(self, dp_id: int):
+        """Add a Tuya IAS smoke/fire sensor."""
+        self.tuya_ias(
+            dp_id=dp_id,
+            ias_cfg=TuyaIasFire,
+            converter=lambda x: IasZone.ZoneStatus.Alarm_1 if x == 0 else 0,
         )
-        self.adds(metering_cfg)
         return self
 
     def tuya_ias(
         self,
         dp_id: int,
-        ias_cfg: TuyaLocalCluster = TuyaIASFire,
+        ias_cfg: TuyaLocalCluster,
+        converter: Optional[Callable[[Any], Any]] = None,
     ) -> QuirkBuilder:
         """Add a Tuya IAS Configuration."""
         self.tuya_dp(
             dp_id,
             ias_cfg.ep_attribute,
             IasZone.AttributeDefs.zone_status.name,
-            converter=lambda x: IasZone.ZoneStatus.Alarm_1 if x == 0 else 0,
+            converter=converter,
         )
         self.adds(ias_cfg)
         return self
 
+    def tuya_metering(
+        self,
+        dp_id: int,
+        metering_cfg: TuyaLocalCluster = TuyaValveWaterConsumed,
+    ) -> QuirkBuilder:
+        """Add a Tuya Metering Configuration."""
+        self.tuya_dp(
+            dp_id,
+            metering_cfg.ep_attribute,
+            "current_summ_delivered",
+        )
+        self.adds(metering_cfg)
+        return self
+
     def tuya_onoff(
         self,
         dp_id: int,
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_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_switch.py b/zhaquirks/tuya/ts0601_switch.py
index 7ecf639..5c702fd 100644
--- a/zhaquirks/tuya/ts0601_switch.py
+++ b/zhaquirks/tuya/ts0601_switch.py
@@ -918,6 +918,7 @@ class TuyaSwitchX8_GP(TuyaSwitch):
     signature = {
         MODELS_INFO: [
             ("_TZE200_wktrysab", "TS0601"),
+            ("_TZE204_wktrysab", "TS0601"),
         ],
         ENDPOINTS: {
             # <SimpleDescriptor endpoint=1 profile=260 device_type=81
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/tuya_smoke.py b/zhaquirks/tuya/tuya_smoke.py
index cdd9ec8..fc01bc1 100644
--- a/zhaquirks/tuya/tuya_smoke.py
+++ b/zhaquirks/tuya/tuya_smoke.py
@@ -12,14 +12,14 @@ from zhaquirks.tuya.builder import TuyaPowerConfigurationCluster2AAA, TuyaQuirkB
     .applies_to("_TZE204_vawy74yh", "TS0601")
     .applies_to("_TZE284_0zaf1cr8", "TS0601")
     .applies_to("_TZ3210_up3pngle", "TS0205")
-    .tuya_ias(dp_id=1)
+    .tuya_smoke(dp_id=1)
     .skip_configuration()
     .add_to_registry()
 )
 
 (
     TuyaQuirkBuilder("_TZE204_ntcy3xu1", "TS0601")
-    .tuya_ias(dp_id=1)
+    .tuya_smoke(dp_id=1)
     .tuya_dp(
         dp_id=14,
         ep_attribute=TuyaPowerConfigurationCluster2AAA.ep_attribute,
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_builder.py b/tests/test_tuya_builder.py
index 219ed20..a188348 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -13,6 +13,8 @@ from zigpy.zcl.clusters.general import Basic
 from tests.common import ClusterListener, wait_for_zigpy_tasks
 import zhaquirks
 from zhaquirks.tuya.builder import (
+    TuyaIasContact,
+    TuyaIasFire,
     TuyaPowerConfigurationCluster2AAA,
     TuyaQuirkBuilder,
     TuyaRelativeHumidity,
@@ -46,6 +48,41 @@ def real_device(MockAppController):
     return device
 
 
+@pytest.mark.parametrize(
+    "method_name,attr_name,exp_class",
+    [
+        ("tuya_battery", "power", TuyaPowerConfigurationCluster2AAA),
+        ("tuya_metering", "smartenergy_metering", TuyaValveWaterConsumed),
+        ("tuya_onoff", "on_off", TuyaOnOffNM),
+        ("tuya_soil_moisture", "soil_moisture", TuyaSoilMoisture),
+        ("tuya_temperature", "temperature", TuyaTemperatureMeasurement),
+        ("tuya_humidity", "humidity", TuyaRelativeHumidity),
+        ("tuya_smoke", "ias_zone", TuyaIasFire),
+        ("tuya_contact", "ias_zone", TuyaIasContact),
+    ],
+)
+async def test_convenience_methods(device_mock, method_name, attr_name, exp_class):
+    """Test TuyaQuirkBuilder convenience methods."""
+
+    registry = DeviceRegistry()
+
+    entry = TuyaQuirkBuilder(
+        device_mock.manufacturer, device_mock.model, registry=registry
+    )
+    entry = getattr(entry, method_name)(dp_id=1)
+    entry.skip_configuration().add_to_registry()
+
+    quirked = registry.get_device(device_mock)
+    assert isinstance(quirked, CustomDeviceV2)
+    assert quirked in registry
+
+    ep = quirked.endpoints[1]
+
+    ep_attr = getattr(ep, attr_name)
+    assert ep_attr is not None
+    assert isinstance(ep_attr, exp_class)
+
+
 async def test_tuya_quirkbuilder(device_mock):
     """Test adding a v2 Tuya Quirk to the registry and getting back a quirked device."""
 
@@ -60,10 +97,7 @@ async def test_tuya_quirkbuilder(device_mock):
     entry = (
         TuyaQuirkBuilder(device_mock.manufacturer, device_mock.model, registry=registry)
         .tuya_battery(dp_id=1)
-        .tuya_metering(dp_id=2)
         .tuya_onoff(dp_id=3)
-        .tuya_soil_moisture(dp_id=4)
-        .tuya_temperature(dp_id=5)
         .tuya_switch(
             dp_id=6,
             attribute_name="test_onoff",
@@ -97,7 +131,7 @@ async def test_tuya_quirkbuilder(device_mock):
             translation_key="test_enum",
             fallback_name="Test enum",
         )
-        .tuya_humidity(dp_id=11)
+        .skip_configuration()
         .add_to_registry()
     )
 
@@ -126,24 +160,6 @@ async def test_tuya_quirkbuilder(device_mock):
     assert tuya_cluster.attributes_by_name["test_sensor"].id == 0xEF09
     assert tuya_cluster.attributes_by_name["test_enum"].id == 0xEF0A
 
-    assert ep.power is not None
-    assert isinstance(ep.power, TuyaPowerConfigurationCluster2AAA)
-
-    assert ep.smartenergy_metering is not None
-    assert isinstance(ep.smartenergy_metering, TuyaValveWaterConsumed)
-
-    assert ep.on_off is not None
-    assert isinstance(ep.on_off, TuyaOnOffNM)
-
-    assert ep.soil_moisture is not None
-    assert isinstance(ep.soil_moisture, TuyaSoilMoisture)
-
-    assert ep.temperature is not None
-    assert isinstance(ep.temperature, TuyaTemperatureMeasurement)
-
-    assert ep.humidity is not None
-    assert isinstance(ep.humidity, TuyaRelativeHumidity)
-
     with mock.patch.object(
         tuya_cluster.endpoint, "request", return_value=foundation.Status.SUCCESS
     ) as m1:
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