zigpy / zha-device-handlers

ZHA device handlers bridge the functionality gap created when manufacturers deviate from the ZCL specification, handling deviations and exceptions by parsing custom messages to and from Zigbee devices.
Apache License 2.0
696 stars 641 forks source link

[Device Support Request] TS0601 _TZE204_pcdmj88b TRV not showing any entities #2706

Open T0ytoy opened 8 months ago

T0ytoy commented 8 months ago

Problem description

I bought some Zigbee TRVs, they show up in home assistant as TS0601_TZE204_pcdmj88b but although they are pairing, no entity for control or sensor reading is showing up.

Model link for reference: https://fr.aliexpress.com/item/1005006191259938.html?spm=a2g0o.productlist.main.3.2de8kzSokzSoTw&algo_pvid=fc119493-da4b-462c-86bd-2d78585444c8&algo_exp_id=fc119493-da4b-462c-86bd-2d78585444c8-1&pdp_npi=4%40dis%21EUR%2132.60%2114.67%21%21%2132.60%21%21%402103834816991401988737073e38b3%2112000036203052461%21sea%21FR%21769762047%21&curPageLogUid=sWvhl7kLhrPV

I tried some custom quirks I found (for Moes or Zonnsmart TRVs) but obviously nothing good came out of it.

Solution description

I never used or debugged custom quirks before, but I'm willing to provide help if someone needs more information to create a custom quirks for this model. Thanks a lot!

Screenshots/Video

No response

Device signature

Device signature ```json { "node_descriptor": "NodeDescriptor(logical_type=, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=, mac_capability_flags=, manufacturer_code=4417, maximum_buffer_size=66, maximum_incoming_transfer_size=66, server_mask=10752, maximum_outgoing_transfer_size=66, descriptor_capability_field=, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)", "endpoints": { "1": { "profile_id": "0x0104", "device_type": "0x0051", "input_clusters": [ "0x0000", "0x0004", "0x0005", "0xef00" ], "output_clusters": [ "0x000a", "0x0019" ] } }, "manufacturer": "_TZE204_pcdmj88b", "model": "TS0601", "class": "zigpy.device.Device" } ```

Diagnostic information

No response

Logs

No response

Custom quirk

No response

Additional information

No response

T0ytoy commented 8 months ago

For information, a converter was made for this valve for zigbee2mqtt: https://github.com/Koenkk/zigbee2mqtt/issues/19462 Maybe the technical details will make it easier to make a quirk for ZHA too :)

jim-fx commented 8 months ago

