How do you Convert a z2m converter to ZHA quirk (Fixed)

Im using ZHA and I’m trying to see if I can add a Moes Thermostat but it isn’t one that is included in ZHA yet


I’ve found a quirk which I think should work and added the name of this thermostat into the .py file

The logs seem to show that it is being recognised, but is not attaching the quirk.

WasBroadcast=<Bool.false: 0>, LQI=129, SecurityUse=<Bool.false: 0>, TimeStamp=2795999, TSN=0, Data=b'\x18\x06\x01\x04\x00\x00\x42\x10\x5F\x54\x5A\x45\x32\x30\x34\x5F\x6C\x70\x65\x64\x76\x74\x76\x72\x05\x00\x00\x42\x06\x54\x53\x30\x36\x30\x31', MacSrcAddr=0x5579, MsgResultRadius=29)
2025-05-09 14:24:38.242 DEBUG (MainThread) [zigpy.application] Received a packet: ZigbeePacket(timestamp=datetime.datetime(2025, 5, 9, 12, 24, 38, 242714, tzinfo=datetime.timezone.utc), priority=0, src=AddrModeAddress(addr_mode=<AddrMode.NWK: 2>, address=0x5579), src_ep=1, dst=AddrModeAddress(addr_mode=<AddrMode.NWK: 2>, address=0x0000), dst_ep=1, source_route=None, extended_timeout=False, tsn=0, profile_id=260, cluster_id=0, data=Serialized[b'\x18\x06\x01\x04\x00\x00B\x10_TZE204_lpedvtvr\x05\x00\x00B\x06TS0601'], tx_options=<TransmitOptions.NONE: 0>, radius=29, non_member_radius=0, lqi=129, rssi=None)
2025-05-09 14:24:38.243 DEBUG (MainThread) [zigpy.zcl] [0x5579:1:0x0000] Received ZCL frame: b'\x18\x06\x01\x04\x00\x00B\x10_TZE204_lpedvtvr\x05\x00\x00B\x06TS0601'
2025-05-09 14:24:38.243 DEBUG (MainThread) [zigpy.zcl] [0x5579:1:0x0000] Decoded ZCL frame header: ZCLHeader(frame_control=FrameControl<0x18>(frame_type=<FrameType.GLOBAL_COMMAND: 0>, is_manufacturer_specific=0, direction=<Direction.Server_to_Client: 1>, disable_default_response=1, reserved=0, *is_cluster=False, *is_general=True), tsn=6, command_id=1, *direction=<Direction.Server_to_Client: 1>)
2025-05-09 14:24:38.243 DEBUG (MainThread) [zigpy.zcl] [0x5579:1:0x0000] Decoded ZCL frame: Basic:Read_Attributes_rsp(status_records=[ReadAttributeRecord(attrid=4, status=<Status.SUCCESS: 0>, value=TypeValue(type=CharacterString, value='_TZE204_lpedvtvr')), ReadAttributeRecord(attrid=5, status=<Status.SUCCESS: 0>, value=TypeValue(type=CharacterString, value='TS0601'))])
2025-05-09 14:24:38.245 INFO (MainThread) [zigpy.device] [0x5579] Read model 'TS0601' and manufacturer '_TZE204_lpedvtvr' from <Endpoint id=1 in=[groups:0x0004, scenes:0x0005, None:0xEF00, basic:0x0000] out=[ota:0x0019, time:0x000A] status=<Status.ZDO_INIT: 1>>
2025-05-09 14:24:38.245 INFO (MainThread) [zigpy.device] [0x5579] Discovered basic device information for <Device model='TS0601' manuf='_TZE204_lpedvtvr' nwk=0x5579 ieee=a4:c1:38:ab:3e:5d:73:cb is_initialized=True>
2025-05-09 14:24:38.245 DEBUG (MainThread) [zigpy.application] Device is initialized <Device model='TS0601' manuf='_TZE204_lpedvtvr' nwk=0x5579 ieee=a4:c1:38:ab:3e:5d:73:cb is_initialized=True>
2025-05-09 14:24:38.246 DEBUG (MainThread) [zha] Emitting event raw_device_initialized with data RawDeviceInitializedEvent(device_info=RawDeviceInitializedDeviceInfo(ieee=a4:c1:38:ab:3e:5d:73:cb, nwk=0x5579, pairing_status=<DevicePairingStatus.INTERVIEW_COMPLETE: 2>, model='TS0601', manufacturer='_TZE204_lpedvtvr', signature={'manufacturer': '_TZE204_lpedvtvr', 'model': 'TS0601', 'node_desc': {'logical_type': <LogicalType.Router: 1>, 'complex_descriptor_available': 0, 'user_descriptor_available': 0, 'reserved': 0, 'aps_flags': 0, 'frequency_band': <FrequencyBand.Freq2400MHz: 8>, 'mac_capability_flags': <MACCapabilityFlags.FullFunctionDevice|MainsPowered|RxOnWhenIdle|AllocateAddress: 142>, 'manufacturer_code': 4417, 'maximum_buffer_size': 66, 'maximum_incoming_transfer_size': 66, 'server_mask': 10752, 'maximum_outgoing_transfer_size': 66, 'descriptor_capability_field': <DescriptorCapability.NONE: 0>}, 'endpoints': {1: {'profile_id': 260, 'device_type': <DeviceType.SMART_PLUG: 81>, 'input_clusters': [4, 5, 61184, 0], 'output_clusters': [25, 10]}, 242: {'profile_id': 41440, 'device_type': 97, 'input_clusters': [], 'output_clusters': [33]}}}), event_type='zha_gateway_message', event='raw_device_initialized') (1 listeners)
2025-05-09 14:24:38.246 DEBUG (MainThread) [zha] (ZHAGatewayProxy) handling event protocol for event: RawDeviceInitializedEvent(device_info=RawDeviceInitializedDeviceInfo(ieee=a4:c1:38:ab:3e:5d:73:cb, nwk=0x5579, pairing_status=<DevicePairingStatus.INTERVIEW_COMPLETE: 2>, model='TS0601', manufacturer='_TZE204_lpedvtvr', signature={'manufacturer': '_TZE204_lpedvtvr', 'model': 'TS0601', 'node_desc': {'logical_type': <LogicalType.Router: 1>, 'complex_descriptor_available': 0, 'user_descriptor_available': 0, 'reserved': 0, 'aps_flags': 0, 'frequency_band': <FrequencyBand.Freq2400MHz: 8>, 'mac_capability_flags': <MACCapabilityFlags.FullFunctionDevice|MainsPowered|RxOnWhenIdle|AllocateAddress: 142>, 'manufacturer_code': 4417, 'maximum_buffer_size': 66, 'maximum_incoming_transfer_size': 66, 'server_mask': 10752, 'maximum_outgoing_transfer_size': 66, 'descriptor_capability_field': <DescriptorCapability.NONE: 0>}, 'endpoints': {1: {'profile_id': 260, 'device_type': <DeviceType.SMART_PLUG: 81>, 'input_clusters': [4, 5, 61184, 0], 'output_clusters': [25, 10]}, 242: {'profile_id': 41440, 'device_type': 97, 'input_clusters': [], 'output_clusters': [33]}}}), event_type='zha_gateway_message', event='raw_device_initialized')
2025-05-09 14:24:38.246 DEBUG (MainThread) [zigpy.quirks.registry] Checking quirks for _TZE204_lpedvtvr TS0601 (a4:c1:38:ab:3e:5d:73:cb)
2025-05-09 14:24:38.246 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'tuya_thermostat.MoesBHT'>
2025-05-09 14:24:38.246 DEBUG (MainThread) [zigpy.quirks] Fail because endpoint list mismatch: {1} {1, 242}
2025-05-09 14:24:38.246 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.xbee.xbee_io.XBeeSensor'>
2025-05-09 14:24:38.246 DEBUG (MainThread) [zigpy.quirks] Fail because endpoint list mismatch: {232, 230} {1, 242}
2025-05-09 14:24:38.246 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.xbee.xbee3_io.XBee3Sensor'>
2025-05-09 14:24:38.246 DEBUG (MainThread) [zigpy.quirks] Fail because endpoint list mismatch: {232, 230} {1, 242}
2025-05-09 14:24:38.246 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.tuya.ts0201.MoesTemperatureHumidtySensorWithScreen'>
2025-05-09 14:24:38.247 DEBUG (MainThread) [zigpy.quirks] Fail because endpoint list mismatch: {1} {1, 242}
2025-05-09 14:24:38.247 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.smartthings.tag_v4.SmartThingsTagV4'>
2025-05-09 14:24:38.247 DEBUG (MainThread) [zigpy.quirks] Fail because endpoint list mismatch: {1} {1, 242}
2025-05-09 14:24:38.247 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.smartthings.multi.SmartthingsMultiPurposeSensor'>
2025-05-09 14:24:38.247 DEBUG (MainThread) [zigpy.quirks] Fail because endpoint list mismatch: {1} {1, 242}
2025-05-09 14:24:38.247 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.netvox.z308e3ed.Z308E3ED'>
2025-05-09 14:24:38.247 DEBUG (MainThread) [zigpy.quirks] Fail because endpoint list mismatch: {1} {1, 242}
2025-05-09 14:24:38.247 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.gledopto.soposhgu10.SoposhGU10'>
2025-05-09 14:24:38.247 DEBUG (MainThread) [zigpy.quirks] Fail because endpoint list mismatch: {11, 13} {1, 242}

