Help with fixing a Quirk TZE204_lpedvtvr (fixed)

I’m trying to build a custom quirk for a Moes Thermostat ( TZE204_lpedvtvr).
I found something similar to this thermostat as a TRV. I suppose it makes sense as the Thermostat itself looks like a TRV in a standard wall mount!

I just can’t get the HVAC Action sensor.

Anyhow, here’s my quirk - with some functions working:

"""Map from manufacturer to standard clusters for thermostatic valves."""

from typing import Any

from zigpy.profiles import zha
from zigpy.quirks.v2.homeassistant import PERCENTAGE, UnitOfTemperature, UnitOfTime
from zigpy.quirks.v2.homeassistant.binary_sensor import BinarySensorDeviceClass
from zigpy.quirks.v2.homeassistant.sensor import SensorStateClass
import zigpy.types as t
from zigpy.zcl.clusters.hvac import RunningState, Thermostat

from zhaquirks.tuya import TUYA_CLUSTER_ID
from zhaquirks.tuya.builder import TuyaQuirkBuilder
from zhaquirks.tuya.mcu import (
    DPToAttributeMapping,
    TuyaAttributesCluster,
    TuyaMCUCluster,
)


class TuyaThermostatSystemMode(t.enum8):
    """Tuya thermostat system mode enum."""

    Auto = 0x00
    Heat = 0x01
    Off = 0x02


class TuyaThermostatSystemModeV02(t.enum8):
    """Tuya thermostat system mode enum, auto and manual."""

    Auto = 0x00
    Manual = 0x02


class TuyaThermostatEcoMode(t.enum8):
    """Tuya thermostat eco mode enum."""

    Comfort = 0x00
    Eco = 0x01


class State(t.enum8):
    """State option."""

    Off = 0x00
    On = 0x01

class TuyaHysteresis(t.enum8):
    """Tuya hysteresis mode."""

    Comfort = 0x00
    Eco = 0x01


class TuyaPresetMode(t.enum8):
    """Tuya preset mode."""

    Eco = 0x00
    Auto = 0x01
    Off = 0x02
    Heat = 0x03


class TuyaThermostatV2(Thermostat, TuyaAttributesCluster):
    """Tuya local thermostat cluster."""

    _CONSTANT_ATTRIBUTES = {
        Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.id: 500,
        Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.id: 3000,
        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)

        # Previously mapped, marking as explicitly unsupported.
        self.add_unsupported_attribute(
            Thermostat.AttributeDefs.local_temperature_calibration.id
        )
        self.add_unsupported_attribute(
            Thermostat.AttributeDefs.min_heat_setpoint_limit.id
        )
        self.add_unsupported_attribute(
            Thermostat.AttributeDefs.max_heat_setpoint_limit.id
        )


class TuyaThermostatV2NoSchedule(TuyaThermostatV2):
    """Ensures schedule is disabled on system_mode change."""

    async def write_attributes(
        self,
        attributes: dict[str | int, Any],
        manufacturer: int | None = None,
        **kwargs,
    ) -> list:
        """Catch attribute writes for system_mode and set schedule to off."""
        results = await super().write_attributes(attributes, manufacturer)
        if (
            Thermostat.AttributeDefs.system_mode.id in attributes
            or Thermostat.AttributeDefs.system_mode.name in attributes
        ):
            tuya_cluster = self.endpoint.tuya_manufacturer
            await tuya_cluster.write_attributes({"schedule_enable": False})

        return results