Here is the relevant zigbee-herdsmann-converter:

 {
        fingerprint: tuya.fingerprint('TS0601', [
            '_TZE204_pcdmj88b',
        ]),
        model: 'TS0601_thermostat_4',
        vendor: 'TuYa',
        description: 'Thermostatic radiator valve',
        fromZigbee: [tuya.fz.datapoints],
        toZigbee: [tuya.tz.datapoints],
        onEvent: tuya.onEventSetLocalTime,
        configure: tuya.configureMagicPacket,
        exposes: [
            e.child_lock(),
            e.battery(),
            e.battery_low(),
            e.climate()
                .withSetpoint('current_heating_setpoint', 5, 35, 0.5, ea.STATE_SET)
                .withLocalTemperature(ea.STATE)
                .withPreset(['schedule', 'holiday', 'manual', 'comfort', 'eco'])
                .withSystemMode(['off', 'heat'], ea.STATE)
                .withLocalTemperatureCalibration(-3, 3, 1, ea.STATE_SET),
            ...tuya.exposes.scheduleAllDays(ea.STATE_SET, 'HH:MM/C HH:MM/C HH:MM/C HH:MM/C HH:MM/C HH:MM/C'),
            e.holiday_temperature().withValueMin(5).withValueMax(30),
            e.comfort_temperature().withValueMin(5).withValueMax(30),
            e.eco_temperature().withValueMin(5).withValueMax(30),
            e.binary('scale_protection', ea.STATE_SET, 'ON', 'OFF').withDescription('If the heat sink is not fully opened within ' +
                'two weeks or is not used for a long time, the valve will be blocked due to silting up and the heat sink will not be ' +
                'able to be used. To ensure normal use of the heat sink, the controller will automatically open the valve fully every ' +
                'two weeks. It will run for 30 seconds per time with the screen displaying "Ad", then return to its normal working state ' +
                'again.'),
            e.binary('frost_protection', ea.STATE_SET, 'ON', 'OFF').withDescription('When the room temperature is lower than ' +
                '5 °C, the valve opens; when the temperature rises to 8 °C, the valve closes.'),
            e.numeric('error', ea.STATE).withDescription('If NTC is damaged, "Er" will be on the TRV display.'),
            e.binary('boost_heating', ea.STATE_SET, 'ON', 'OFF')
                .withDescription('Boost Heating: the device will enter the boost heating mode.'),
        ],
        meta: {
            tuyaDatapoints: [
                [2, 'preset', tuya.valueConverterBasic.lookup(
                    {'schedule': tuya.enum(0), 'holiday': tuya.enum(1), 'manual': tuya.enum(2), 'comfort': tuya.enum(3), 'eco': tuya.enum(4)})],
                [4, 'current_heating_setpoint', tuya.valueConverter.divideBy10],
                [5, 'local_temperature', tuya.valueConverter.divideBy10],
                [6, 'battery', tuya.valueConverter.raw],
                [7, 'child_lock', tuya.valueConverter.lockUnlock],
                [21, 'holiday_temperature', tuya.valueConverter.divideBy10],
                [24, 'comfort_temperature', tuya.valueConverter.divideBy10],
                [25, 'eco_temperature', tuya.valueConverter.divideBy10],
                [28, 'schedule_monday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(1)],
                [29, 'schedule_tuesday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(2)],
                [30, 'schedule_wednesday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(3)],
                [31, 'schedule_thursday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(4)],
                [32, 'schedule_friday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(5)],
                [33, 'schedule_saturday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(6)],
                [34, 'schedule_sunday', tuya.valueConverter.thermostatScheduleDayMultiDPWithDayNumber(7)],
                [35, 'fault_alarm', tuya.valueConverter.errorOrBatteryLow],
                [36, 'frost_protection', tuya.valueConverter.onOff],
                [37, 'boost_heating', tuya.valueConverter.onOff],
                [39, 'scale_protection', tuya.valueConverter.onOff],
                [47, 'local_temperature_calibration', tuya.valueConverter.localTempCalibration2],
                [49, 'system_mode', tuya.valueConverterBasic.lookup({'off': tuya.enum(0), 'heat': tuya.enum(1)})],
            ],
        },
    },
elf0heart commented 8 months ago

@T0ytoy Did you get any success integrating @jim-fx 's answer in ZHA through a custom quirk ? I am in the same situation, bought it but can not get the sensors using ZHA. Cheers !

T0ytoy commented 8 months ago

@elf0heart I tried tonight to convert from @jim-fx zigbee2mqtt converter to a zha quirk but it seems that is a bit above my skill limit. My quirk loads but no entity appears. As of now the only solution seem to be using zigbee2mqtt :(

elf0heart commented 8 months ago

Thanks for the try : ) I try to gather some information I found through my other research. I was in the same situation with another thermostat valve, topic discussed here : Link. After putting the custom quirk "beca", detailed by @Rofo, @R1DEN , got the 20 entities showing up in ZHA. Not sure if anyone can help more on this topic ? @Rofo, @R1DEN . I understand it takes a substantial amount of time developing such quirks, but could anyone share the method to get there ? Thx ! Would a modification of the ts0601_trv_beca.py file make the trick ? I got scared of the 1000+ lines of code...

GigaDive commented 8 months ago

@T0ytoy @elf0heart I also used the already known tuya quirks but they do not work so far. In this repository: ts0601_trv.py and ts0601_trv_sas.py do load when importing them as a custom quirk. But the this ts0601_valve.py does not work and results in an error. I bought this TRV from AliExpress and it seems to be the same. Any method to solve this issue? Currently, I lack the knowledge of the ZHA / zigpy codebase.

T0ytoy commented 8 months ago

@GigaDive these quirks wouldn't work as they do not define MODELS_INFO for the TVR this thread is about (TS0601_TZE204_pcdmj88b).

For reference this class loads (because I set the signature to match) :

class newTVR(TuyaThermostat):

    signature = {
        MODELS_INFO: [
            ("_TZE204_pcdmj88b", "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,
                    ManufCluster,
                    # SiterwellThermostat,
                    # SiterwellUserInterface,
                    # TuyaPowerConfigurationCluster2AA,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            }
        }
    }

The difficult part is to define which replacement clusters to use, and write respective classes that interpret "tuya format" and convert it to zha-understandable format. That remains a bit out of reach for me, sadly.

Teka101 commented 8 months ago

Hello,

Very experimental patch (and very ugly), just add these lines at the end of file ts0601_trv.py:

from typing import Tuple
from zhaquirks.const import (
    SKIP_CONFIGURATION,
)

PCDM_PRESET = 1026 #OK
PCDM_TARGET_TEMP_ATTR = 516 #OK
PCDM_TEMPERATURE_ATTR = 517 #OK
PCDM_BATTERY_ATTR = 518 #OK
PCDM_CHILD_LOCK_ATTR = 1073 #nop?
PCDM_SYSTEM_MODE_ATTR = 293 #nop?
#1315 ?window_mode?

class PcdmManufTrvCluster(TuyaManufClusterAttributes):
    """Manufacturer Specific Cluster of some thermostatic valves."""

    class Preset(t.enum8):
        """Working modes of the thermostat."""

        Schedule = 0x00
        Away = 0x01
        Manual = 0x02
        Comfort = 0x03
        Eco = 0x04

    set_time_offset = 1970

    attributes = TuyaManufClusterAttributes.attributes.copy()
    attributes.update(
        {
            PCDM_PRESET: ("operation_preset", Preset, True),
            PCDM_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True),
            PCDM_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True),
            PCDM_BATTERY_ATTR: ("battery", t.uint32_t, True),
            PCDM_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True),
            PCDM_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True),
        }
    )

    TEMPERATURE_ATTRS = {
        PCDM_TARGET_TEMP_ATTR: "occupied_heating_setpoint",
        PCDM_TEMPERATURE_ATTR: "local_temperature",
    }

    def handle_cluster_request(
        self,
        hdr: foundation.ZCLHeader,
        args: Tuple,
        *,
        dst_addressing: Optional[
            Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK]
        ] = None,
    ) -> None:
        _LOGGER.debug(
            "handle_cluster_request: [0x%04x:%s:0x%04x] Received value (command 0x%04x)",
            self.endpoint.device.nwk,
            self.endpoint.endpoint_id,
            self.cluster_id,
            hdr.command_id,
        )
        _LOGGER.debug('%d # %s', len(args), str(args))
        return super().handle_cluster_request(hdr, args, dst_addressing=dst_addressing)

    async def write_attributes(self, attributes, manufacturer=None):
        _LOGGER.debug('write_attributes %s', str(attributes))
        return await super().write_attributes(attributes, manufacturer)

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)
        if attrid in self.TEMPERATURE_ATTRS:
            self.endpoint.device.thermostat_bus.listener_event(
                "temperature_change",
                self.TEMPERATURE_ATTRS[attrid],
                value * 10,  # decidegree to centidegree
            )
        elif attrid == PCDM_CHILD_LOCK_ATTR:
            mode = 1 if value else 0
            self.endpoint.device.ui_bus.listener_event("child_lock_change", mode)
        elif attrid == PCDM_BATTERY_ATTR:
            self.endpoint.device.battery_bus.listener_event("battery_change", value)
        elif attrid == PCDM_SYSTEM_MODE_ATTR:
            self.endpoint.device.thermostat_bus.listener_event("mode_change", value)

class PcdmThermostat(TuyaThermostatCluster):
    """Thermostat cluster for some thermostatic valves."""

    def map_attribute(self, attribute, value):
        _LOGGER.info(f'map_attribute: attribute={attribute} value={value}')
        if attribute == "occupied_heating_setpoint":
            # centidegree to decidegree
            return {PCDM_TARGET_TEMP_ATTR: round(value / 10)}
        if attribute == "local_temperature":
            # centidegree to decidegree
            return {PCDM_TEMPERATURE_ATTR: round(value / 10)}
        if attribute in ("system_mode", "programing_oper_mode"):
            if attribute == "system_mode":
                system_mode = value
                oper_mode = self._attr_cache.get(
                    self.attributes_by_name["programing_oper_mode"].id,
                    self.ProgrammingOperationMode.Simple,
                )
            else:
                system_mode = self._attr_cache.get(
                    self.attributes_by_name["system_mode"].id, self.SystemMode.Heat
                )
                oper_mode = value
            if system_mode == self.SystemMode.Off:
                return {PCDM_SYSTEM_MODE_ATTR: 0}
            if system_mode == self.SystemMode.Heat:
                return {PCDM_SYSTEM_MODE_ATTR: 1}
            else:
                self.error("Unsupported value for SystemMode")

    def mode_change(self, value):
        """System Mode change."""
        if value == 0:
            self._update_attribute(
                self.attributes_by_name["system_mode"].id, self.SystemMode.Off
            )
            return

        if value == 1:
            mode = self.ProgrammingOperationMode.Schedule_programming_mode
        else:
            mode = self.ProgrammingOperationMode.Simple

        self._update_attribute(
            self.attributes_by_name["system_mode"].id, self.SystemMode.Heat
        )
        self._update_attribute(self.attributes_by_name["programing_oper_mode"].id, mode)

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

    _CHILD_LOCK_ATTR = PCDM_CHILD_LOCK_ATTR

class PcdmTrv(TuyaThermostat):
    """PCDRM Thermostatic radiator valve"""

    def __init__(self, *args, **kwargs):
        """Init device."""
        # self.window_detection_bus = Bus()
        super().__init__(*args, **kwargs)

    signature = {
        #  endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184]
        #  output_clusters=[10, 25]>
        MODELS_INFO: [
            ("_TZE204_pcdmj88b", "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 = {
#        SKIP_CONFIGURATION: True,
        ENDPOINTS: {
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.THERMOSTAT,
#                DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    PcdmManufTrvCluster,
                    PcdmThermostat,
                    #TODO PcdmUserInterface,
                    #TODO MoesWindowDetection
                    TuyaPowerConfigurationCluster2AA,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            }
        }
    }
elf0heart commented 8 months ago

Thanks for looking at the issue @Teka101 . I incorporated the few lines of code in the quirk, it's getting better, got the battery as a sensor, as well as the thermostat, see printscreens below. However, seems there is still a lot of entities missing. It seems that the thermostat card can only 'reads' what the physical TRV is showing. At the moment, I can not set the temperature for instance or get the % opening of the TRV. THanks anyways, this is good progress !

image image

Teka101 commented 8 months ago

@elf0heart yes it's only work for reading... i'm working on it 💪

Teka101 commented 8 months ago

Now reading is in better shape but i'm unable to find why i can't change mode or temperature :(

Code is available at : https://github.com/Teka101/zha-device-handlers/blob/support_tze204_pcdmj88b/zhaquirks/tuya/ts0601_trv_tze204.py

medivel commented 8 months ago

Thank you for your efforts so far! It's a pity that it is so hard to get it work. Looking forward that you'll get it done!

elf0heart commented 8 months ago

Thanks for trying @Teka101 , indeed every time the temperature is changed on the thermostat card, the heating setpoint comes back to what it was previously. Can't help much, sorry...

Teka101 commented 8 months ago

@elf0heart ok i can change temperature and preset now. Can you try if it's okay on your installation ?

elf0heart commented 7 months ago

@Teka101 , just tried. I confirm that the heating setpoint can now be set through home assistant, works in writing now ! Thanks for that. However the 'switch', whenever turned on in home assistant, comes back to 'off' a few seconds later. FOr my usage I do not mind, since I am using each TRV with the scheduler custom card (i always use it in 'heating mode', by just changing the heating setpoints from 16 to 20 °C for instaance). When pairing the TRV this time though, didn't get the usual screen with "3 entities" have been found. But when going to ZHA, devices, the TRV appeared well. The main function is therefore working. (i would still be interested in having a few more entities, like "% opening" of the TRV). Cheers ! screen1

Teka101 commented 7 months ago

@elf0heart switch is for "boost mode" but it doesn't work yet :( And if i'm right the TRV doesn't report valve state (it's only open or close...)

elf0heart commented 7 months ago

@Teka101 , yes you are right, this model is only off or on, didn't realize that -> they are less accurate than the previous ones I bought (TS0601 _TZE200_b6wax7g0). User manual states that the following function are availabe : child lock, AF antifreeze mode->set to 8°C the temperature, BS: quick heat->TRV full open for 5 min, CC : offset temperature -> to adjust internal temperature sensor, EE: blind spot ->adjust heating point by offseting, DP: open window detection, HS : thermal stop -> fully closed. To me, all of those functions are useless when using scheduler card. Those TRV are now fully working thanks to your hard work, thanks again. The only cosmetic improvement which, if possible, could be made, is to set the min/max range for the heating setpoint. See screenshot below, the 3 first TRV cards are from the TZE200_b6wax7g0 variant, the last one with the TZE204_pcdmj88b variant with the discussed quirk. We see the scale is not the same, althouth the heating setpoint the same.

image

T0ytoy commented 7 months ago

@Teka101 well, that is outstanding, thank you for your work :) I added your quirk to my hass instance and am testing it for a few days. So far it seems to work pretty well. Preset don't seem to be selectable from home assistant (only through the physical button on the TVR)

I have another of those TRV working with zigbee2mqtt at the same time, the implementation has a few more entities available as shown below:

image

I would say the most important ones are local temperature calibration (to tweak internal temperature sensor value) and maybe preset target temperature (only default values are available right now).

Again, thank you very much for your work, and please let me know if you're interested in any details regarding those TVR using Z2M.

T0ytoy commented 7 months ago

It seems preset selection and their temperature is easily accessible through cluster 0xef00 👍

Screenshot_20231205_001646_Home Assistant

I wasn't however able to change the value of the local_temperature_calibration attribute (0x0010) in cluster 0x0201: no effect on reported local temperature (0x0000)

Teka101 commented 7 months ago

@elf0heart yes there is more to do :)