Any ideas on what this could be that I’m doing wrong?

Blockquote

Did you enable quirks & point to the path the file is saved in ZHA config?

Yes. That’s done - it can be seen in the log that it’s accessing the directory

Can’t see anything properly in your log because it’s one huge wall of text

If you mean zigpy.quirks.registry, then that is the ZHA built-in quirks and not your custom file.

Can you post the specific log entry you’re referring to?

This is the one that I think is failing.


device_type': 97, 'input_clusters': [], 'output_clusters': [33]}}}), event_type='zha_gateway_message', event='raw_device_initialized')
2025-05-09 14:25:50.893 DEBUG (MainThread) [zigpy.quirks.registry] Checking quirks for _TZE204_lpedvtvr TS0601 (a4:c1:38:ab:3e:5d:73:cb)
2025-05-09 14:25:50.893 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'tuya_thermostat.MoesBHT'>
2025-05-09 14:25:50.893 DEBUG (MainThread) [zigpy.quirks] Fail because endpoint list mismatch: {1} {1, 242}

Configuration.yaml

zha:
  custom_quirks_path: /config/custom_zha_quirks
  enable_quirks: true

Try changing this:

To this:

zha:
  custom_quirks_path: config/custom_zha_quirks
  enable_quirks: true

Obviously that custom zha quirks folder needs to be stored in the same location where your yaml file is located, otherwise you need to define the absolute path.

