Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
T0ytoy opened this issue Nov 4, 2023 · 52 comments · May be fixed by #2873
Open

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

T0ytoy opened this issue Nov 4, 2023 · 52 comments · May be fixed by #2873
Labels
Tuya Request/PR regarding a Tuya device

Comments

@T0ytoy
Copy link

T0ytoy commented Nov 4, 2023

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
{
  "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.EndDevice: 2>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress: 128>, 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>, *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

@TheJulianJES TheJulianJES added the Tuya Request/PR regarding a Tuya device label Nov 5, 2023
@T0ytoy
Copy link
Author

T0ytoy commented Nov 9, 2023

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

@jim-fx
Copy link

jim-fx commented Nov 15, 2023

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
Copy link

@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
Copy link
Author

T0ytoy commented Nov 19, 2023

@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
Copy link

elf0heart commented Nov 20, 2023

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
Copy link

@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
Copy link
Author

T0ytoy commented Nov 24, 2023

@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
Copy link

Teka101 commented Nov 24, 2023

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
Copy link

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
Copy link

Teka101 commented Nov 25, 2023

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

@Teka101
Copy link

Teka101 commented Nov 28, 2023

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
Copy link

medivel commented Nov 29, 2023

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
Copy link

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
Copy link

Teka101 commented Dec 3, 2023

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

@elf0heart
Copy link

@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
Copy link

Teka101 commented Dec 4, 2023

@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
Copy link

@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
Copy link
Author

T0ytoy commented Dec 4, 2023

@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
Copy link
Author

T0ytoy commented Dec 4, 2023

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
Copy link

Teka101 commented Dec 5, 2023

@elf0heart yes there is more to do :)

@T0ytoy preset can be change with group PcdmThermostat

At this time, you can only change:

  • target temperature
  • child mode lock (no visual integration in HASS but working with service call)
  • preset mode : eco / manual / away / schedule

And in read only, we have :

  • battery level
  • valve is open or closed

I'm still working on it

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

@T0ytoy
Copy link
Author

T0ytoy commented Dec 11, 2023

@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
Copy link

Teka101 commented Dec 11, 2023

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
Copy link
Author

T0ytoy commented Dec 11, 2023

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
Copy link

Teka101 commented Dec 11, 2023

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

@T0ytoy
Copy link
Author

T0ytoy commented Dec 11, 2023

@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
Copy link

Teka101 commented Dec 14, 2023

@T0ytoy ok thank you for your code.

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

@T0ytoy
Copy link
Author

T0ytoy commented Dec 16, 2023

@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
Copy link

royduin commented Dec 16, 2023

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
Copy link
Author

T0ytoy commented Dec 17, 2023

@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
Copy link

Teka101 commented Dec 17, 2023

@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
Copy link

@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
Copy link
Author

T0ytoy commented Dec 18, 2023

@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
Copy link

ed-wright commented Dec 18, 2023

@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
Copy link

ed-wright commented Dec 18, 2023

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
Copy link
Author

T0ytoy commented Dec 18, 2023

@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
Copy link

ed-wright commented Dec 18, 2023

@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
Copy link

@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
Copy link
Author

T0ytoy commented Dec 18, 2023

@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
Copy link

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

@ed-wright
Copy link

ed-wright commented Dec 18, 2023

@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
Copy link
Author

T0ytoy commented Dec 18, 2023

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
Copy link

ed-wright commented Dec 18, 2023

I was on 2023.12.1, i am updating to 2023.12.3.

Edit: It made no difference :(

@ed-wright
Copy link

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

@ed-wright
Copy link

@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
Copy link
Author

T0ytoy commented Dec 19, 2023

@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
Copy link

@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
Copy link

Teka101 commented Dec 26, 2023

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
Copy link

Teka101 commented Dec 30, 2023

PR: #2873

@TheJulianJES TheJulianJES linked a pull request Dec 30, 2023 that will close this issue
3 tasks
@synchronierer
Copy link

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
Copy link

Teka101 commented Apr 16, 2024

Hello @synchronierer,

Last version of code is in pull request : #2873

@synchronierer
Copy link

@ Teka101:
Thx so much, HA wouldn't be the same without people like you.
I hope the support for the device will soon be integrated ofiicially.

I use the lastest code for the custom quirk and to them to be controllable. I even set up a heating program with Scheduler und Scheduler Card from HACS, but it's still not very intuitive.
Is there a way to change temperature manually and go back to the schedule temperature after a certain time/event?

@Teka101
Copy link

Teka101 commented Apr 18, 2024

@synchronierer thank you.
I only use scheduler for my trv, so i don't know how you can do this...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Tuya Request/PR regarding a Tuya device
Projects
None yet
Development

Successfully merging a pull request may close this issue.

10 participants