@T0ytoy preset can be change with group PcdmThermostat

At this time, you can only change:

And in read only, we have :

I'm still working on it

PS: je vois des français partout ^_^

T0ytoy commented 7 months ago

@Teka101 how did you find out attributes identifiers? Ex:

PCDM_PRESET = 1026 #                010000 000010 2
PCDM_TARGET_TEMP_ATTR = 516 #       001000 000100 4
PCDM_TEMPERATURE_ATTR = 517 #       001000 000101 5
PCDM_BATTERY_ATTR = 518 #           001000 000110 6

I'm trying to add temperature_calibration, this is the value I guessed:

PCDM_TEMPERATURE_CORRECTION_ATTR = 559 # 001000 101111 47

I added it to attributes.update in PcdmManufTrvCluster and added:

elif attrid == PCDM_TEMPERATURE_CORRECTION_ATTR:
            _LOGGER.debug("update attribute calibration")
            self.endpoint.device.thermostat_bus.listener_event("temperature_correction_change", value)

at the end of the _update_attribute() method but it doesn't seem to od anything.

If you have any clue, I would love to hear about it :)

Teka101 commented 7 months ago

Hello @T0ytoy

You need to add entry in attributes (variable attributes):

PCDM_TEMPERATURE_CORRECTION_ATTR: ("temperature_calibration", t.int32s, True),

Maybe you need to divide by 10 temperature before send it, i don't know...

If still doesn't work maybe the prefix 512 applied on PCDM_TEMPERATURE_CORRECTION_ATTR is not the good one... At this time, i don't know when apply prefix 256,512 or 1024...

T0ytoy commented 7 months ago

I think i did already:

class PcdmManufTrvCluster(TuyaManufClusterAttributes):
    """Manufacturer Specific Cluster of some thermostatic valves."""

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        global PcdmManuClusterSelf
        PcdmManuClusterSelf = self

    set_time_offset = 1970

    attributes = TuyaManufClusterAttributes.attributes.copy()
    attributes.update(
        {
            PCDM_PRESET: ("operation_preset", t.uint8_t, True),
            PCDM_BATTERY_ATTR: ("battery", t.uint32_t, True),
            PCDM_BATTERY_LOW_ATTR: ("battery_low", t.uint8_t, True),
            PCDM_BOOST_MODE: ("boost_duration_seconds", t.uint32_t, True),
            PCDM_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True),
            PCDM_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True),
            PCDM_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True),
            PCDM_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True),
            PCDM_TEMPERATURE_CORRECTION_ATTR: ("temperature_correction", t.int8s),

            PCDM_TARGET_MANUAL_ATTR: ("occupied_heating_setpoint", t.uint32_t, True),
            PCDM_TARGET_CONFORT_ATTR: ("comfort_heating_setpoint", t.uint32_t, True),
            PCDM_TARGET_ECO_ATTR: ("eco_heating_setpoint", t.uint32_t, True),
        }
    )

I'll try different prefixes, thx.

Teka101 commented 7 months ago

@T0ytoy when i look at other quirks, temperature_calibration is type t.int32s

T0ytoy commented 7 months ago

@Teka101 well it seems it did the trick, I have access to the calibration value now! it doesn't appear as an entity yet though, it only works through zha interface "write attribute", but it works! I tried to limit the range of value you cane use from -12 to +12 calibration = -12 if value < -12 else 12 if value > 12 else value but it doens't seem to work, I don't understand why.

Here is the converter I'm testing:

import logging
from typing import Optional, Tuple, Union

from zigpy.profiles import zha
import zigpy.types as t
from zigpy.zcl import foundation
from zigpy.zcl.clusters.general import (
    AnalogOutput,
    Basic,
    BinaryInput,
    Groups,
    OnOff,
    Ota,
    Scenes,
    Time,
)

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

_LOGGER = logging.getLogger(__name__)

# MQTT
# {
#   "2": "Mode",
#   "4": "Set temperature",
#   "5": "Current temperature",
#   "6": "Battery capacity",
#   "7": "Child lock",
#   "8": "Temperature scale",
#   "9": "Set temperature ceiling",
#   "10": "The lower limit of temperature",
#   "14": "Window check",
#   "16": "Window temp",
#   "17": "Window time",
#   "18": "Backlight brightness",
#   "19": "Factory data reset",
#   "21": "Holiday temperature",
#   "24": "Home temp", || comfort_temperature
#   "25": "Leave temp", || eco_temperature
#   "28": "Week program",
#   "29": "Week program Tuesday",
#   "30": "Week program Wednesday",
#   "31": "Week program Thursday",
#   "32": "Week program Friday",
#   "33": "Week program Saturday",
#   "34": "Week program Sunday",
#   "35": "Fault alarm",
#   "36": "Frost protection",
#   "37": "Rapid warming",
#   "38": "Rapid heating countdown",
#   "39": "Switch Scale",
#   "47": "Temperature correction",
#   "48": "Valve testing",
#   "49": "State of the valve",
#   "101": "111"
# }