Yes, I know - and the pycache directory contains a compiled version of the py file, so it is processing it.

The issue may be in the discovery of the device - it seems to report as a SMART PLUG

2025-05-09 19:24:31.006 DEBUG (MainThread) [zha] (ZHAGatewayProxy) handling event protocol for event: RawDeviceInitializedEvent(device_info=RawDeviceInitializedDeviceInfo(ieee=a4:c1:38:ab:3e:5d:73:cb, nwk=0x87E9, pairing_status=<DevicePairingStatus.INTERVIEW_COMPLETE: 2>, model='TS0601', manufacturer='_TZE204_lpedvtvr', signature={'manufacturer': '_TZE204_lpedvtvr', 'model': 'TS0601', 'node_desc': {'logical_type': <LogicalType.Router: 1>, 'complex_descriptor_available': 0, 'user_descriptor_available': 0, 'reserved': 0, 'aps_flags': 0, 'frequency_band': <FrequencyBand.Freq2400MHz: 8>, 'mac_capability_flags': <MACCapabilityFlags.FullFunctionDevice|MainsPowered|RxOnWhenIdle|AllocateAddress: 142>, 'manufacturer_code': 4417, 'maximum_buffer_size': 66, 'maximum_incoming_transfer_size': 66, 'server_mask': 10752, 'maximum_outgoing_transfer_size': 66, 'descriptor_capability_field': <DescriptorCapability.NONE: 0>}, 'endpoints': {1: {'profile_id': 260, 'device_type': <DeviceType.SMART_PLUG: 81>, 'input_clusters': [4, 5, 61184, 0], 'output_clusters': [25, 10]}, 242: {'profile_id': 41440, 'device_type': 97, 'input_clusters': [], 'output_clusters': [33]}}}), event_type='zha_gateway_message', event='raw_device_initialized')

