Open T0ytoy opened 10 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 :)
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)})],
],
},
},
@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 !
@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 :(
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...
@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.
@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.
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],
}
}
}
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 !
@elf0heart yes it's only work for reading... i'm working on it 💪
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
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!
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...
@elf0heart ok i can change temperature and preset now. Can you try if it's okay on your installation ?
@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 !
@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...)
@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.
@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:
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.
It seems preset selection and their temperature is easily accessible through cluster 0xef00 👍
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)
@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 ^_^
@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 :)
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...
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.
@T0ytoy when i look at other quirks, temperature_calibration
is type t.int32s
@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!
@T0ytoy ok thank you for your code.
I just publish a new version with: window detection mode and temperature calibration
@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!
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
@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 :
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 😄 )
@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 !
@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
@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?
@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>
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)
@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.
@T0ytoy i have confirmed the quirk it running, mine did come in a blue box anoyingly i dont have the box to hand.
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
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
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
@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!
@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?
perfect clarification, just wanted to check that the version you had and the version in this thread was the same, thanks!
@T0ytoy i have also tried changing the str(int(float(value))) and removed the str to see if it works, it did not work
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?
I was on 2023.12.1, i am updating to 2023.12.3.
Edit: It made no difference :(
@T0ytoy i have found the box and they are the same Model: BAB-1413Pro-E
@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
@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.
@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
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 :/
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!
Hello @synchronierer,
Last version of code is in pull request : https://github.com/zigpy/zha-device-handlers/pull/2873
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=Diagnostic information
No response
Logs
No response
Custom quirk
No response
Additional information
No response