#                                   010000 000000 = 0x400 | 1024
#                                   001000 000000 = 0x200 | 512
#                                   000000 111111 0x3F | 63
PCDM_PRESET = 1026 #                010000 000010 2
PCDM_TARGET_TEMP_ATTR = 516 #       001000 000100 4
PCDM_TEMPERATURE_ATTR = 517 #       001000 000101 5
PCDM_BATTERY_ATTR = 518 #           001000 000110 6
PCDM_CHILD_LOCK_ATTR = 263 #        000100 000111 7
PCDM_BATTERY_LOW_ATTR = 1315 #nop?  010100 100011 35
PCDM_SYSTEM_MODE_ATTR = 1073 #      010000 110001 49
PCDM_TEMPERATURE_CORRECTION_ATTR = 559  # 001000 101111 47
#
PCDM_TARGET_MANUAL_ATTR = 512+ 4
PCDM_TARGET_HOLIDAY_ATTR = 21
PCDM_TARGET_CONFORT_ATTR = 536#try 001000 011000 24
PCDM_TARGET_ECO_ATTR = 537#try   001000 011001 25 ## 35=NOP !
PCDM_BOOST_MODE = 293 #nop?         000100 100101 37

PcdmManuClusterSelf = None

class PcdmManufTrvCluster(TuyaManufClusterAttributes):
    """Manufacturer Specific Cluster of some thermostatic valves."""

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        global PcdmManuClusterSelf
        PcdmManuClusterSelf = self

    set_time_offset = 1970

    attributes = TuyaManufClusterAttributes.attributes.copy()
    attributes.update(
        {
            PCDM_PRESET: ("operation_preset", t.uint8_t, True),
            PCDM_BATTERY_ATTR: ("battery", t.uint32_t, True),
            PCDM_BATTERY_LOW_ATTR: ("battery_low", t.uint8_t, True),
            PCDM_BOOST_MODE: ("boost_duration_seconds", t.uint32_t, True),
            PCDM_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True),
            PCDM_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True),
            PCDM_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True),
            PCDM_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True),
            PCDM_TEMPERATURE_CORRECTION_ATTR: ("temperature_correction", t.int32s, True),

            PCDM_TARGET_MANUAL_ATTR: ("occupied_heating_setpoint", t.uint32_t, True),
            PCDM_TARGET_CONFORT_ATTR: ("comfort_heating_setpoint", t.uint32_t, True),
            PCDM_TARGET_ECO_ATTR: ("eco_heating_setpoint", t.uint32_t, True),
        }
    )

    TEMPERATURE_ATTRS = {
        PCDM_TARGET_TEMP_ATTR: "occupied_heating_setpoint",
        PCDM_TARGET_CONFORT_ATTR: "comfort_heating_setpoint",
        PCDM_TARGET_ECO_ATTR: "eco_heating_setpoint",
        PCDM_TEMPERATURE_ATTR: "local_temperature",
    }

    def handle_cluster_request(
        self,
        hdr: foundation.ZCLHeader,
        args: Tuple,
        *,
        dst_addressing: Optional[
            Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK]
        ] = None,
    ) -> None:
        _LOGGER.debug(
            "handle_cluster_request: [0x%04x:%s:0x%04x] Received value (command 0x%04x)",
            self.endpoint.device.nwk,
            self.endpoint.endpoint_id,
            self.cluster_id,
            hdr.command_id,
        )
        _LOGGER.debug('%d # %s', len(args), str(args))
        return super().handle_cluster_request(hdr, args, dst_addressing=dst_addressing)

    async def write_attributes(self, attributes, manufacturer=None):
        return await super().write_attributes(attributes, manufacturer=foundation.ZCLHeader.NO_MANUFACTURER_ID)

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)
        if attrid in self.TEMPERATURE_ATTRS:
            self.endpoint.device.thermostat_bus.listener_event(
                "temperature_change",
                self.TEMPERATURE_ATTRS[attrid],
                value * 10,  # decidegree to centidegree
            )
        elif attrid == PCDM_BATTERY_ATTR:
            self.endpoint.device.battery_bus.listener_event("battery_change", value)
        elif attrid == PCDM_BATTERY_LOW_ATTR and value > 0:
            self.endpoint.device.battery_bus.listener_event("battery_change", 5)
        elif attrid == PCDM_BOOST_MODE:
            self.endpoint.device.boost_bus.listener_event("set_change", 1 if value > 0 else 0)
        elif attrid == PCDM_CHILD_LOCK_ATTR:
            self.endpoint.device.ui_bus.listener_event("child_lock_change", 1 if value > 0 else 0)
        elif attrid == PCDM_PRESET:
            self.endpoint.device.thermostat_bus.listener_event("program_change", value)
        elif attrid == PCDM_SYSTEM_MODE_ATTR:
            self.endpoint.device.thermostat_bus.listener_event("mode_change", value)
        elif attrid == PCDM_TEMPERATURE_CORRECTION_ATTR:
            self.endpoint.device.thermostat_bus.listener_event("temperature_correction_change", value)

class PcdmThermostat(TuyaThermostatCluster):
    """Thermostat cluster for some thermostatic valves."""

    class Preset(t.enum8):
        """Working modes of the thermostat."""

        Schedule = 0x00
        Away = 0x01
        Manual = 0x02
        Comfort = 0x03
        Eco = 0x04

    attributes = TuyaThermostatCluster.attributes.copy()
    attributes.update(
        {
            PCDM_PRESET: ("operation_preset", Preset, True),
        }
    )

    def map_attribute(self, attribute, value):
        _LOGGER.info(f'map_attribute: attribute={attribute} value={value}')
        if attribute == "occupied_heating_setpoint":
            active_preset = self._attr_cache.get(
                    self.attributes_by_name["operation_preset"].id,
                    self.ProgrammingOperationMode.Simple,
                )
            attrid = PCDM_TARGET_TEMP_ATTR
            # attrid = PCDM_TARGET_MANUAL_ATTR #TODO missing Preset.Schedule
            # if active_preset == self.Preset.Away:
            #     attrid = PCDM_TARGET_HOLIDAY_ATTR
            # elif active_preset == self.Preset.Manual:
            #     attrid = PCDM_TARGET_MANUAL_ATTR
            # elif active_preset == self.Preset.Comfort:
            #     attrid = PCDM_TARGET_CONFORT_ATTR
            # elif active_preset == self.Preset.Eco:
            #     attrid = PCDM_TARGET_ECO_ATTR
            _LOGGER.info(f'map_attribute: attribute={attribute} active_preset={active_preset} => {attrid}')
            # centidegree to decidegree
            return {attrid: round(value / 10)}
        if attribute == "local_temperature":
            # centidegree to decidegree
            return {PCDM_TEMPERATURE_ATTR: round(value / 10)}
        if attribute == "system_mode":#, "programing_oper_mode"):
            if attribute == "system_mode":
                system_mode = value
                # oper_mode = self._attr_cache.get(
                #     self.attributes_by_name["programing_oper_mode"].id,
                #     self.ProgrammingOperationMode.Simple,
                # )
            else:
                system_mode = self._attr_cache.get(
                    self.attributes_by_name["system_mode"].id, self.SystemMode.Heat
                )
                # oper_mode = value
            if system_mode == self.SystemMode.Off:
                return {PCDM_SYSTEM_MODE_ATTR: 0}
            if system_mode == self.SystemMode.Heat:
                return {PCDM_SYSTEM_MODE_ATTR: 1}
            else:
                self.error("Unsupported value for SystemMode")
        if attribute == "programing_oper_mode":
            if value == self.ProgrammingOperationMode.Schedule_programming_mode:
                return {PCDM_PRESET: self.Preset.Schedule.value}
            if value == self.ProgrammingOperationMode.Simple:
                return {PCDM_PRESET: self.Preset.Manual.value}
            if value == self.ProgrammingOperationMode.Economy_mode:
                return {PCDM_PRESET: self.Preset.Eco.value}
        if attribute == "operation_preset":
            return {PCDM_PRESET: value.value}
        if attribute == "temperature_correction":
            calibration = -12 if value < -12 else 12 if value > 12 else value
            return {PCDM_TEMPERATURE_CORRECTION_ATTR: calibration}

    def temperature_correction_change(self, value):
        calibration = -12 if value < -12 else 12 if value > 12 else value
        self._update_attribute(self.attributes_by_name["temperature_correction"].id, calibration)

    def mode_change(self, value):
        """System Mode change."""
        _LOGGER.error(f'mode_change value [{value}]')
        # mode = self.SystemMode.Off if value == 0 else self.SystemMode.Heat
        # self._update_attribute(self.attributes_by_name["system_mode"].id, mode)
        self._update_attribute(self.attributes_by_name["system_mode"].id, self.SystemMode.Heat)
        if value == 0:
            mode = self.RunningMode.Off
            state = self.RunningState.Idle
        else:
            mode = self.RunningMode.Heat
            state = self.RunningState.Heat_State_On
        self._update_attribute(self.attributes_by_name["running_mode"].id, mode)
        self._update_attribute(self.attributes_by_name["running_state"].id, state)

    def program_change(self, value):
        """Programming mode change."""
        operation_preset = None
        prog_mode = None
        if value == 0:
            prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode
            operation_preset = self.Preset.Schedule
        elif value == 1:
            prog_mode = self.ProgrammingOperationMode.Simple
            operation_preset = self.Preset.Away
        elif value == 2:
            prog_mode = self.ProgrammingOperationMode.Simple
            operation_preset = self.Preset.Manual
        elif value == 3:
            prog_mode = self.ProgrammingOperationMode.Simple
            operation_preset = self.Preset.Comfort
        elif value == 4:
            prog_mode = self.ProgrammingOperationMode.Economy_mode
            operation_preset = self.Preset.Eco
        else:
            self.error("Unsupported value for Mode")
        _LOGGER.info(f'program_change PRESET value [{value}] {prog_mode} {operation_preset}')

        if operation_preset is not None:
            self._update_attribute(self.attributes_by_name["operation_preset"].id, operation_preset)
            self._update_attribute(self.attributes_by_name["programing_oper_mode"].id, prog_mode)
            self._update_attribute(self.attributes_by_name["system_mode"].id, self.SystemMode.Heat)

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

    _CHILD_LOCK_ATTR = PCDM_CHILD_LOCK_ATTR