(
    TuyaQuirkBuilder("_TZE204_lpedvtvr", "TS0601")
    # default device type is `SMART_PLUG` for this,
    # so change it back to keep UID/entity the same
    .replaces_endpoint(1, device_type=zha.DeviceType.THERMOSTAT)
    .tuya_dp(
        dp_id=2,
        ep_attribute=TuyaThermostatV2NoSchedule.ep_attribute,
        attribute_name=TuyaThermostatV2NoSchedule.AttributeDefs.running_state.name,
        converter=lambda x: RunningState.Heat_State_On if x else RunningState.Idle,
    )
    .tuya_switch(
        dp_id=39,
        attribute_name="child_lock",
        translation_key="child_lock",
        fallback_name="Child lock",
    )
    .tuya_dp(
        dp_id=1,
        ep_attribute=TuyaThermostatV2NoSchedule.ep_attribute,
        attribute_name=TuyaThermostatV2NoSchedule.AttributeDefs.system_mode.name,
        converter=lambda x: {
            True: Thermostat.SystemMode.Heat,
            False: Thermostat.SystemMode.Off,
        }[x],
        dp_converter=lambda x: {
            Thermostat.SystemMode.Heat: True,
            Thermostat.SystemMode.Off: False,
        }[x],
    )
    .tuya_dp(
        dp_id=16,
        ep_attribute=TuyaThermostatV2NoSchedule.ep_attribute,
        attribute_name=TuyaThermostatV2NoSchedule.AttributeDefs.local_temperature.name,
        converter=lambda x: x * 10,
    )
    .tuya_dp(
        dp_id=50,
        ep_attribute=TuyaThermostatV2NoSchedule.ep_attribute,
        attribute_name=TuyaThermostatV2NoSchedule.AttributeDefs.occupied_heating_setpoint.name,
        converter=lambda x: x * 10,
        dp_converter=lambda x: x // 10,
    )
   .adds(TuyaThermostatV2NoSchedule)
   .tuya_number(
        dp_id=2,
        attribute_name="hvac_action",
        type=t.uint16_t,
        translation_key="hvac_action",
        fallback_name="HVAC Mode",
   )
   .tuya_number(
        dp_id=34,
        attribute_name="max_temperature",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=15,
        max_value=35,
        step=1,
        multiplier=0.1,
        translation_key="max_temperature",
        fallback_name="Max temperature",
   )
   .tuya_number(
        dp_id=18,
        attribute_name="min_temperature",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=1,
        max_value=15,
        step=1,
        multiplier=0.1,
        translation_key="min_temperature",
        fallback_name="Min temperature",
   )
   .tuya_number(
        dp_id=48,
        attribute_name="display_brightness",
        type=t.uint16_t,
        min_value=1,
        max_value=100,
        step=1,
        translation_key="display_brightness",
        fallback_name="Display brightness",
   )
   .tuya_number(
        dp_id=113,
        attribute_name="eco_temperature",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=5,
        max_value=30,
        step=1,
        multiplier=0.1,
        translation_key="eco_temperature",
        fallback_name="Eco temperature",
   )

   .skip_configuration()
   .add_to_registry()
)

What I can’t work out is how to get the HVAC action setting - its under sensors in the screenshot here:

Any ideas?

Here are the ups:

{
  "1": "Switch",
  "2": "Work Mode",
  "16": "Current temperature",
  "18": "The lower limit of temperature",
  "28": "Factory data reset",
  "32": "Sensor selection",
  "34": "Set temperature ceiling",
  "39": "Child lock",
  "47": "State of the valve",
  "48": "Backlight brightness",
  "50": "Set temperature",
  "101": "Temp Calibration",
  "102": "Week Program 13 1",
  "103": "Week Program 13 2",
  "104": "Week Program 13 3",
  "105": "Week Program 13 4",
  "106": "Week Program 13 5",
  "107": "Week Program 13 6",
  "108": "Week Program 13 7",
  "109": "Floor temp.",
  "110": "Dead zone temp.",
  "111": "High protect temp.",
  "112": "Low protection temp.",
  "113": "Eco cool temp.",
  "114": "Screen Time Set",
  "115": "Rgblight"
}

and I’m getting these attributes:

whenever I turn the thermostat on or off from HA, I get the message shown at the bottom of the screen:

I got it working. Here’s the quirk - the only thing missing is schedules, which I’m not bothered about as I will use HA to handle that.


"""Map from manufacturer to standard clusters for thermostatic valves."""

from typing import Any

from zigpy.profiles import zha
from zigpy.quirks.v2.homeassistant import PERCENTAGE, UnitOfTemperature, UnitOfTime
from zigpy.quirks.v2.homeassistant.binary_sensor import BinarySensorDeviceClass
from zigpy.quirks.v2.homeassistant.sensor import SensorStateClass
import zigpy.types as t
from zigpy.zcl.clusters.hvac import RunningState, Thermostat

from zhaquirks.tuya import TUYA_CLUSTER_ID
from zhaquirks.tuya.builder import TuyaQuirkBuilder
from zhaquirks.tuya.mcu import (
    DPToAttributeMapping,
    TuyaAttributesCluster,
    TuyaMCUCluster,
)

class PresetMode(t.enum8):
    """Tuya preset mode  enum."""

    Manual = 0x00
    Temp_Manual = 0x01
    Programming = 0x02
    Eco = 0x03

class ScreenTimeSet(t.enum8):
    """Tuya screen time set enum."""

    Off = 0x00
    Short = 0x01
    Medium = 0x02
    Long = 0x03

class SensorMode(t.enum8):
    """Tuya sensor mode enum."""

    Air = 0x00
    Both = 0x01
    Floor = 0x02

class TuyaThermostatV2(Thermostat, TuyaAttributesCluster):
    """Tuya local thermostat cluster."""

    _CONSTANT_ATTRIBUTES = {
        Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.id: 500,
        Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.id: 3000,
        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)
        # Previously mapped, marking as explicitly unsupported.
        self.add_unsupported_attribute(
            Thermostat.AttributeDefs.local_temperature_calibration.id
        )
        self.add_unsupported_attribute(
            Thermostat.AttributeDefs.min_heat_setpoint_limit.id
        )
        self.add_unsupported_attribute(
            Thermostat.AttributeDefs.max_heat_setpoint_limit.id
        )



