Hello, HA community.
I need your help.
I bought a BHT-002-GCLZB thermostat [recognized as (“_TZE200_u9bfwha0”, “TS0601”) model].
It works with ZHA pretty well but ZHA doesn’t support two features that I need:
- Temperature offset (calibration)
- Deadzone temperate setting.
I found a few topics and I’m trying to add those features to my HA gui.
I found the standard handler for my thermostat
and I found this topic and code.
I combined both and I made my own zha_quirk:
"""Map from manufacturer to standard clusters for electric heating thermostats."""
from zigpy.profiles import zha
from zigpy.zcl import foundation
import zigpy.types as t
from zigpy.zcl.clusters.general import AnalogOutput, Basic, Groups, Ota, Scenes, Time
from zhaquirks import LocalDataCluster
from zhaquirks.const import (
DEVICE_TYPE,
ENDPOINTS,
INPUT_CLUSTERS,
MODELS_INFO,
OUTPUT_CLUSTERS,
PROFILE_ID,
)
from zhaquirks.tuya import (
TuyaManufClusterAttributes,
TuyaThermostat,
TuyaThermostatCluster,
TuyaUserInterfaceCluster,
)
# info from https://github.com/zigpy/zha-device-handlers/pull/538#issuecomment-723334124
# https://github.com/Koenkk/zigbee-herdsman-converters/blob/24baf6800c109ad891899e580659d800ab4918ad/converters/fromZigbee.js#L239
# and https://github.com/Koenkk/zigbee-herdsman-converters/blob/24baf6800c109ad891899e580659d800ab4918ad/converters/common.js#L113
MOESBHT_TARGET_TEMP_ATTR = 0x0210 # [0,0,0,21] target room temp (degree)
MOESBHT_TEMPERATURE_ATTR = 0x0218 # [0,0,0,200] current room temp (decidegree)
MOESBHT_SCHEDULE_MODE_ATTR = 0x0403 # [1] false [0] true /!\ inverted
MOESBHT_MANUAL_MODE_ATTR = 0x0402 # [1] false [0] true /!\ inverted
MOESBHT_ENABLED_ATTR = 0x0101 # [0] off [1] on
MOESBHT_RUNNING_MODE_ATTR = 0x0424 # [1] idle [0] heating /!\ inverted
MOESBHT_CHILD_LOCK_ATTR = 0x0128 # [0] unlocked [1] child-locked
MOESBHT_TEMPERATURE_CALIBRATION_ATTR = 0x021B # temperature calibration (decidegree)
MOESBHT_DEADZONE_TEMPERATURE_ATTR = 0x0214 # deadzone value
class MoesBHTManufCluster(TuyaManufClusterAttributes):
"""Manufacturer Specific Cluster of some electric heating thermostats."""
attributes = {
MOESBHT_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True),
MOESBHT_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True),
MOESBHT_SCHEDULE_MODE_ATTR: ("schedule_mode", t.uint8_t, True),
MOESBHT_MANUAL_MODE_ATTR: ("manual_mode", t.uint8_t, True),
MOESBHT_ENABLED_ATTR: ("enabled", t.uint8_t, True),
MOESBHT_RUNNING_MODE_ATTR: ("running_mode", t.uint8_t, True),
MOESBHT_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True),
MOESBHT_TEMPERATURE_CALIBRATION_ATTR: ("temperature_calibration", t.int32s, True),
MOESBHT_DEADZONE_TEMPERATURE_ATTR: ("deadzone_temperature", t.int32s, True),
}
def _update_attribute(self, attrid, value):
super()._update_attribute(attrid, value)
if attrid == MOESBHT_TARGET_TEMP_ATTR:
self.endpoint.device.thermostat_bus.listener_event(
"temperature_change",
"occupied_heating_setpoint",
value * 100, # degree to centidegree
)
elif attrid == MOESBHT_TEMPERATURE_ATTR:
self.endpoint.device.thermostat_bus.listener_event(
"temperature_change",
"local_temperature",
value * 10, # decidegree to centidegree
)
elif attrid == MOESBHT_SCHEDULE_MODE_ATTR:
if value == 0: # value is inverted
self.endpoint.device.thermostat_bus.listener_event(
"program_change", "scheduled"
)
elif attrid == MOESBHT_MANUAL_MODE_ATTR:
if value == 0: # value is inverted
self.endpoint.device.thermostat_bus.listener_event(
"program_change", "manual"
)
elif attrid == MOESBHT_ENABLED_ATTR:
self.endpoint.device.thermostat_bus.listener_event("enabled_change", value)
elif attrid == MOESBHT_RUNNING_MODE_ATTR:
# value is inverted
self.endpoint.device.thermostat_bus.listener_event(
"state_change", 1 - value
)
elif attrid == MOESBHT_CHILD_LOCK_ATTR:
self.endpoint.device.ui_bus.listener_event("child_lock_change", value)
class MoesBHTThermostat(TuyaThermostatCluster):
"""Thermostat cluster for some electric heating controllers."""
def map_attribute(self, attribute, value):
"""Map standardized attribute value to dict of manufacturer values."""
if attribute == "occupied_heating_setpoint":
# centidegree to degree
return {MOESBHT_TARGET_TEMP_ATTR: round(value / 100)}
if attribute == "system_mode":
if value == self.SystemMode.Off:
return {MOESBHT_ENABLED_ATTR: 0}
if value == self.SystemMode.Heat:
return {MOESBHT_ENABLED_ATTR: 1}
self.error("Unsupported value for SystemMode")
elif attribute == "programing_oper_mode":
# values are inverted
if value == self.ProgrammingOperationMode.Simple:
return {MOESBHT_MANUAL_MODE_ATTR: 0, MOESBHT_SCHEDULE_MODE_ATTR: 1}
if value == self.ProgrammingOperationMode.Schedule_programming_mode:
return {MOESBHT_MANUAL_MODE_ATTR: 1, MOESBHT_SCHEDULE_MODE_ATTR: 0}
self.error("Unsupported value for ProgrammingOperationMode")
return super().map_attribute(attribute, value)
def program_change(self, mode):
"""Programming mode change."""
if mode == "manual":
value = self.ProgrammingOperationMode.Simple
else:
value = self.ProgrammingOperationMode.Schedule_programming_mode
self._update_attribute(
self.attributes_by_name["programing_oper_mode"].id, value
)
def enabled_change(self, value):
"""System mode change."""
if value == 0:
mode = self.SystemMode.Off
else:
mode = self.SystemMode.Heat
self._update_attribute(self.attributes_by_name["system_mode"].id, mode)
class MoesBHTUserInterface(TuyaUserInterfaceCluster):
"""HVAC User interface cluster for tuya electric heating thermostats."""
_CHILD_LOCK_ATTR = MOESBHT_CHILD_LOCK_ATTR
class Thermostat_TZE200_u9bfwha0_TemperatureOffset(LocalDataCluster, AnalogOutput):
"""AnalogOutput cluster for setting temperature offset."""
def __init__(self, *args, **kwargs):
"""Init."""
super().__init__(*args, **kwargs)
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, 6)
self._update_attribute(self.attributes_by_name["min_present_value"].id, -6)
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):
"""Set new temperature offset value."""
self._update_attribute(self.attributes_by_name["present_value"].id, value)
def get_value(self):
"""Get current temperature offset value."""
return self._attr_cache.get(self.attributes_by_name["present_value"].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
self._update_attribute(attrid, value)
await self.endpoint.tuya_manufacturer.write_attributes(
{MOESBHT_TEMPERATURE_CALIBRATION_ATTR: value}, manufacturer=None
)
return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],)
class MoesBHT(TuyaThermostat):
"""Tuya thermostat for devices like the Moes BHT-002GCLZB valve and BHT-003GBLZB Electric floor heating."""
signature = {
# endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184],
# output_clusters=[10, 25]
MODELS_INFO: [
("_TZE200_aoclfnxz", "TS0601"),
("_TZE200_2ekuz3dz", "TS0601"),
("_TZE200_ye5jkfsb", "TS0601"),
,
],
ENDPOINTS: {
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
INPUT_CLUSTERS: [
Basic.cluster_id,
Groups.cluster_id,
Scenes.cluster_id,
TuyaManufClusterAttributes.cluster_id,
],
OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
}
},
}
replacement = {
ENDPOINTS: {
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.THERMOSTAT,
INPUT_CLUSTERS: [
Basic.cluster_id,
Groups.cluster_id,
Scenes.cluster_id,
MoesBHTManufCluster,
MoesBHTThermostat,
MoesBHTUserInterface,
Thermostat_TZE200_u9bfwha0_TemperatureOffset,
],
OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
}
}
}
I added a new class Thermostat_TZE200_u9bfwha0_TemperatureOffset and this class is used for replace endpoints.
Now I set the temperature offset from HA gui and use both:
Controls:
Pic no. 1
and in manage zigbee device:
Pic no. 2
A. First problem that I have is that: If I change the value via the control panel then the value is changed everywhere (control panel, and both clusters (MoesBHTManufCluster, Thermostat_TZE200_u9bfwha0_TemperatureOffset) in manage zigbee device.
But If I will change in Thermostat_TZE200_u9bfwha0_TemperatureOffset value for “present_value” then change is not applied.
B. I’m trying also add deadzone temperature settings to control panel for this device.
I extended my quirk and added new class “Thermostat_TZE200_u9bfwha0_TemperatureDeadZone” and used this class in replacement:
The attribute id for deadzone temperature is ok because it works in “manage zigbee device”
#<code snipped>
class Thermostat_TZE200_u9bfwha0_TemperatureDeadZone(LocalDataCluster, AnalogOutput):
"""AnalogOutput cluster for setting temperature offset."""
def __init__(self, *args, **kwargs):
"""Init."""
super().__init__(*args, **kwargs)
self.endpoint.device.thermostat_bus.add_listener(self)
self._update_attribute(
self.attributes_by_name["description"].id, "Temperature DeadZone"
)
self._update_attribute(self.attributes_by_name["max_present_value"].id, 5)
self._update_attribute(self.attributes_by_name["min_present_value"].id, 1)
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):
"""Set new temperature offset value."""
self._update_attribute(self.attributes_by_name["present_value"].id, value)
def get_value(self):
"""Get current temperature offset value."""
return self._attr_cache.get(self.attributes_by_name["present_value"].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
self._update_attribute(attrid, value)
await self.endpoint.tuya_manufacturer.write_attributes(
{MOESBHT_DEADZONE_TEMPERATURE_ATTR: value}, manufacturer=None
)
return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],)
class MoesBHT(TuyaThermostat):
"""Tuya thermostat for devices like the Moes BHT-002GCLZB valve and BHT-003GBLZB Electric floor heating."""
signature = {
# endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184],
# output_clusters=[10, 25]
MODELS_INFO: [
("_TZE200_aoclfnxz", "TS0601"),
("_TZE200_2ekuz3dz", "TS0601"),
("_TZE200_ye5jkfsb", "TS0601"),
("_TZE200_u9bfwha0", "TS0601"),
],
ENDPOINTS: {
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
INPUT_CLUSTERS: [
Basic.cluster_id,
Groups.cluster_id,
Scenes.cluster_id,
TuyaManufClusterAttributes.cluster_id,
],
OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
}
},
}
replacement = {
ENDPOINTS: {
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.THERMOSTAT,
INPUT_CLUSTERS: [
Basic.cluster_id,
Groups.cluster_id,
Scenes.cluster_id,
MoesBHTManufCluster,
MoesBHTThermostat,
MoesBHTUserInterface,
Thermostat_TZE200_u9bfwha0_TemperatureOffset,
Thermostat_TZE200_u9bfwha0_TemperatureDeadZone,
],
OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
}
}
}
In “manage zigbee device” panel in “MoesBHTManufCluster” I can see and use both attributes:
Pic no. 4
but in the controls panel, I can see only one control - for deadzone temperature
Pic no. 3
I suppose this is because both classes have the same cluster id (from AnalogOutput).
What I should do if I want to see and use both attributes in the control panel for this device?
Thank you in advance.