class PcdmHelperOnOff(LocalDataCluster, OnOff):
    """Helper OnOff cluster for various functions controlled by switch."""

    def set_change(self, value):
        """Set new OnOff value."""
        self._update_attribute(self.attributes_by_name["on_off"].id, value)

    def get_attr_val_to_write(self, value):
        """Return dict with attribute and value for thermostat."""
        return None

    async def write_attributes(self, attributes, manufacturer=None):
        """Defer attributes writing to the set_data tuya command."""
        records = self._write_attr_records(attributes)
        if not records:
            return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]]

        has_change = False
        for record in records:
            attr_name = self.attributes[record.attrid].name
            if attr_name == "on_off":
                value = record.value.value
                has_change = True

        if has_change:
            attr_val = self.get_attr_val_to_write(value)
            if attr_val is not None:
                # global self in case when different endpoint has to exist
                return await PcdmManuClusterSelf.endpoint.tuya_manufacturer.write_attributes(
                    attr_val, manufacturer=manufacturer
                )

        return [
            [
                foundation.WriteAttributesStatusRecord(
                    foundation.Status.FAILURE, r.attrid
                )
                for r in records
            ]
        ]

    async def command(
        self,
        command_id: Union[foundation.GeneralCommand, int, t.uint8_t],
        *args,
        manufacturer: Optional[Union[int, t.uint16_t]] = None,
        expect_reply: bool = True,
        tsn: Optional[Union[int, t.uint8_t]] = None,
    ):
        """Override the default Cluster command."""

        if command_id in (0x0000, 0x0001, 0x0002):
            if command_id == 0x0000:
                value = False
            elif command_id == 0x0001:
                value = True
            else:
                attrid = self.attributes_by_name["on_off"].id
                success, _ = await self.read_attributes(
                    (attrid,), manufacturer=manufacturer
                )
                try:
                    value = success[attrid]
                except KeyError:
                    return foundation.GENERAL_COMMANDS[
                        foundation.GeneralCommand.Default_Response
                    ].schema(command_id=command_id, status=foundation.Status.FAILURE)
                value = not value
            _LOGGER.debug("CALLING WRITE FROM COMMAND")
            (res,) = await self.write_attributes(
                {"on_off": value},
                manufacturer=manufacturer,
            )
            return foundation.GENERAL_COMMANDS[
                foundation.GeneralCommand.Default_Response
            ].schema(command_id=command_id, status=res[0].status)

        return foundation.GENERAL_COMMANDS[
            foundation.GeneralCommand.Default_Response
        ].schema(command_id=command_id, status=foundation.Status.UNSUP_CLUSTER_COMMAND)

class PcdmBoost(PcdmHelperOnOff):
    """On/Off cluster for the boost function of the heating thermostats."""

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self.endpoint.device.boost_bus.add_listener(self)

    def get_attr_val_to_write(self, value):
        """Return dict with attribute and value for boot mode."""
        return {PCDM_BOOST_MODE: 299 if value else 0}

class PcdmWindowDetection(PcdmHelperOnOff):
    """On/Off cluster for the window detection function of the electric heating thermostats."""

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self.endpoint.device.boost_bus.add_listener(self)

    def get_attr_val_to_write(self, value):
        """Return dict with attribute and value for boot mode."""
        return {PCDM_BOOST_MODE: value}

class PcdmTrv(TuyaThermostat):
    """PCDRM Thermostatic radiator valve"""

    def __init__(self, *args, **kwargs):
        """Init device."""
        self.boost_bus = Bus()
        self.window_detection_bus = Bus()
        super().__init__(*args, **kwargs)

    signature = {
        #  endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184]
        #  output_clusters=[10, 25]>
        MODELS_INFO: [
            ("_TZE204_pcdmj88b", "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,
                    PcdmManufTrvCluster,
                    PcdmBoost,
                    PcdmThermostat,
                    PcdmUserInterface,
                    # PcdmWindowDetection,
                    TuyaPowerConfigurationCluster2AA,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            }
        }
    }

###

I'm still wondering how to make it show up as an entity, but there is progress!

Teka101 commented 7 months ago

@T0ytoy ok thank you for your code.

I just publish a new version with: window detection mode and temperature calibration

T0ytoy commented 7 months ago

@Teka101 I was finally able to test you update, it seems to work thank you! In the home assistant zha UI, boost mode and window detection mode are both switches without a name, so it isn't clear which does what at first glance, but it's not a big problem.

If I have some time this week-end, I'll try to implement the right class so that temprature calibration gets it's own numeric entity in home assistant, so that it can be used easily.

I use the calibration feature (it has an entity on the z2m integration) to correct the device internal temperature to the temperature of an external zigbee thermometer: that way when the radiator heats up, the TVR temperature does not increase just because it's too close to the radiator. It is done home assistant side, that is why I'm deseperately trying to get that entity :D

I'll let you know if I'm getting anything done. Thank you!

royduin commented 7 months ago

Nice work @Teka101! Curious; why didn't you create a PR or is it not fully ready yet? For now I'm using the custom quirk

T0ytoy commented 7 months ago

@Teka101 good news, I was able to make the temperature offset (or calibration) have it's own entity in Home Assistant. It seems to work well enough, and was the feature I wanted the most :)

Here is the code :

