Moes ZHT-S01 does not work in ZHA

Hi all,

I am trying to use a Moes ZHT-S01 Zigbee thermostat with ZHA. It pairs but there is just 1 entity created for firmware version and even that is “Unknown”.

I understand I might need a “quirk” to get it to work but having tried about 5 or 6 different quirks yesterday I cannot find one that works (or I am not doing something right).

I was following all the steps as I understood them:

  1. configuration.yaml change to enable quirks and tell it the path
  2. putting the python code in the right folder on my HA instance
  3. restarting HA
  4. repairing the device

Same result every time. The pycache folder was being created so it was picking up the fact there was a quirk there but the quirk is not listed in the Device Info though which makes me think the quirk wasn’t suitable for that device.

Device Info:
TS0601
by _TZE284_rlytpmij
Connected via [Generic Zigbee Coordinator (EZSP)]
Firmware: 0x00000051
Zigbee: A4:C1:38:78:92:14:D2:CA
Zigbee info
Nwk: 0xb4ca
Device Type: Router
LQI: 216
RSSI: -57
Last seen: 2025-11-25T10:06:29
Power source: Mains

If a quirk is used I gather it should be displayed at the bottom of this Device Info section. Home Assistant is bang up to date (2025.11.3) and using ZHA coupled with a SMLight SLZB-06M.

Can anyone assist please.

Thank you
Rich

Hi all,

I realise it was only 3 days ago since I posted but Moes support have suggested I use Z2M rather than ZHA. Can anyone confirm if this exact thermostat (model ZHT-S01) works with Zigbee2MQTT?

I don’t really want to have to change over to Z2M because my current config works great with 57 devices all using ZHA and I don’t relish the thought of re-pairing everything just to get a single device to work.

I have an unused spare Conbee 2 USB adaptor that I used to use before I upgraded to the SMLight so in theory I could have the thermostat running via that on Z2M. That would work wouldn’t it?

Kind regards
Richard

I’ve got a different Moes zigbee device and it’s built using a Tuya zigbee module, which always use a non-standard message protocol.

Z2M works with these devices because it has a large library of custom scripts for dealing with the conversion. It appears a script for the S01 isn’t in the default install yet, but people have been working on it: [New device support]: TS0601 (_TZE284_rlytpmij) · Issue #29644 · Koenkk/zigbee2mqtt · GitHub

It’s likely that’s why ZHA doesn’t have a quirk for it (yet), as they’re often ported across from Z2M once they’re working there.

I (basically ChatGPT) managed to create some kind of quirk to this based on generic Tuya Thermostat quirk and the work that was done in Z2MQTT with this thermostat (TS0601_TZE284_rlytpmij).

The model I have is this https://moeshouse.com/products/zigbee-smart-thermostat-programmable-temperature-controller-water-boiler-electric-heating?variant=50590497964347

I’m still testing this and appreciate corrections and suggestions. I don’t have any idea if the code is reasonable or pretty. The code has also some Finnish comments :slight_smile:


# Moes/Tuya TS0601 (_TZE284_rlytpmij) — ZHA quirk,
# - Builder-polku (quirks v2): tuya dp 1/111/117/47
# - Fallback (EF00): ep_attribute="tuya_ef00", time-callbackit varmistettu, DP-aliasit tuettu
# - Päivittää Thermostat.local_temperature ja TemperatureMeasurement.measured_value (0.01°C)
# - Replacement altistaa Thermostat + TemperatureMeasurement; EF00 klusteriluokkana

import logging
from zigpy.quirks import CustomDevice
from zigpy.profiles import zha
from zigpy.zcl.clusters.general import Basic, Identify, PowerConfiguration, Ota, Groups, Scenes
from zigpy.zcl.clusters.hvac import Thermostat, RunningState
from zigpy.zcl.clusters.measurement import TemperatureMeasurement

_LOGGER = logging.getLogger(__name__)

# ---------- Yritä builder-APIa (quirks v2) ----------
_builder_ok = False
try:
    # v2 builder
    from zigpy.quirks.v2 import BinarySensorDeviceClass, EntityType
    from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass
    from zigpy.quirks.v2.homeassistant import UnitOfTemperature
    from zigpy.types import t
    from zigpy.zcl import foundation

    from zhaquirks.tuya import TUYA_SET_TIME, TuyaTimePayload
    from zhaquirks.tuya.builder import TuyaQuirkBuilder
    from zhaquirks.tuya.mcu import TuyaAttributesCluster, TuyaMCUCluster

    _builder_ok = True