Then I’m out of ideas. Last shot - have you restarted HA since you added it?

I’ve found a z2m code for this thermostat, but have no idea on how to convert to a zha quirk. Any advice/help?

Datapoints


DP ID | DP Name | DP Code | Data transfer type | Data Type | Data Attributes | Remarks
1 | Switch | switch | Issue and report | bool |   |  
2 | Work Mode | mode | Issue and report | enum | Enumerated   values:Manual,Temporary_Manual,Programming,Energy_Saving |  
16 | Current temperature | temp_current | Only report | value | Values range:0-900,Pitch:5,Unit:℃ |  
18 | The lower limit of temperature | lower_temp | Issue and report | value | Values range:50-150,Pitch:10,Unit:℃ |  
28 | Factory data reset | factory_reset | Issue and report | bool |   |  
32 | Sensor selection | sensor_choose | Issue and report | enum | Enumerated values:In,Al,Ou |  
34 | Set temperature ceiling | upper_temp | Issue and report | value | Values range:350-450,Pitch:10,Unit:℃ |  
39 | Child lock | child_lock | Issue and report | bool |   |  
47 | State of the valve | valve_state | Only report | enum | Enumerated values:open,close |  
48 | Backlight brightness | backlight | Issue and report | value | Values range:0-100,Pitch:1,Unit:% |  
50 | Set temperature | temp_set | Issue and report | value | Values range:50-450,Pitch:5,Unit:℃ |  
101 | Temp Calibration | temp_calibration | Issue and report | value | Values range:-10-10,Pitch:1,Unit:℃ |  
102 | Week Program 13 1 | week_program_13_1 | Issue and report | raw |   |  
103 | Week Program 13 2 | week_program_13_2 | Issue and report | raw |   |  
104 | Week Program 13 3 | week_program_13_3 | Issue and report | raw |   |  
105 | Week Program 13 4 | week_program_13_4 | Issue and report | raw |   |  
106 | Week Program 13 5 | week_program_13_5 | Issue and report | raw |   |  
107 | Week Program 13 6 | week_program_13_6 | Issue and report | raw |   |  
108 | Week Program 13 7 | week_program_13_7 | Issue and report | raw |   |  
109 | Floor temp. | floortemp | Only report | value | Values range:0-900,Pitch:5,Unit:°C |  
110 | Dead zone temp. | deadzonetemp | Issue and report | value | Values range:5-50,Pitch:5,Unit:°C |  
111 | High protect temp. | highprotecttemp | Issue and report | value | Values range:100-700,Pitch:10,Unit:°C |  
112 | Low protection temp. | lowprotecttemp | Issue and report | value | Values range:0-100,Pitch:10,Unit:°C |  
113 | Eco cool temp. | eco_temp | Issue and report | value | Values range:100-300,Pitch:10,Unit:°C |  
114 | Screen Time Set | screen_time_set | Issue and report | enum | Enumerated values:10S,20S,30S,40S,50S,60S |  
115 | Rgblight | rgblight | Issue and report | bool |   |  


Here is the z2m converter

const exposes = require('zigbee-herdsman-converters/lib/exposes');
const tuya = require('zigbee-herdsman-converters/lib/tuya');
const e = exposes.presets;
const ea = exposes.access;