code ``` import logging from typing import Optional, Tuple, Union from zigpy.profiles import zha import zigpy.types as t from zigpy.zcl import foundation from zigpy.zcl.clusters.general import ( AnalogOutput, Basic, BinaryInput, Groups, OnOff, Ota, Scenes, Time, ) from zhaquirks import Bus, LocalDataCluster from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, INPUT_CLUSTERS, MODELS_INFO, OUTPUT_CLUSTERS, PROFILE_ID, ) from zhaquirks.tuya import ( TuyaManufClusterAttributes, TuyaPowerConfigurationCluster2AA, TuyaThermostat, TuyaThermostatCluster, TuyaUserInterfaceCluster, TUYA_DP_TYPE_BOOL, TUYA_DP_TYPE_VALUE, TUYA_DP_TYPE_ENUM, TUYA_DP_TYPE_FAULT ) _LOGGER = logging.getLogger(__name__) # Features | Implemented # ------------------------------------------|------------ # - preset: | # * manual mode | yes # * holiday mode | yes # * eco mode | yes # * programming mode | partialy => calendar cannot be change # * comfort mode | yes # - child lock | yes # - AF antifreeze mode | no # -> set to 8°C the temperature | # - BS: quick heat | no # -> TRV full open for 5 min | # - CC : offset temperature | # -> to adjust internal sensor temp. | yes # - EE: blind spot | no # -> adjust heating point by offseting | # - DP: open window detection | yes # - HS : thermal stop -> fully closed | no # MQTT # { # "2": "Mode", # "4": "Set temperature", # "5": "Current temperature", # "6": "Battery capacity", # "7": "Child lock", # "8": "Temperature scale", # "9": "Set temperature ceiling", # "10": "The lower limit of temperature", # "14": "Window check", # "16": "Window temp", # "17": "Window time", # "18": "Backlight brightness", # "19": "Factory data reset", # "21": "Holiday temperature", # "24": "Home temp", || comfort_temperature # "25": "Leave temp", || eco_temperature # "28": "Week program", # "29": "Week program Tuesday", # "30": "Week program Wednesday", # "31": "Week program Thursday", # "32": "Week program Friday", # "33": "Week program Saturday", # "34": "Week program Sunday", # "35": "Fault alarm", # "36": "Frost protection", # "37": "Rapid warming", # "38": "Rapid heating countdown", # "39": "Switch Scale", # "47": "Temperature correction", # "48": "Valve testing", # "49": "State of the valve", # } PCDM_PRESET = TUYA_DP_TYPE_ENUM + 2 # 1026 PCDM_TARGET_TEMP_ATTR = TUYA_DP_TYPE_VALUE + 4 # 516 PCDM_TEMPERATURE_ATTR = TUYA_DP_TYPE_VALUE + 5 # 517 PCDM_BATTERY_ATTR = TUYA_DP_TYPE_VALUE + 6 # 518 PCDM_CHILD_LOCK_ATTR = TUYA_DP_TYPE_BOOL + 7 # 519 PCDM_WINDOW_MODE_ATTR = TUYA_DP_TYPE_BOOL + 14 # 270 PCDM_BATTERY_LOW_ATTR = TUYA_DP_TYPE_FAULT + 35 # 1315 PCDM_TEMPERATURE_CORRECTION_ATTR = TUYA_DP_TYPE_VALUE + 47 # 559 PCDM_SYSTEM_MODE_ATTR = TUYA_DP_TYPE_ENUM + 49 # 1073 # PCDM_TARGET_MANUAL_ATTR = TUYA_DP_TYPE_VALUE + 4 PCDM_TARGET_HOLIDAY_ATTR = TUYA_DP_TYPE_VALUE + 21 PCDM_TARGET_CONFORT_ATTR = TUYA_DP_TYPE_VALUE + 24 PCDM_TARGET_ECO_ATTR = TUYA_DP_TYPE_VALUE + 25 PCDM_FROST_PROTECT = TUYA_DP_TYPE_BOOL + 36 # 292 PCDM_BOOST_MODE = TUYA_DP_TYPE_BOOL + 37 # 293 PcdmManuClusterSelf = None class PcdmManufTrvCluster(TuyaManufClusterAttributes): """Manufacturer Specific Cluster of some thermostatic valves.""" def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) global PcdmManuClusterSelf PcdmManuClusterSelf = self set_time_offset = 1970 attributes = TuyaManufClusterAttributes.attributes.copy() attributes.update( { PCDM_PRESET: ("operation_preset", t.uint8_t, True), PCDM_BATTERY_ATTR: ("battery", t.uint32_t, True), PCDM_BATTERY_LOW_ATTR: ("battery_low", t.uint8_t, True), PCDM_BOOST_MODE: ("boost_duration_seconds", t.uint32_t, True), PCDM_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True), PCDM_FROST_PROTECT: ("frost_protection", t.uint8_t, True), PCDM_WINDOW_MODE_ATTR: ("window_detection", t.uint8_t, True), PCDM_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True), PCDM_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True), PCDM_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True), PCDM_TEMPERATURE_CORRECTION_ATTR: ("temperature_calibration", t.int32s, True), PCDM_TARGET_MANUAL_ATTR: ("occupied_heating_setpoint", t.uint32_t, True), PCDM_TARGET_CONFORT_ATTR: ("comfort_heating_setpoint", t.uint32_t, True), PCDM_TARGET_ECO_ATTR: ("eco_heating_setpoint", t.uint32_t, True), } ) TEMPERATURE_ATTRS = { PCDM_TARGET_TEMP_ATTR: "occupied_heating_setpoint", PCDM_TARGET_CONFORT_ATTR: "comfort_heating_setpoint", PCDM_TARGET_ECO_ATTR: "eco_heating_setpoint", PCDM_TEMPERATURE_ATTR: "local_temperature", } def handle_cluster_request( self, hdr: foundation.ZCLHeader, args: Tuple, *, dst_addressing: Optional[ Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK] ] = None, ) -> None: _LOGGER.debug( "handle_cluster_request: [0x%04x:%s:0x%04x] Received value (command 0x%04x)", self.endpoint.device.nwk, self.endpoint.endpoint_id, self.cluster_id, hdr.command_id, ) _LOGGER.debug('%d # %s', len(args), str(args)) return super().handle_cluster_request(hdr, args, dst_addressing=dst_addressing) async def write_attributes(self, attributes, manufacturer=None): return await super().write_attributes(attributes, manufacturer=foundation.ZCLHeader.NO_MANUFACTURER_ID) def _update_attribute(self, attrid, value): super()._update_attribute(attrid, value) if attrid in self.TEMPERATURE_ATTRS: self.endpoint.device.thermostat_bus.listener_event( "temperature_change", self.TEMPERATURE_ATTRS[attrid], value * 10, # decidegree to centidegree ) elif attrid == PCDM_BATTERY_ATTR: self.endpoint.device.battery_bus.listener_event("battery_change", value) elif attrid == PCDM_BATTERY_LOW_ATTR and value > 0: self.endpoint.device.battery_bus.listener_event("battery_change", 5) elif attrid == PCDM_BOOST_MODE: self.endpoint.device.boost_bus.listener_event("set_change", 300 if value > 0 else 0) elif attrid == PCDM_CHILD_LOCK_ATTR: self.endpoint.device.ui_bus.listener_event("child_lock_change", 1 if value > 0 else 0) elif attrid == PCDM_WINDOW_MODE_ATTR: self.endpoint.device.window_detection_bus.listener_event("set_value", value) elif attrid == PCDM_PRESET: self.endpoint.device.thermostat_bus.listener_event("program_change", value) elif attrid == PCDM_FROST_PROTECT and value == 1: self.endpoint.device.thermostat_bus.listener_event("program_change", 5) elif attrid == PCDM_SYSTEM_MODE_ATTR: self.endpoint.device.thermostat_bus.listener_event("mode_change", value) elif attrid == PCDM_TEMPERATURE_CORRECTION_ATTR: self.endpoint.device.thermostat_bus.listener_event("temperature_calibration_change", value) class PcdmThermostat(TuyaThermostatCluster): """Thermostat cluster for some thermostatic valves.""" class Preset(t.enum8): """Working modes of the thermostat.""" Schedule = 0x00 Away = 0x01 Manual = 0x02 Comfort = 0x03 Eco = 0x04 FrostProtect = 0x05 attributes = TuyaThermostatCluster.attributes.copy() attributes.update( { PCDM_PRESET: ("operation_preset", Preset, True), } ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.endpoint.device.thermostat_bus.listener_event("temperature_change", "min_heat_setpoint_limit", 500) self.endpoint.device.thermostat_bus.listener_event("temperature_change", "max_heat_setpoint_limit", 4500) def map_attribute(self, attribute, value): _LOGGER.info(f'map_attribute: attribute={attribute} value={value}') if attribute == "occupied_heating_setpoint": # centidegree to decidegree return {PCDM_TARGET_TEMP_ATTR: round(value / 10)} if attribute == "local_temperature": # centidegree to decidegree return {PCDM_TEMPERATURE_ATTR: round(value / 10)} if attribute == "local_temperature_calibration": # centidegree to decidegree return {PCDM_TEMPERATURE_CORRECTION_ATTR: round(value / 10)} if attribute == "system_mode":#, "programing_oper_mode"): if attribute == "system_mode": system_mode = value # oper_mode = self._attr_cache.get( # self.attributes_by_name["programing_oper_mode"].id, # self.ProgrammingOperationMode.Simple, # ) else: system_mode = self._attr_cache.get( self.attributes_by_name["system_mode"].id, self.SystemMode.Heat ) # oper_mode = value if system_mode == self.SystemMode.Off: return {PCDM_SYSTEM_MODE_ATTR: 0} if system_mode == self.SystemMode.Heat: return {PCDM_SYSTEM_MODE_ATTR: 1} else: self.error("Unsupported value for SystemMode") if attribute == "programing_oper_mode": if value == self.ProgrammingOperationMode.Schedule_programming_mode: return {PCDM_PRESET: self.Preset.Schedule.value} if value == self.ProgrammingOperationMode.Simple: return {PCDM_PRESET: self.Preset.Manual.value} if value == self.ProgrammingOperationMode.Economy_mode: return {PCDM_PRESET: self.Preset.Eco.value} if attribute == "operation_preset": if value == self.Preset.FrostProtect: return {PCDM_FROST_PROTECT: 1} return {PCDM_PRESET: value.value} def mode_change(self, value): """System Mode change.""" _LOGGER.error(f'mode_change value [{value}]') # mode = self.SystemMode.Off if value == 0 else self.SystemMode.Heat # self._update_attribute(self.attributes_by_name["system_mode"].id, mode) self._update_attribute(self.attributes_by_name["system_mode"].id, self.SystemMode.Heat) if value == 0: mode = self.RunningMode.Off state = self.RunningState.Idle else: mode = self.RunningMode.Heat state = self.RunningState.Heat_State_On self._update_attribute(self.attributes_by_name["running_mode"].id, mode) self._update_attribute(self.attributes_by_name["running_state"].id, state) def program_change(self, value): """Programming mode change.""" operation_preset = None prog_mode = None if value == 0: prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode operation_preset = self.Preset.Schedule elif value == 1: prog_mode = self.ProgrammingOperationMode.Simple operation_preset = self.Preset.Away elif value == 2: prog_mode = self.ProgrammingOperationMode.Simple operation_preset = self.Preset.Manual elif value == 3: prog_mode = self.ProgrammingOperationMode.Simple operation_preset = self.Preset.Comfort elif value == 4: prog_mode = self.ProgrammingOperationMode.Economy_mode operation_preset = self.Preset.Eco elif value == 5: operation_preset = self.Preset.FrostProtect else: self.error("Unsupported value for Mode") _LOGGER.info(f'program_change PRESET value [{value}] {prog_mode} {operation_preset}') if prog_mode is not None: self._update_attribute(self.attributes_by_name["programing_oper_mode"].id, prog_mode) self._update_attribute(self.attributes_by_name["system_mode"].id, self.SystemMode.Heat) if operation_preset is not None: self._update_attribute(self.attributes_by_name["operation_preset"].id, operation_preset) def temperature_calibration_change(self, value): if value < -12: calibration = -12 elif value > 12: calibration = 12 else: calibration = value _LOGGER.info(f'temperature_calibration_change: {value} {calibration}') self._update_attribute(self.attributes_by_name["local_temperature_calibration"].id, calibration) class PcdmUserInterface(TuyaUserInterfaceCluster): """HVAC User interface cluster for tuya electric heating thermostats.""" _CHILD_LOCK_ATTR = PCDM_CHILD_LOCK_ATTR class PcdmHelperOnOff(LocalDataCluster, OnOff): """Helper OnOff cluster for various functions controlled by switch.""" def set_change(self, value): """Set new OnOff value.""" self._update_attribute(self.attributes_by_name["on_off"].id, value) def get_attr_val_to_write(self, value): """Return dict with attribute and value for thermostat.""" return None async def write_attributes(self, attributes, manufacturer=None): """Defer attributes writing to the set_data tuya command.""" records = self._write_attr_records(attributes) if not records: return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] has_change = False for record in records: attr_name = self.attributes[record.attrid].name if attr_name == "on_off": value = record.value.value has_change = True if has_change: attr_val = self.get_attr_val_to_write(value) if attr_val is not None: # global self in case when different endpoint has to exist return await PcdmManuClusterSelf.endpoint.tuya_manufacturer.write_attributes( attr_val, manufacturer=manufacturer ) return [ [ foundation.WriteAttributesStatusRecord( foundation.Status.FAILURE, r.attrid ) for r in records ] ] async def command( self, command_id: Union[foundation.GeneralCommand, int, t.uint8_t], *args, manufacturer: Optional[Union[int, t.uint16_t]] = None, expect_reply: bool = True, tsn: Optional[Union[int, t.uint8_t]] = None, ): """Override the default Cluster command.""" if command_id in (0x0000, 0x0001, 0x0002): if command_id == 0x0000: value = False elif command_id == 0x0001: value = True else: attrid = self.attributes_by_name["on_off"].id success, _ = await self.read_attributes( (attrid,), manufacturer=manufacturer ) try: value = success[attrid] except KeyError: return foundation.GENERAL_COMMANDS[ foundation.GeneralCommand.Default_Response ].schema(command_id=command_id, status=foundation.Status.FAILURE) value = not value _LOGGER.debug("CALLING WRITE FROM COMMAND") (res,) = await self.write_attributes( {"on_off": value}, manufacturer=manufacturer, ) return foundation.GENERAL_COMMANDS[ foundation.GeneralCommand.Default_Response ].schema(command_id=command_id, status=res[0].status) return foundation.GENERAL_COMMANDS[ foundation.GeneralCommand.Default_Response ].schema(command_id=command_id, status=foundation.Status.UNSUP_CLUSTER_COMMAND) class PcdmBoost(PcdmHelperOnOff): """On/Off cluster for the boost function of the heating thermostats.""" def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) self.endpoint.device.boost_bus.add_listener(self) def get_attr_val_to_write(self, value): """Return dict with attribute and value for boot mode.""" return {PCDM_BOOST_MODE: 299 if value else 0} class PcdmWindowDetection(PcdmHelperOnOff): """On/Off cluster for the window detection function of the heating thermostats.""" def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) self.endpoint.device.window_detection_bus.add_listener(self) def get_attr_val_to_write(self, value): """Return dict with attribute and value for boot mode.""" return {PCDM_WINDOW_MODE_ATTR: 1 if value else 0} class PcdmTemperatureOffset(LocalDataCluster, AnalogOutput): """AnalogOutput cluster for setting temperature offset.""" def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) self.endpoint.device.temperature_calibration_bus.add_listener(self) self.endpoint.device.thermostat_bus.add_listener(self) self._update_attribute(self.attributes_by_name["description"].id, "Temperature Offset") self._update_attribute(self.attributes_by_name["max_present_value"].id, 12) self._update_attribute(self.attributes_by_name["min_present_value"].id, -12) self._update_attribute(self.attributes_by_name["resolution"].id, 1) self._update_attribute(self.attributes_by_name["application_type"].id, 0x0009) self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) def set_value(self, value): # is this useful? """Set new temperature offset value.""" self._update_attribute(self.attributes_by_name["local_temperature_calibration"].id, value) def get_value(self): # is this useful? """Get current temperature offset value.""" return self._attr_cache.get(self.attributes_by_name["local_temperature_calibration"].id) async def write_attributes(self, attributes, manufacturer=None): """Modify value before passing it to the set_data tuya command.""" for attrid, value in attributes.items(): if isinstance(attrid, str): attrid = self.attributes_by_name[attrid].id if attrid not in self.attributes: self.error("%d is not a valid attribute id", attrid) continue intValue = str(int(float(value))) # remove any decimal part self._update_attribute(attrid, intValue) if attrid == 0x0055: # `present_value` await PcdmManuClusterSelf.endpoint.tuya_manufacturer.write_attributes( {PCDM_TEMPERATURE_CORRECTION_ATTR: intValue}, manufacturer=None ) return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) class PcdmTrv(TuyaThermostat): """PCDRM Thermostatic radiator valve""" def __init__(self, *args, **kwargs): """Init device.""" self.boost_bus = Bus() self.window_detection_bus = Bus() self.temperature_calibration_bus = Bus() super().__init__(*args, **kwargs) signature = { # endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184] # output_clusters=[10, 25]> MODELS_INFO: [ ("_TZE204_pcdmj88b", "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, PcdmManufTrvCluster, PcdmBoost, PcdmThermostat, PcdmUserInterface, TuyaPowerConfigurationCluster2AA, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], }, 2: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE, INPUT_CLUSTERS: [ PcdmWindowDetection, PcdmTemperatureOffset, ], OUTPUT_CLUSTERS: [], }, } } ### ````

The important part is the class 'PcdmTemperatureOffset', and be careful to add 'self.temperature_calibration_bus = Bus()' to the init method of class 'PcdmTrv'.

I made a home assistant blueprint that takes a climate entity, it's local temperature attribute, it's calibration entity, and an external thermometer entity, and hacks its way to make the TVR temperature follow the external temperature :

Feel free to try it if you are interested.

Last feature I'd like to see is entities for presets (switch to enable/disable them, and a number entitiy for each preset to set target temperature), let me know if you start working on it, otherwise I may or may not try it myself later (I need a break right now 😄 )

Teka101 commented 7 months ago

@royduin i'm trying to check if all features implemented works and after i will submit a PR :)

@T0yto great ! i'll check code asap (i only work 1 or 2 hours by week on this project, so sorry for delay in response) In term of Home-Assistant integration the new version 2023.12 try new things in order to improve integration of ZIGBEE devices... maybe in the futur, we don't have to bring patch to have full features on HA.

Really good job for your blueprint !

ed-wright commented 7 months ago

@T0ytoy thanks for the hard work, i am testing your latest changes and it does indeed show the offset as an entity, it does however appear to have no effect when changed, at least on my thermostats (i have 4 of these running). I may be doing something incorrect so if that is the case please correct me but if not i am happy to test

T0ytoy commented 7 months ago

@ed-wright it takes about ~35 seconds for it to be applied to the current temperature. After moving the calibration slider, can you chek the 'PcdmManufTrvCluster' cluster calibration attribute (using ZHA) and make sure the read value is equal to the slider value?

ed-wright commented 7 months ago

@T0ytoy i get the following

Failed to call service number/set_value. Failed to send request: Request failed after 5 attempts: <Status.MAC_NO_ACK: 233>

ed-wright commented 7 months ago

Screenshot 2023-12-18 175033 Screenshot 2023-12-18 175137

So i set the number to 12 and -12 and neither changed the value

It does show that your code and my valve is in agreement that the calibration value is at 0x022f (559)

T0ytoy commented 7 months ago

@ed-wright I just checked, the quirk I have running on my HA and the one I linked above are exactly the same, and it's working fine for me, so I don't really know what to think. Could you maybe have a different version of the TRV? Mine came in a blue box, the user manuel front page says "Model: BAB-1413Pro-E".

I guess since the quirk is loaded and would only do so with "_TZE204_pcdmj88b", "TS0601", the signature is right. Does it work if you change the calibration value in the "manage Zigbee device" menu?

EDIT: alternatively you can try and put some _LOGGER.error("line xxx : value is %s", value)at key lines of the code (%s or %d depending on the line I think, also 'value' or 'intValue'): I'm thinking lines 456, 460, 464, 472 are good places to investigate. This way you would have information in home assistant logs on what is going on.

ed-wright commented 7 months ago

@T0ytoy i have confirmed the quirk it running, mine did come in a blue box anoyingly i dont have the box to hand.

image when i set it manually it works and on the hardware unit itself the temperature is reflected and it shows on the thermostat entity too

image

It also reads correctly, but as before the hard work you have done to add the number entity has no input :(

On the debugging side

image

It looks like the number is being generated correctly

Interestingly i cannot see the debug messages i put in set_value nor get_value ever being called

ed-wright commented 7 months ago

@T0ytoy can i ask a really dim question, is the version you have in git the same as the one in your post above?

Again, thanks for the hard work and helping debug!

T0ytoy commented 7 months ago

@ed-wright I never see set_value nor get_value, that is why I put comments on those methods. No worry 😄

What do you mean by "the git" ? I only shared my version on the post above, under the spoiler tag "code". Could you clarify?

ed-wright commented 7 months ago

perfect clarification, just wanted to check that the version you had and the version in this thread was the same, thanks!

ed-wright commented 7 months ago

@T0ytoy i have also tried changing the str(int(float(value))) and removed the str to see if it works, it did not work

T0ytoy commented 7 months ago

What version of home assistant are you running? I am running 2023.12.3,maybe there was a change to how number is handled, I don't know?

ed-wright commented 7 months ago

I was on 2023.12.1, i am updating to 2023.12.3.

Edit: It made no difference :(

ed-wright commented 7 months ago

@T0ytoy i have found the box and they are the same Model: BAB-1413Pro-E

ed-wright commented 7 months ago

@T0ytoy Ah! I got it working, I deleted the unit in HA entirely and readded it and it has now magically started working, i have no idea why as I did tell HA to reconfigure the device. Thanks for the troubleshoting work and hard work creating the quirk! Many Thanks

T0ytoy commented 7 months ago

@ed-wright Good news! It kinda make sense: the issue was probably on the zigbee association side, I'm glad there is no intricated technical issue with the python code, as I'm really not comfortable debugging advanced issues in this context 😄

To give credit to where it's due: 890% of the work was done by @Teka101, I mostly just worked on the calibration feature. Many thanks to him :)

As a side note, I'm currently experimenting with using an average value over ~5-10 minutes instead of the raw "0.5 °C resolution shit data" locale temperature data from the TVR to feed the blueprint I made, I think it might be working a bit better since the raw temperature is jumping +-1°C all the time and the automation blueprint reacts a lot to try to compensate. I'm hoping it will generate less spikes above and below target temperature.

ed-wright commented 7 months ago

@T0ytoy looks like I may have spoken too soon I can see in ZHA that the number is being set but it still looks like the actual offset is not, I will debug

Teka101 commented 7 months ago

Hello,

I've made an update of my code (@T0ytoy now slider for temperature calibration is working, thank for your code).

Maybe someone can check if boost works ? Because i think, it's broken on this feature :/

Teka101 commented 7 months ago

PR: https://github.com/zigpy/zha-device-handlers/pull/2873

synchronierer commented 3 months ago

Hello all, first things first: thx so much for your work, that's so helpful for the community.

May a noob ask, where I can find the latest version of the code?

Thx in advance!

Teka101 commented 3 months ago

Hello @synchronierer,

Last version of code is in pull request : https://github.com/zigpy/zha-device-handlers/pull/2873