(
    TuyaQuirkBuilder("_TZE204_lpedvtvr", "TS0601")
    # default device type is `SMART_PLUG` for this,
    # so change it back to keep UID/entity the same
     .replaces_endpoint(1, device_type=zha.DeviceType.THERMOSTAT)

    .adds(TuyaThermostatV2)
    .tuya_switch(
        dp_id=39,
        attribute_name="child_lock",
        translation_key="child_lock",
        fallback_name="Child lock",
    )
    .tuya_dp(
        dp_id=47,
        ep_attribute=Thermostat.ep_attribute,
        attribute_name=Thermostat.AttributeDefs.running_state.name,
        converter=lambda x: RunningState.Idle if x else RunningState.Heat_State_On,
    )

    .tuya_enum(
        dp_id=2,
        attribute_name="preset_mode",
        enum_class=PresetMode,
        translation_key="preset_mode",
        fallback_name="Preset mode",
    )
    .tuya_dp(
        dp_id=1,
        ep_attribute=TuyaThermostatV2.ep_attribute,
        attribute_name=TuyaThermostatV2.AttributeDefs.system_mode.name,
        converter=lambda x: {
            True: Thermostat.SystemMode.Heat,
            False: Thermostat.SystemMode.Off,
        }[x],
        dp_converter=lambda x: {
            Thermostat.SystemMode.Heat: True,
            Thermostat.SystemMode.Off: False,
        }[x],
    )
    .tuya_dp(
        dp_id=16,
        ep_attribute=TuyaThermostatV2.ep_attribute,
        attribute_name=TuyaThermostatV2.AttributeDefs.local_temperature.name,
        converter=lambda x: x * 10,
    )
    .tuya_dp(
        dp_id=50,
        ep_attribute=TuyaThermostatV2.ep_attribute,
        attribute_name=TuyaThermostatV2.AttributeDefs.occupied_heating_setpoint.name,
        converter=lambda x: x * 10,
        dp_converter=lambda x: x // 10,
    )
    .tuya_number(
        dp_id=34 ,
        attribute_name="max_temperature",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=15,
        max_value=35,
        step=1,
        multiplier=0.1,
        translation_key="max_temperature",
        fallback_name="Max temperature",
    )

.tuya_number(
        dp_id=110,
        attribute_name="deadzone_temperature",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=0,
        max_value=5,
        step=1,
        translation_key="deadzone_temperature",
        fallback_name="Deadzone temperature",
     )

 .tuya_switch(
        dp_id=28,
        attribute_name="factory_reset",
        translation_key="factory_reset",
        fallback_name="Factory reset",
    )

 .tuya_switch(
        dp_id=115,
        attribute_name="rgb_backlight",
        translation_key="rgb_backlight",
        fallback_name="RGB Backlight",
    )

    .tuya_number(
        dp_id=18,
        attribute_name="min_temperature",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=1,
        max_value=15,
        step=1,
        multiplier=0.1,
        translation_key="min_temperature",
        fallback_name="Min temperature",
    )
    .tuya_number(
        dp_id=48,
        attribute_name="display_brightness",
        type=t.uint16_t,
        min_value=1,
        max_value=100,
        step=1,
        translation_key="display_brightness",
        fallback_name="Display brightness",
    )
    .tuya_number(
        dp_id=113,
        attribute_name="eco_temperature",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=5,
        max_value=30,
        step=1,
        multiplier=0.1,
        translation_key="eco_temperature",
        fallback_name="Eco temperature",
    )
    .tuya_number(
        dp_id=101,
        attribute_name=TuyaThermostatV2.AttributeDefs.local_temperature_calibration.name,
        type=t.int32s,
        min_value=-10,
        max_value=10,
        unit=UnitOfTemperature.CELSIUS,
        step=1,
        translation_key="local_temperature_calibration",
        fallback_name="Local temperature calibration",
    )    
    .tuya_enum(
        dp_id=32,
        attribute_name="temperature_sensor_select",
        enum_class=SensorMode,
        translation_key="sensor_mode",
        fallback_name="Sensor mode",
        )
    .tuya_enum(
        dp_id=114,
        attribute_name="screen_time_set",
        enum_class=ScreenTimeSet,
        translation_key="screen_time_set",
        fallback_name="Screen Time",
        )
    .tuya_number(
        dp_id=112,
        attribute_name="min_temperature_limit",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=1,
        max_value=30,
        step=1,
        multiplier=0.1,
        translation_key="min_temperature_limit",
        fallback_name="Min temp limit",
    )
    .tuya_number(
        dp_id=111,
        attribute_name="max_temperature_limit",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=1,
        max_value=45,
        step=1,
        multiplier=0.1,
        translation_key="max_temperature_limit",
        fallback_name="Max temp limit",
    )
    .tuya_number(
        dp_id=109,
        attribute_name="external_temperature_input",
        type=t.uint16_t,
        unit=UnitOfTemperature.CELSIUS,
        min_value=1,
        max_value=45,
        step=1,
        multiplier=0.1,
        translation_key="external_temperature_input",
        fallback_name="External temperature",
    )

    .skip_configuration()
    .add_to_registry()
)