const definition = {
    zigbeeModel: ['TS0601'],
    model: 'ZHT-SR-GB',
    vendor: 'Moes',
    description: 'Smart knob thermostat',
    fromZigbee: [tuya.fz.datapoints],
    toZigbee: [tuya.tz.datapoints],
    exposes: [
        e.binary('child_lock', ea.STATE_SET, 'ON', 'OFF').withLabel('Child lock'),
        e.binary('rgb_backlight', ea.STATE_SET, 'ON', 'OFF').withLabel('RGB backlight').withDescription('Default: On'),
        e.numeric('eco_temperature', ea.STATE_SET)
            .withValueMin(10)
            .withValueMax(30)
            .withValueStep(1)
            .withUnit('°C')
            .withDescription('Max temperature in ECO mode. Default: 30'),
        e.binary('valve_state', ea.STATE, false, true).withLabel('Heating in process'),
        e.climate()
            .withSystemMode(['off', 'heat'], ea.STATE_SET)
            .withPreset(['manual', 'temporary manual', 'schedule', 'eco'])
            .withSetpoint('current_heating_setpoint', 5, 45, 0.5, ea.STATE_SET)
            .withLocalTemperature(ea.STATE)
            .withLocalTemperatureCalibration(-10, 10, 1, ea.STATE_SET)
            .withDescription('Default: 22'),
        e.numeric('deadzone_temperature', ea.STATE_SET)
            .withValueMin(0.5)
            .withValueMax(5)
            .withValueStep(0.5)
            .withLabel('Dead zone temperature')
            .withUnit('°C')
            .withDescription('Default: 0.5'),
        e.numeric('max_temperature', ea.STATE_SET)
            .withValueMin(35)
            .withValueMax(45)
            .withValueStep(1)
            .withLabel('Set temperature ceiling')
            .withUnit('°C')
            .withDescription('Default: 45'),
        e.numeric('min_temperature_limit', ea.STATE_SET)
            .withValueMin(0)
            .withValueMax(10)
            .withValueStep(1)
            .withUnit('°C')
            .withLabel('Low temperature protection')
            .withDescription('Default: 0'),
        e.numeric('max_temperature_limit', ea.STATE_SET)
            .withValueMin(10)
            .withValueMax(70)
            .withValueStep(1)
            .withLabel('High temperature protection')
            .withUnit('°C')
            .withDescription('Default: 45'),
        e.numeric('lower_limit_of_temperature', ea.STATE_SET)
            .withValueMin(5)
            .withValueMax(15)
            .withValueStep(1)
            .withUnit('°C')
            .withLabel('The lower limit of temperature')
            .withDescription('Default: 5'),
        e.enum('sensor', ea.STATE_SET, ['IN', 'OU', 'AL']).withLabel('Sensor')
            .withDescription('Choose which sensor to use. Default: AL'),
        e.numeric('external_temperature_input', ea.STATE)
            .withLabel('Floor temperature')
            .withUnit('°C')
            .withDescription('Temperature from floor sensor'),
        e.numeric('brightness', ea.STATE_SET)
            .withValueMin(0)
            .withValueMax(100)
            .withValueStep(1)
            .withLabel('Backlight brightness')
            .withUnit('%')
            .withDescription('Backlight brightness in %. Default: 5%'),
        e.text('schedule_monday', ea.STATE_SET)
            .withLabel('Schedule for monday')
            .withDescription('Default: 06:00/20.0 11:30/20.0 13:30/20.0 17:30/20.0'),
        e.text('schedule_tuesday', ea.STATE_SET)
            .withLabel('Schedule for tuesday')
            .withDescription('Default: 06:00/20.0 11:30/20.0 13:30/20.0 17:30/20.0'),
        e.text('schedule_wednesday', ea.STATE_SET)
            .withLabel('Schedule for wednesday')
            .withDescription('Default: 06:00/20.0 11:30/20.0 13:30/20.0 17:30/20.0'),
        e.text('schedule_thursday', ea.STATE_SET)
            .withLabel('Schedule for thursday')
            .withDescription('Default: 06:00/20.0 11:30/20.0 13:30/20.0 17:30/20.0'),
        e.text('schedule_friday', ea.STATE_SET)
            .withLabel('Schedule for friday')
            .withDescription('Default: 06:00/20.0 11:30/20.0 13:30/20.0 17:30/20.0'),
        e.text('schedule_saturday', ea.STATE_SET)
            .withLabel('Schedule for saturday')
            .withDescription('Default: 06:00/20.0 11:30/20.0 13:30/20.0 17:30/20.0'),
        e.text('schedule_sunday', ea.STATE_SET)
            .withLabel('Schedule for sunday')
            .withDescription('Default: 06:00/20.0 11:30/20.0 13:30/20.0 17:30/20.0'),
        e.enum('factory_reset', ea.STATE_SET, ['factory_reset'])
            .withLabel('Factory reset')
            .withDescription('Reset all settings to factory ones'),
        e.enum('screen_time_set', ea.STATE_SET, ['10s', '20s', '30s', '40s', '50s', '60s'])
            .withLabel('Screen Timeout')
            .withDescription('Set screen timeout duration. Default: 20s'),
    ],
    meta: {
        tuyaDatapoints: [
            [1, 'system_mode', tuya.valueConverterBasic.lookup({off: false, heat: true})],
            [2, 'preset', tuya.valueConverterBasic.lookup({manual: tuya.enum(0), 'temporary manual': tuya.enum(1), schedule: tuya.enum(2), eco: tuya.enum(3)})],
            [16, 'local_temperature', tuya.valueConverter.divideBy10],
            [18, 'lower_limit_of_temperature', tuya.valueConverter.divideBy10],
            [28, 'factory_reset', tuya.valueConverterBasic.lookup({factory_reset: true})],
            [32, 'sensor', tuya.valueConverterBasic.lookup({IN: tuya.enum(0), OU: tuya.enum(2), AL: tuya.enum(1)})],
            [34, 'max_temperature', tuya.valueConverter.divideBy10],
            [39, 'child_lock', tuya.valueConverterBasic.lookup({ON: true, OFF: false})],
            [47, 'valve_state', tuya.valueConverter.trueFalseInvert],
            [48, 'brightness', tuya.valueConverter.raw],
            [50, 'current_heating_setpoint', tuya.valueConverter.divideBy10],
            [101, 'local_temperature_calibration', tuya.valueConverter.localTemperatureCalibration],
            [102, 'schedule_monday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(1)],
            [103, 'schedule_tuesday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(2)],
            [104, 'schedule_wednesday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(3)],
            [105, 'schedule_thursday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(4)],
            [106, 'schedule_friday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(5)],
            [107, 'schedule_saturday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(6)],
            [108, 'schedule_sunday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(7)],
            [109, 'external_temperature_input', tuya.valueConverter.divideBy10],
            [110, 'deadzone_temperature', tuya.valueConverter.raw],
            [111, 'max_temperature_limit', tuya.valueConverter.divideBy10],
            [112, 'min_temperature_limit', tuya.valueConverter.divideBy10],
            [113, 'eco_temperature', tuya.valueConverter.divideBy10],
            [114, 'screen_time_set', tuya.valueConverterBasic.lookup({
                '10s': tuya.enum(0),
                '20s': tuya.enum(1),
                '30s': tuya.enum(2),
                '40s': tuya.enum(3),
                '50s': tuya.enum(4),
                '60s': tuya.enum(5)
            })],
            [115, 'rgb_backlight', tuya.valueConverterBasic.lookup({ON: true, OFF: false})],
        ],
    },
    onEvent: tuya.onEventSetLocalTime,
};

module.exports = definition;

See My Tuya device doesn't work with ZHA - Or how to build a Tuya Quirk

Thanks - I did have a look, but it seems quite different to the closest quirk that I would want to modify:


"""Map from manufacturer to standard clusters for electric heating thermostats."""

from zigpy.profiles import zha
import zigpy.types as t
from zigpy.zcl.clusters.general import Basic, Groups, Ota, Scenes, Time

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)
from zhaquirks.tuya import (
    TuyaManufClusterAttributes,
    TuyaThermostat,
    TuyaThermostatCluster,
    TuyaUserInterfaceCluster,
)

# info from https://github.com/zigpy/zha-device-handlers/pull/538#issuecomment-723334124
# https://github.com/Koenkk/zigbee-herdsman-converters/blob/3156e2864882c9eb8c96b8124b12ed86f31484c4/converters/fromZigbee.js#L239
# and https://github.com/Koenkk/zigbee-herdsman-converters/blob/3156e2864882c9eb8c96b8124b12ed86f31484c4/converters/common.js#L113
MOESBHT_TARGET_TEMP_ATTR = 0x0210  # [0,0,0,21] target room temp (degree)
MOESBHT_TEMPERATURE_ATTR = 0x0218  # [0,0,0,200] current room temp (decidegree)
MOESBHT_SCHEDULE_MODE_ATTR = 0x0403  # [1] false [0] true   /!\ inverted
MOESBHT_MANUAL_MODE_ATTR = 0x0402  # [1] false [0] true /!\ inverted
MOESBHT_ENABLED_ATTR = 0x0101  # [0] off [1] on
MOESBHT_RUNNING_MODE_ATTR = 0x0424  # [1] idle [0] heating /!\ inverted
MOESBHT_CHILD_LOCK_ATTR = 0x0128  # [0] unlocked [1] child-locked


class MoesBHTManufCluster(TuyaManufClusterAttributes):
    """Manufacturer Specific Cluster of some electric heating thermostats."""

    attributes = {
        MOESBHT_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True),
        MOESBHT_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True),
        MOESBHT_SCHEDULE_MODE_ATTR: ("schedule_mode", t.uint8_t, True),
        MOESBHT_MANUAL_MODE_ATTR: ("manual_mode", t.uint8_t, True),
        MOESBHT_ENABLED_ATTR: ("enabled", t.uint8_t, True),
        MOESBHT_RUNNING_MODE_ATTR: ("running_mode", t.uint8_t, True),
        MOESBHT_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True),
    }

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)
        if attrid == MOESBHT_TARGET_TEMP_ATTR:
            self.endpoint.device.thermostat_bus.listener_event(
                "temperature_change",
                "occupied_heating_setpoint",
                value * 100,  # degree to centidegree
            )
        elif attrid == MOESBHT_TEMPERATURE_ATTR:
            self.endpoint.device.thermostat_bus.listener_event(
                "temperature_change",
                "local_temperature",
                value * 10,  # decidegree to centidegree
            )
        elif attrid == MOESBHT_SCHEDULE_MODE_ATTR:
            if value == 0:  # value is inverted
                self.endpoint.device.thermostat_bus.listener_event(
                    "program_change", "scheduled"
                )
        elif attrid == MOESBHT_MANUAL_MODE_ATTR:
            if value == 0:  # value is inverted
                self.endpoint.device.thermostat_bus.listener_event(
                    "program_change", "manual"
                )
        elif attrid == MOESBHT_ENABLED_ATTR:
            self.endpoint.device.thermostat_bus.listener_event("enabled_change", value)
        elif attrid == MOESBHT_RUNNING_MODE_ATTR:
            # value is inverted
            self.endpoint.device.thermostat_bus.listener_event(
                "state_change", 1 - value
            )
        elif attrid == MOESBHT_CHILD_LOCK_ATTR:
            self.endpoint.device.ui_bus.listener_event("child_lock_change", value)


class MoesBHTThermostat(TuyaThermostatCluster):
    """Thermostat cluster for some electric heating controllers."""

    def map_attribute(self, attribute, value):
        """Map standardized attribute value to dict of manufacturer values."""

        if attribute == "occupied_heating_setpoint":
            # centidegree to degree
            return {MOESBHT_TARGET_TEMP_ATTR: round(value / 100)}
        if attribute == "system_mode":
            if value == self.SystemMode.Off:
                return {MOESBHT_ENABLED_ATTR: 0}
            if value == self.SystemMode.Heat:
                return {MOESBHT_ENABLED_ATTR: 1}
            self.error("Unsupported value for SystemMode")
        elif attribute == "programing_oper_mode":
            # values are inverted
            if value == self.ProgrammingOperationMode.Simple:
                return {MOESBHT_MANUAL_MODE_ATTR: 0, MOESBHT_SCHEDULE_MODE_ATTR: 1}
            if value == self.ProgrammingOperationMode.Schedule_programming_mode:
                return {MOESBHT_MANUAL_MODE_ATTR: 1, MOESBHT_SCHEDULE_MODE_ATTR: 0}
            self.error("Unsupported value for ProgrammingOperationMode")

        return super().map_attribute(attribute, value)

    def program_change(self, mode):
        """Programming mode change."""
        if mode == "manual":
            value = self.ProgrammingOperationMode.Simple
        else:
            value = self.ProgrammingOperationMode.Schedule_programming_mode

        self._update_attribute(
            self.attributes_by_name["programing_oper_mode"].id, value
        )

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


class MoesBHTUserInterface(TuyaUserInterfaceCluster):
    """HVAC User interface cluster for tuya electric heating thermostats."""

    _CHILD_LOCK_ATTR = MOESBHT_CHILD_LOCK_ATTR


class MoesBHT(TuyaThermostat):
    """Tuya thermostat for devices like the Moes BHT-002GCLZB valve and BHT-003GBLZB Electric floor heating."""

    signature = {
        #  endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184],
        #  output_clusters=[10, 25]
        MODELS_INFO: [
            ("_TZE200_aoclfnxz", "TS0601"),
            ("_TZE200_2ekuz3dz", "TS0601"),
            ("_TZE200_ye5jkfsb", "TS0601"),
            ("_TZE200_u9bfwha0", "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,
                    TuyaManufClusterAttributes.cluster_id,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.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,
                    MoesBHTManufCluster,
                    MoesBHTThermostat,
                    MoesBHTUserInterface,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            }
        }
    }

Datapoints


DP ID | DP Name | DP Code | Data transfer type | Data Type | Data Attributes | Remarks
1 | Switch | switch | Issue and report | bool |   |  
2 | Work Mode | mode | Issue and report | enum | Enumerated   values:Manual,Temporary_Manual,Programming,Energy_Saving |  
16 | Current temperature | temp_current | Only report | value | Values range:0-900,Pitch:5,Unit:℃ |  
18 | The lower limit of temperature | lower_temp | Issue and report | value | Values range:50-150,Pitch:10,Unit:℃ |  
28 | Factory data reset | factory_reset | Issue and report | bool |   |  
32 | Sensor selection | sensor_choose | Issue and report | enum | Enumerated values:In,Al,Ou |  
34 | Set temperature ceiling | upper_temp | Issue and report | value | Values range:350-450,Pitch:10,Unit:℃ |  
39 | Child lock | child_lock | Issue and report | bool |   |  
47 | State of the valve | valve_state | Only report | enum | Enumerated values:open,close |  
48 | Backlight brightness | backlight | Issue and report | value | Values range:0-100,Pitch:1,Unit:% |  
50 | Set temperature | temp_set | Issue and report | value | Values range:50-450,Pitch:5,Unit:℃ |  
101 | Temp Calibration | temp_calibration | Issue and report | value | Values range:-10-10,Pitch:1,Unit:℃ |  
102 | Week Program 13 1 | week_program_13_1 | Issue and report | raw |   |  
103 | Week Program 13 2 | week_program_13_2 | Issue and report | raw |   |  
104 | Week Program 13 3 | week_program_13_3 | Issue and report | raw |   |  
105 | Week Program 13 4 | week_program_13_4 | Issue and report | raw |   |  
106 | Week Program 13 5 | week_program_13_5 | Issue and report | raw |   |  
107 | Week Program 13 6 | week_program_13_6 | Issue and report | raw |   |  
108 | Week Program 13 7 | week_program_13_7 | Issue and report | raw |   |  
109 | Floor temp. | floortemp | Only report | value | Values range:0-900,Pitch:5,Unit:°C |  
110 | Dead zone temp. | deadzonetemp | Issue and report | value | Values range:5-50,Pitch:5,Unit:°C |  
111 | High protect temp. | highprotecttemp | Issue and report | value | Values range:100-700,Pitch:10,Unit:°C |  
112 | Low protection temp. | lowprotecttemp | Issue and report | value | Values range:0-100,Pitch:10,Unit:°C |  
113 | Eco cool temp. | eco_temp | Issue and report | value | Values range:100-300,Pitch:10,Unit:°C |  
114 | Screen Time Set | screen_time_set | Issue and report | enum | Enumerated values:10S,20S,30S,40S,50S,60S |  
115 | Rgblight | rgblight | Issue and report | bool |   |  


For example, the constants - MOESBHT etc. I don’t see how they map to the data points. Can you give some advice on how to modify this quirk?

Look at the v2 quirks in the ZHA quirks repo, most have been changed over now.

Thanks - i did check for V2 and 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!

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:

I’ll re-post this as a separate thread as I think I’m off (my own) topic!