except Exception:
    _builder_ok = False


# ---------- Builder-pohjainen toteutus ----------
if _builder_ok:
    class TuyaThermostatCluster(Thermostat, TuyaAttributesCluster):
        """Tuya-local Thermostat cluster; Heating only, poistetut attribuutit yhtenäisesti."""
        _CONSTANT_ATTRIBUTES = {
            Thermostat.AttributeDefs.ctrl_sequence_of_oper.id:
                Thermostat.ControlSequenceOfOperation.Heating_Only
        }

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            # Ther­mostat vakiot: merkkaa eksplisiittisesti unsupported, jos HA/ZHA yrittää lukea
            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)
            # Kalibrointi uusissa toteutuksissa tuodaan usein erikseen
            # (ZHA voi tarjota eri polkua; jätetään unsupported ellei DP 19 sidota builderilla)
            # self.add_unsupported_attribute(Thermostat.AttributeDefs.local_temperature_calibration.id)

    class NoManufTimeNoVersionRespTuyaMCUCluster(TuyaMCUCluster):
        """Tuya Manufacturer Cluster, jossa set_time on julkinen ja MCU-version vastaus ei kaada."""
        class ServerCommandDefs(TuyaMCUCluster.ServerCommandDefs):
            set_time = foundation.ZCLCommandDef(
                id=TUYA_SET_TIME,
                schema={"time": TuyaTimePayload},
                is_manufacturer_specific=False,
            )

        def handle_mcu_version_response(self, payload: TuyaMCUCluster.MCUVersion) -> foundation.Status:  # type: ignore
            return foundation.Status.SUCCESS

    # Rakennetaan quirk täsmälleen sinun valmistaja+mallille (DP:t Z2M-mallin mukaan)
    (
        TuyaQuirkBuilder("_TZE284_rlytpmij", "TS0601")
        .tuya_dp(  # SystemMode: 1 bool -> Heat/Off
            dp_id=1,
            ep_attribute=TuyaThermostatCluster.ep_attribute,
            attribute_name=TuyaThermostatCluster.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(  # Setpoint: 111 -> OccupiedHeatingSetpoint (0.01°C)
            dp_id=111,
            ep_attribute=TuyaThermostatCluster.ep_attribute,
            attribute_name=TuyaThermostatCluster.AttributeDefs.occupied_heating_setpoint.name,
            converter=lambda x: int(x * 10),         # DP 0.1°C -> ZCL 0.01°C
            dp_converter=lambda x: int(x // 10),     # ZCL 0.01°C -> DP 0.1°C
        )
        .tuya_dp(  # Local temperature: 117 -> local_temperature (0.01°C)
            dp_id=117,
            ep_attribute=TuyaThermostatCluster.ep_attribute,
            attribute_name=TuyaThermostatCluster.AttributeDefs.local_temperature.name,
            converter=lambda x: int(x * 10),         # 0.1°C -> 0.01°C
        )
        .tuya_dp(  # Running state: 47 -> 0=heat,1=idle (Z2M mallissa)
            dp_id=47,
            ep_attribute=TuyaThermostatCluster.ep_attribute,
            attribute_name=TuyaThermostatCluster.AttributeDefs.running_state.name,
            converter=lambda x: RunningState.Heat_State_On if (x in (0, False)) else RunningState.Idle,
        )
        .adds(TuyaThermostatCluster)                  # lisää local Thermostat -klusteri
        .skip_configuration()                         # ei lähetetä config-kyselyitä
        .add_to_registry(replacement_cluster=NoManufTimeNoVersionRespTuyaMCUCluster)  # set_time-fix
    )

else:
    # ---------- EF00-fallback (vanhempi ympäristö) ----------
    try:
        from zhaquirks.tuya import TuyaManufCluster
    except Exception as e:
        TuyaManufCluster = None
        _LOGGER.warning("TuyaManufCluster ei saatavilla: %s", e)

    DP_TEMP_IDS      = (117, 1, 3)
    DP_SETPOINT_IDS  = (111, 2)
    DP_HEATSTATE_IDS = (47, 12, 13)

    def _tuya01_to_c(v) -> float:
        if isinstance(v, (bytes, bytearray)):
            try:
                v = int.from_bytes(v, "big")
            except Exception:
                v = 0
        return float(int(v)) / 10.0

    class MoesTuyaCluster(TuyaManufCluster):  # type: ignore
        """EF00-klusteri: parser + ZCL-mapping + time-callback-fixit"""
        cluster_id = 0xEF00
        ep_attribute = "tuya_ef00"

        # Luokkataso no-op, varalle
        @staticmethod
        def set_time(*args, **kwargs): return None
        @staticmethod
        def set_time_local_offset(*args, **kwargs): return None

        def handle_cluster_request(self, hdr, args, dst_addressing=None):
            # Instanssitaso varmistus ennen basea
            if not hasattr(self, "set_time") or self.set_time is None:
                self.set_time = lambda *a, **kw: None
            if not hasattr(self, "set_time_local_offset") or self.set_time_local_offset is None:
                self.set_time_local_offset = lambda *a, **kw: None
            return super().handle_cluster_request(hdr, args, dst_addressing=dst_addressing)

        def _parse_dp_payload(self, data: bytes):
            idx, ln, out = 0, len(data), []
            while idx + 2 <= ln:
                dp_id = data[idx]; dp_type = data[idx+1]; idx += 2
                if idx >= ln: break
                length = data[idx]
                if (idx + 1 + length) > ln and (idx + 2) <= ln:
                    length = (length << 8) | data[idx + 1]; idx += 2
                else:
                    idx += 1
                if idx + length > ln: break
                raw = data[idx: idx + length]; idx += length
                if dp_type == 0x01: val = bool(raw[0]) if raw else False
                elif dp_type == 0x02: val = int.from_bytes(raw, "big", signed=(len(raw) == 2))
                elif dp_type == 0x04: val = raw[0] if raw else 0
                else: val = raw
                out.append((dp_id, dp_type, val, raw))
            return out

        def _apply(self, dp_id, dp_type, val, raw):
            ep1 = getattr(self.endpoint.device, "endpoints", {}).get(1)
            thermo: Thermostat = getattr(ep1, "thermostat", None) if ep1 else None
            tmeas:  TemperatureMeasurement = getattr(ep1, "temperature", None) if ep1 else None
            def up(cluster, attr, value):
                if cluster is None: return
                try: cluster._update_attribute(attr, value)
                except Exception as e: _LOGGER.debug("attr 0x%04X update failed: %s", attr, e)

            # temp
            if (dp_id in DP_TEMP_IDS):
                c = _tuya01_to_c(val)
                if thermo: up(thermo, 0x0000, int(round(val * 10)))
                if tmeas:  up(tmeas,  0x0000, int(round(val * 10)))
            # setpoint
            elif (dp_id in DP_SETPOINT_IDS) and thermo:
                c = _tuya01_to_c(val)
                up(thermo, 0x0012, int(round(val * 10)))
            # mode
            elif dp_id in (1, 2) and thermo:
                sys = 0x04 if val in (True, 1, "heat", "manual") else 0x00
                up(thermo, 0x001C, sys)
            # running
            elif (dp_id in DP_HEATSTATE_IDS) and thermo:
                heat_on = val not in (1, "idle", "Idle", False)
                up(thermo, 0x0029, 0x0001 if heat_on else 0x0000)
            # calib
            elif dp_id == 19 and thermo:
                try: up(thermo, 0x0010, int(val))
                except Exception: pass

        def cluster_command(self, tsn, command_id, args):
            try:
                raw = bytes(args[0]) if args and isinstance(args[0], (bytes, bytearray)) else bytes(args or b"")
                payload = raw
                for cut in (0, 2, 4):
                    test = raw[cut:] if len(raw) > cut else raw
                    if self._parse_dp_payload(test): payload = test; break
                for dp_id, dp_type, val, raw_dp in self._parse_dp_payload(payload):
                    _LOGGER.debug("Tuya DP recv: id=%s type=%s val=%s", dp_id, dp_type, val)
                    self._apply(dp_id, dp_type, val, raw_dp)
            except Exception as e:
                _LOGGER.exception("EF00 parse error: %s", e)
            try:
                return super().cluster_command(tsn, command_id, args)
            except Exception:
                return

    class MoesThermostat(CustomDevice):  # Fallback device class
        signature = {
            "models_info": [("_TZE284_rlytpmij", "TS0601")],
            "endpoints": {
                1: {
                    "profile_id": 0x0104,
                    "device_type": 0x0051,
                    "input_clusters": [0x0000, 0x0004, 0x0005, 0xED00, 0xEF00],
                    "output_clusters": [0x000A, 0x0019],
                },
                242: {
                    "profile_id": 0xA1E0,
                    "device_type": 0x0061,
                    "input_clusters": [],
                    "output_clusters": [0x0021],
                },
            },
        }

        replacement = {
            "endpoints": {
                1: {
                    "profile_id": 0x0104,
                    "device_type": 0x0301,  # THERMOSTAT
                    "input_clusters": [
                        0x0000, 0x0003, 0x0001, 0x0004, 0x0005,
                        0x0201, 0x0402,
                        MoesTuyaCluster,  # EF00 custom-luokka
                    ],
                    "in_clusters": [
                        0x0000, 0x0003, 0x0001, 0x0004, 0x0005,
                        0x0201, 0x0402,
                        MoesTuyaCluster,
                    ],
                    "output_clusters": [0x0019],
                    "out_clusters":    [0x0019],
                },
                242: {
                    "profile_id": 0xA1E0,
                    "device_type": 0x0061,
                    "input_clusters": [],
                    "in_clusters": [],
                    "output_clusters": [0x0021],
                    "out_clusters":    [0x0021],
                },
            }
        }

        def __init__(self, *a, **kw):
            super().__init__(*a, **kw)
            self._patch_ef00()

        def setup(self):
            self._patch_ef00()

        def _patch_ef00(self):
            # Instanssitasolle varmistetaan time-callbackit riippumatta siitä, kumpi EF00 on käytössä
            try:
                ep1 = self.endpoints.get(1)
                ef00 = None
                for attr in ("tuya_ef00", "tuya_cluster", "tuya_manufacturer", "manufacturer_specific"):
                    ef00 = getattr(ep1, attr, None)
                    if ef00 is not None:
                        break
                if ef00 is None:
                    _LOGGER.debug("EF00 instance not found on ep1 during patch")
                    return
                ef00.set_time = lambda *a, **kw: None
                ef00.set_time_local_offset = lambda *a, **kw: None
                _LOGGER.debug("EF00 time callbacks patched on instance")
            except Exception as e:
                _LOGGER.debug("EF00 patch failed: %s", e)

Hi @timofin

Nice work. Thank you for this. I replaced the ts0601.py quirk file with your code and I at least get current temperature and target temperature shown now which is a great start.

If I modify the target temperature in HA it reflects this on the thermostat.

But if I modify it on the thermostat HA doesn’t update. And similarly when the current temperature changes this is also not updated into HA.

So it seems to be one way at the moment. Are you seeing this as well?

Kind regards
Richard

Hi @RichHA

Yes I seem to have the same issue.

My prompt (in short) to ChatGPT was to:

Quirk debugging is quite time consuming due to the need for restarting HA and re-pairing the devices between every change. I’ll try to debug this at some point. Please let me know if You find any corrections.

@timofin this works great for me! Thank you for providing that quirk.

If I change the target temperature on my thermostat, I can see it update in HA and so does the current temperature.

Have you found a way to measure power consumption of the thermostat?

This seems to work. ChatGPT did this so I can’t really give more information on this :smiley: This took about 100 iterations/tries, really annoying.

I have two files:
init.py
ts0601_thermostat_rlytpmij.py

in the folder:
custom_zha_quirks/TZE284_rlytpmij

ts0601_thermostat_rlytpmij.py:

"""ZHA custom quirk: Moes ZHT-S01 Zigbee thermostat (TS0601, _TZE284_rlytpmij).

This device is a Tuya MCU thermostat exposing only Tuya manufacturer clusters on the wire (0xED00 + 0xEF00).
Home Assistant ZHA needs a quirk to expose a proper Thermostat cluster and translate HA writes into Tuya DP writes.

This file supports two quirk APIs:
1) Preferred (newer): TuyaQuirkBuilder (zigpy quirks v2). If available, it will register the quirk automatically.
2) Fallback (older): classic zhaquirks + EnchantedDevice style device class.

DP mapping (per Z2M converters for this model):
- DP 1   system_mode (off/heat)
- DP 39  child_lock
- DP 47  running_state / valve_state
- DP 101 floor_temperature (0.1°C)
- DP 111 current_heating_setpoint (0.1°C)
- DP 117 local_temperature (0.1°C)

Notes:
- Tuya temps are 0.1°C on the wire for this model. ZCL uses 0.01°C.
- Signature MUST match the raw device descriptor (no 0x0201/0x0402 in signature!).
"""

from __future__ import annotations

import logging
from typing import Any

_LOGGER = logging.getLogger(__name__)


# --------------------------------------------------------------------------------------
# Preferred implementation: TuyaQuirkBuilder (quirks v2)
# --------------------------------------------------------------------------------------
try:
    from zigpy.zcl.clusters.hvac import Thermostat
    from zigpy.zcl.clusters.measurement import TemperatureMeasurement
    from zigpy.zcl.foundation import ZCLCommandDef
    from zigpy.zcl import foundation

    try:
        # zigpy typically exposes this enum here
        from zigpy.zcl.clusters.hvac.thermostat import RunningState
    except Exception:  # pragma: no cover
        RunningState = None  # type: ignore

    from zhaquirks.tuya.builder import TuyaQuirkBuilder
    from zhaquirks.tuya.mcu import TuyaAttributesCluster, TuyaMCUCluster
    from zhaquirks.tuya import TUYA_SET_TIME, TuyaTimePayload

    _HAVE_BUILDER = True
except Exception as err:  # pragma: no cover
    _LOGGER.debug("TuyaQuirkBuilder not available (%s). Falling back to classic quirk.", err)
    _HAVE_BUILDER = False


def _clamp(val: int, lo: int, hi: int) -> int:
    return lo if val < lo else hi if val > hi else val


def _zcl_centideg_to_tuya_deci(val: int) -> int:
    """Convert ZCL 0.01°C -> Tuya 0.1°C, rounded to 0.5°C steps."""
    # Convert to 0.1°C
    dp = int(round(val / 10.0))
    # Device expects 0.5°C steps -> dp must be multiple of 5 (0.5°C == 5 * 0.1°C)
    dp = int(round(dp / 5.0)) * 5
    return _clamp(dp, 50, 350)  # 5.0°C .. 35.0°C


if _HAVE_BUILDER:

    class MoesThermostatCluster(Thermostat, TuyaAttributesCluster):
        """Thermostat cluster backed by Tuya DPs."""

        _CONSTANT_ATTRIBUTES = {
            Thermostat.AttributeDefs.ctrl_sequence_of_oper.id: Thermostat.ControlSequenceOfOperation.Heating_Only,
        }

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            # Avoid ZHA trying to read/parse optional attributes some Tuya MCUs don't support
            for attr in (
                "week_day_schedule",
                "min_setpoint_dead_band",
                "remote_sensing",
                "control_sequence_of_oper",
            ):
                try:
                    self.add_unsupported_attribute(attr)
                except Exception:
                    pass


    class MoesFloorTemperatureCluster(TemperatureMeasurement, TuyaAttributesCluster):
        """Expose floor temperature as a standard TemperatureMeasurement cluster."""


    class NoManufTimeNoVersionRespTuyaMCUCluster(TuyaMCUCluster):
        """Some Tuya MCUs want set_time without manufacturer specific flag and respond oddly to version query."""

        class ServerCommandDefs(TuyaMCUCluster.ServerCommandDefs):
            set_time = ZCLCommandDef(
                id=TUYA_SET_TIME,
                schema={"time": TuyaTimePayload},
                is_manufacturer_specific=False,
            )

        def handle_mcu_version_response(self, *args, **kwargs):
            # Ignore/ack; returning SUCCESS prevents ZHA failing init on some firmwares
            return foundation.Status.SUCCESS


    def _tuya_system_mode_to_zcl(v: Any) -> Thermostat.SystemMode:
        return Thermostat.SystemMode.Heat if bool(v) else Thermostat.SystemMode.Off


    def _zcl_system_mode_to_tuya(v: Any) -> bool:
        # v may be int, enum, or string depending on call site
        try:
            return int(v) != int(Thermostat.SystemMode.Off)
        except Exception:
            return str(v).lower() not in ("off", "0", "false")


    def _tuya_running_state_to_zcl(v: Any) -> Any:
        # DP47: 0=open (heating), 1=closed (idle)
        if RunningState is None:
            return 0x0001 if int(v) == 0 else 0x0000
        return RunningState.Heat_State_On if int(v) == 0 else RunningState.Idle


    (
        TuyaQuirkBuilder("_TZE284_rlytpmij", "TS0601")
        # Thermostat core
        .adds(MoesThermostatCluster)
        .tuya_dp(
            dp_id=1,
            ep_attribute=MoesThermostatCluster.ep_attribute,
            attribute_name=MoesThermostatCluster.AttributeDefs.system_mode.name,
            converter=_tuya_system_mode_to_zcl,
            dp_converter=_zcl_system_mode_to_tuya,
        )
        .tuya_dp(
            dp_id=111,
            ep_attribute=MoesThermostatCluster.ep_attribute,
            attribute_name=MoesThermostatCluster.AttributeDefs.occupied_heating_setpoint.name,
            converter=lambda x: int(x) * 10,  # 0.1°C -> 0.01°C
            dp_converter=_zcl_centideg_to_tuya_deci,
        )
        .tuya_dp(
            dp_id=117,
            ep_attribute=MoesThermostatCluster.ep_attribute,
            attribute_name=MoesThermostatCluster.AttributeDefs.local_temperature.name,
            converter=lambda x: int(x) * 10,  # 0.1°C -> 0.01°C
        )
        .tuya_dp(
            dp_id=47,
            ep_attribute=MoesThermostatCluster.ep_attribute,
            attribute_name=MoesThermostatCluster.AttributeDefs.running_state.name,
            converter=_tuya_running_state_to_zcl,
        )
        # Floor temperature sensor entity
        .adds(MoesFloorTemperatureCluster)
        .tuya_dp(
            dp_id=101,
            ep_attribute=MoesFloorTemperatureCluster.ep_attribute,
            attribute_name=MoesFloorTemperatureCluster.AttributeDefs.measured_value.name,
            converter=lambda x: int(x) * 10,  # 0.1°C -> 0.01°C
        )
        .skip_configuration()
        .add_to_registry(replacement_cluster=NoManufTimeNoVersionRespTuyaMCUCluster)
    )


# --------------------------------------------------------------------------------------
# Fallback implementation: classic zhaquirks device + EF00 attributes
# --------------------------------------------------------------------------------------
if not _HAVE_BUILDER:
    from zigpy.profiles import zha
    from zigpy.quirks import CustomCluster
    import zigpy.types as t
    from zigpy.zcl.clusters.general import Basic, GreenPowerProxy, Groups, Ota, Scenes, Time
    from zigpy.zcl.clusters.measurement import TemperatureMeasurement
    from zhaquirks import Bus
    from zhaquirks.const import DEVICE_TYPE, ENDPOINTS, INPUT_CLUSTERS, MODELS_INFO, OUTPUT_CLUSTERS, PROFILE_ID
    from zhaquirks.tuya import (
        NoManufacturerCluster,
        TuyaManufClusterAttributes,
        TuyaThermostatCluster,
        TuyaUserInterfaceCluster,
    )
    from zhaquirks.tuya.mcu import EnchantedDevice

    # DPs
    DP_SYSTEM_MODE = 1
    DP_CHILD_LOCK = 39
    DP_RUNNING_STATE = 47
    DP_FLOOR_TEMP = 101
    DP_TARGET_TEMP = 111
    DP_LOCAL_TEMP = 117

    def _dp_attr(dp_id: int) -> int:
        return 0x0200 + dp_id

    ATTR_SYSTEM_MODE = _dp_attr(DP_SYSTEM_MODE)
    ATTR_CHILD_LOCK = _dp_attr(DP_CHILD_LOCK)
    ATTR_RUNNING_STATE = _dp_attr(DP_RUNNING_STATE)
    ATTR_FLOOR_TEMP = _dp_attr(DP_FLOOR_TEMP)
    ATTR_TARGET_TEMP = _dp_attr(DP_TARGET_TEMP)
    ATTR_LOCAL_TEMP = _dp_attr(DP_LOCAL_TEMP)

    class MoesManufCluster(TuyaManufClusterAttributes):
        """Tuya EF00 cluster with typed attributes for this thermostat."""

        attributes = TuyaManufClusterAttributes.attributes.copy()
        attributes.update(
            {
                ATTR_SYSTEM_MODE: ("system_mode", t.uint8_t, True),
                ATTR_CHILD_LOCK: ("child_lock", t.uint8_t, True),
                ATTR_RUNNING_STATE: ("running_state", t.uint8_t, True),
                ATTR_FLOOR_TEMP: ("floor_temperature", t.uint32_t, True),
                ATTR_TARGET_TEMP: ("current_heating_setpoint", t.uint32_t, True),
                ATTR_LOCAL_TEMP: ("local_temperature", t.uint32_t, True),
            }
        )

        @staticmethod
        def _to_zcl_temp(v: Any) -> int:
            return int(v) * 10  # 0.1°C -> 0.01°C

        def _update_attribute(self, attrid, value):
            super()._update_attribute(attrid, value)

            dev = self.endpoint.device

            if attrid == ATTR_LOCAL_TEMP:
                dev.thermostat_bus.listener_event("temperature_change", "local_temperature", self._to_zcl_temp(value))
                return

            if attrid == ATTR_TARGET_TEMP:
                dev.thermostat_bus.listener_event(
                    "temperature_change", "occupied_heating_setpoint", self._to_zcl_temp(value)
                )
                return

            if attrid == ATTR_FLOOR_TEMP:
                dev.floor_temperature_bus.listener_event("temperature_change", self._to_zcl_temp(value))
                return

            if attrid == ATTR_SYSTEM_MODE:
                dev.thermostat_bus.listener_event("system_mode_change", int(value))
                return

            if attrid == ATTR_RUNNING_STATE:
                dev.thermostat_bus.listener_event("state_change", int(value))
                return

            if attrid == ATTR_CHILD_LOCK:
                dev.ui_bus.listener_event("child_lock_change", int(value))

    class MoesThermostat(TuyaThermostatCluster):
        """Expose Thermostat cluster backed by Tuya DPs."""

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.endpoint.device.thermostat_bus.add_listener(self)

        def map_attribute(self, attribute: str, value):
            if attribute == "system_mode":
                if int(value) == int(self.SystemMode.Off):
                    return {ATTR_SYSTEM_MODE: 0}
                return {ATTR_SYSTEM_MODE: 1}

            if attribute == "occupied_heating_setpoint":
                return {ATTR_TARGET_TEMP: _zcl_centideg_to_tuya_deci(int(value))}

            return {}

        def temperature_change(self, attr: str, value: int):
            if attr in ("local_temperature", "occupied_heating_setpoint"):
                self._update_attribute(self.attributes_by_name[attr].id, int(value))

        def system_mode_change(self, value: int):
            mode = self.SystemMode.Off if int(value) == 0 else self.SystemMode.Heat
            self._update_attribute(self.attributes_by_name["system_mode"].id, mode)

        def state_change(self, value: int):
            # DP47: 0=open (heating), 1=closed (idle)
            rs_attr = self.attributes_by_name.get("running_state")
            if rs_attr is None:
                return
            running_state = 0x0001 if int(value) == 0 else 0x0000
            self._update_attribute(rs_attr.id, running_state)

    class MoesFloorTemperatureMeasurement(CustomCluster, TemperatureMeasurement):
        """Floor temperature sensor (local cluster)."""

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.endpoint.device.floor_temperature_bus.add_listener(self)

        def temperature_change(self, value: int):
            self._update_attribute(self.attributes_by_name["measured_value"].id, int(value))

    class MoesUserInterface(TuyaUserInterfaceCluster):
        """Expose child lock via UI cluster."""

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.endpoint.device.ui_bus.add_listener(self)

        def child_lock_change(self, value: int):
            self._update_attribute(self.attributes_by_name["keypad_lockout"].id, int(value))

        def map_attribute(self, attribute: str, value):
            if attribute == "keypad_lockout":
                return {ATTR_CHILD_LOCK: int(value)}
            return super().map_attribute(attribute, value)

    class MoesThermostatDevice(EnchantedDevice):
        """Moes ZHT-S01 (TS0601 / _TZE284_rlytpmij)."""

        def __init__(self, *args, **kwargs):
            # buses MUST exist before clusters attach listeners during DB load
            self.thermostat_bus = Bus()
            self.ui_bus = Bus()
            self.floor_temperature_bus = Bus()
            super().__init__(*args, **kwargs)

        signature = {
            MODELS_INFO: [("_TZE284_rlytpmij", "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,
                        NoManufacturerCluster.cluster_id,  # 0xED00
                        TuyaManufClusterAttributes.cluster_id,  # 0xEF00
                    ],
                    OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
                },
                242: {
                    PROFILE_ID: 0xA1E0,
                    DEVICE_TYPE: 0x0061,
                    INPUT_CLUSTERS: [],
                    OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
                },
            },
        }

        replacement = {
            ENDPOINTS: {
                1: {
                    PROFILE_ID: zha.PROFILE_ID,
                    DEVICE_TYPE: zha.DeviceType.THERMOSTAT,
                    INPUT_CLUSTERS: [
                        Basic.cluster_id,
                        Groups.cluster_id,
                        Scenes.cluster_id,
                        NoManufacturerCluster,  # 0xED00
                        MoesManufCluster,  # 0xEF00
                        MoesThermostat,  # 0x0201
                        MoesFloorTemperatureMeasurement,  # 0x0402
                        MoesUserInterface,  # 0x0204
                    ],
                    OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
                },
                242: {
                    PROFILE_ID: 0xA1E0,
                    DEVICE_TYPE: 0x0061,
                    INPUT_CLUSTERS: [],
                    OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
                },
            }
        }

init.py:

# /config/custom_zha_quirks/TZE284_rlytpmij/__init__.py
# Importing the module is enough; it registers the quirk (builder) or exposes MoesThermostatDevice (fallback).
from . import ts0601_thermostat_rlytpmij  # noqa: F401

Thank you for sharing. I and Claude run over the code, too, and got it reliably working including the preset selection: https://gist.github.com/taschik/ffebd3c685b4392a57bfa8d79a0d2ac1

I have yet to figure out how to get the currently active preset to show up in the thermostat card. I believe it requires a preset_mode field which I was not able yet to expose. But this also works reliable to report changes back to HA.

If you are interested, I added a power and an energy sensor which track the estimated energy usage of the my floorheating when turned on.

I am using an input helper to configure how many “watts” the floor heating uses and use it as input into a template sensor:

{% set action = state_attr('climate.thermostate', 'hvac_action') %}
{% if action == 'heating' %}
  {{ states('input_number.thermostat_heating_watts_basement') | float(0) }}
{% else %}
  0
{% endif %}

and then use an integral sensor with left Rieman sum and a 1 min interval to create the sensor data to be used in the energy dashboard.

My next goal is how do I get the configured schedule to become configurable in HA. Did you try this already?

Thanks for providing the initial work here!

Thank You for the reply and the alternative solution. I will give it a try. I bought six peaces of these new Moes models and had two older ones (TZE204_aoclfnxz) in plase, thought that the same quirk would have applied - well no :smiley:

While the thermostat was updating correctly in both directions when I shared my quirk earlier today, I have trouble getting updates from the thermostats at the moment. It might have something to do with running multiple (8 pcs) of these thermostat.

My Terhmostat is for water circulation floor heating, so the energy calculation is a bit difficult - I whould have to know the supply and return water temperatures and the flow rate to calculate power and energy. Thanks anyway for providing the example for this also.

@taschik I noticed that the thermostat “Network status icon” on the top left in the thermostat disappears after some time from the pairing. When that happens, the thermostat won’t send any updates to HA. Thermostat can still be controlled from Home Assistant. I have a pretty solid Zigbee network and hadn’t have any problems with other devices. I think this is just some Tuya related bullshit once again. If you face similiar problems and find a solution for this, I would be very interested.

p.s.
I managed to reply on my own post in the previous post :smiley: You may wan’t to checkt that post out too…

I observed the same. I can always send from HA to Thermostat but the back channel stops working after a while. I also noticed the little “wifi icon” disappear after a while and I thought it’s because of my bad Zigbee network. If love to hear a solution to keep it sending because my power calculation only works if HA knows that the thermostat is turned on. I can infer it from the power demand on the phase it is working as I measure that but that’s not reliable enough for me.
How frustrating! I was wondering if I should order more or try a W500 from Aqara but it doesn’t look as nice and also seems to have its issues.

@timofin my current workaround is to change the mode from auto To manual wait 5s and then back to auto and after another 5-10s it updates its current status. Really annoying at best. I ordered a W500 now and am going to test if they work better and if so, I’ll replace the MOES/Tuya c**p.

I’d still be very curious to hear if you find any better working solutions. It’s definitely not having multiples as it happens to me with a single on too.

I ordered six of these so I’m still looking for a solution :wink: I’m thinking of moving from ZHA to Z2M… A new Zigbee adapter should arrive today and I will build a parallel Z2M network for testing.

I have been running Zigbee2MQTT for two days now and the thermostat has been online and reporting temperatures without problems. I purchased SMLIGHT SLZM-06M U coordinator to test out Z2M together with ZHA with the old coordinator. I guess I will be moving to Z2M to avoid replacing all six thermostats. I also tested out ZHA with the new coordinator to confirm that the issue is not with the coordinator.

I figured it out by longpressing the “-” in the turned off state. It’s not the factory reset which will keep the Zigbee setup but resets everything else like all setpoints and preset time configurations. I had an old Sonoff USB Dongle-P flying around and made this one Coordinator for a second Zigbeenetwork but using Z2M and it is working great! No issues with disconnects and much more reliable.
Now I only have to move y entire ZHA network over but I think it’s worth it. Thanks @timofin for the suggestion. You’re a hero!

One thing I am currently experiencing is that the moment the thermostat starts the heating, it’s local temperature drops significantly after a short period of time.

I don’t know why and how to prevent it but perhaps someone has a good idea how to counter it.

In some cases, the floor heating gets really hot and the thermostat needs to be turned off manually. After a while, the local temperature recovers and shows the actual value as seen in the two examples below:


What could be done about that? Any one an idea or tip?