zigpy / zha-device-handlers

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

[Device Support Request] Support temperature offset for Tuya Saswell TRV _TZE200_c88teujp #2025

Closed jclsn closed 1 year ago

jclsn commented 1 year ago

Is your feature request related to a problem? Please describe. I would like to set a temperature offset for the Saswell TRVs. It seems there needs to be a data point mapping implemented. The model is listed here

https://github.com/Koenkk/zigbee-herdsman-converters/blob/927f352c9dc848f716c23abf9d2a0a56c13a9f67/devices/saswell.js#L11

and here

https://github.com/Koenkk/zigbee-herdsman-converters/blob/927f352c9dc848f716c23abf9d2a0a56c13a9f67/lib/tuya.js#L516

but I have no idea how to implement a mapping here

https://github.com/zigpy/zha-device-handlers/blob/c2b2893d027fdcd3a225996fdf382d2ac72ff799/zhaquirks/tuya/ts0601_valve.py#L161

@javicalle dmulcahey said to mention you here ;)

Describe the solution you'd like Implement a data point mapping for a given model

Device signature ```yaml { "node_descriptor": "NodeDescriptor(logical_type=, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=, mac_capability_flags=, manufacturer_code=4098, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)", "endpoints": { "1": { "profile_id": 260, "device_type": "0x0301", "in_clusters": [ "0x0000", "0x0001", "0x0004", "0x0005", "0x0201", "0xef00" ], "out_clusters": [ "0x000a", "0x0019" ] } }, "manufacturer": "_TZE200_c88teujp", "model": "TS0601", "class": "zhaquirks.tuya.ts0601_trv_sas.Thermostat_TZE200_c88teujp" } ```
Diagnostic information ```yaml { "home_assistant": { "installation_type": "Home Assistant OS", "version": "2022.12.7", "dev": false, "hassio": true, "virtualenv": false, "python_version": "3.10.7", "docker": true, "arch": "aarch64", "timezone": "Europe/Berlin", "os_name": "Linux", "os_version": "5.15.76-v8", "supervisor": "2022.11.2", "host_os": "Home Assistant OS 9.4", "docker_version": "20.10.19", "chassis": "embedded", "run_as_root": true }, "custom_components": { "tuya_v2": { "version": "1.5.0", "requirements": [ "tuya-iot-py-sdk==0.4.1" ] }, "hacs": { "version": "1.29.0", "requirements": [ "aiogithubapi>=22.10.1" ] }, "openweathermap_all": { "version": "0.0.1", "requirements": [ "owm2json==0.1.89" ] } }, "integration_manifest": { "domain": "zha", "name": "Zigbee Home Automation", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ "bellows==0.34.5", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.89", "zigpy-deconz==0.19.2", "zigpy==0.52.3", "zigpy-xbee==0.16.2", "zigpy-zigate==0.10.3", "zigpy-znp==0.9.2" ], "usb": [ { "vid": "10C4", "pid": "EA60", "description": "*2652*", "known_devices": [ "slae.sh cc2652rb stick" ] }, { "vid": "1A86", "pid": "55D4", "description": "*sonoff*plus*", "known_devices": [ "sonoff zigbee dongle plus v2" ] }, { "vid": "10C4", "pid": "EA60", "description": "*sonoff*plus*", "known_devices": [ "sonoff zigbee dongle plus" ] }, { "vid": "10C4", "pid": "EA60", "description": "*tubeszb*", "known_devices": [ "TubesZB Coordinator" ] }, { "vid": "1A86", "pid": "7523", "description": "*tubeszb*", "known_devices": [ "TubesZB Coordinator" ] }, { "vid": "1A86", "pid": "7523", "description": "*zigstar*", "known_devices": [ "ZigStar Coordinators" ] }, { "vid": "1CF1", "pid": "0030", "description": "*conbee*", "known_devices": [ "Conbee II" ] }, { "vid": "10C4", "pid": "8A2A", "description": "*zigbee*", "known_devices": [ "Nortek HUSBZB-1" ] }, { "vid": "0403", "pid": "6015", "description": "*zigate*", "known_devices": [ "ZiGate+" ] }, { "vid": "10C4", "pid": "EA60", "description": "*zigate*", "known_devices": [ "ZiGate" ] }, { "vid": "10C4", "pid": "8B34", "description": "*bv 2010/10*", "known_devices": [ "Bitron Video AV2010/10" ] } ], "codeowners": [ "@dmulcahey", "@adminiuga", "@puddly" ], "zeroconf": [ { "type": "_esphomelib._tcp.local.", "name": "tube*" }, { "type": "_zigate-zigbee-gateway._tcp.local.", "name": "*zigate*" }, { "type": "_zigstar_gw._tcp.local.", "name": "*zigstar*" } ], "dependencies": [ "file_upload" ], "after_dependencies": [ "onboarding", "usb", "zeroconf" ], "iot_class": "local_polling", "loggers": [ "aiosqlite", "bellows", "crccheck", "pure_pcapy3", "zhaquirks", "zigpy", "zigpy_deconz", "zigpy_xbee", "zigpy_zigate", "zigpy_znp" ], "is_built_in": true }, "data": { "ieee": "**REDACTED**", "nwk": 59604, "manufacturer": "_TZE200_c88teujp", "model": "TS0601", "name": "_TZE200_c88teujp TS0601", "quirk_applied": true, "quirk_class": "zhaquirks.tuya.ts0601_trv_sas.Thermostat_TZE200_c88teujp", "manufacturer_code": 4098, "power_source": "Battery or Unknown", "lqi": 91, "rssi": null, "last_seen": "2022-12-20T14:25:47", "available": true, "device_type": "EndDevice", "signature": { "node_descriptor": "NodeDescriptor(logical_type=, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=, mac_capability_flags=, manufacturer_code=4098, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)", "endpoints": { "1": { "profile_id": 260, "device_type": "0x0301", "in_clusters": [ "0x0000", "0x0001", "0x0004", "0x0005", "0x0201", "0xef00" ], "out_clusters": [ "0x000a", "0x0019" ] } } }, "active_coordinator": false, "entities": [ { "entity_id": "climate.thermostat_badezimmer_zha", "name": "_TZE200_c88teujp TS0601" }, { "entity_id": "sensor.thermostat_badezimmer_battery_zha", "name": "_TZE200_c88teujp TS0601" }, { "entity_id": "sensor.thermostat_badezimmer_thermostathvacaction_zha", "name": "_TZE200_c88teujp TS0601" } ], "neighbors": [], "routes": [], "endpoint_names": [ { "name": "THERMOSTAT" } ], "user_given_name": "Thermostat Badezimmer", "device_reg_id": "06deedd7a9cb8801191f0ac65d462677", "area_id": "badezimmer", "cluster_details": { "1": { "device_type": { "name": "THERMOSTAT", "id": 769 }, "profile_id": 260, "in_clusters": { "0x0000": { "endpoint_attribute": "basic", "attributes": { "0x0004": { "attribute_name": "manufacturer", "value": "_TZE200_c88teujp" }, "0x0005": { "attribute_name": "model", "value": "TS0601" } }, "unsupported_attributes": {} }, "0x0001": { "endpoint_attribute": "power", "attributes": { "0x0021": { "attribute_name": "battery_percentage_remaining", "value": 200 } }, "unsupported_attributes": {} }, "0x0004": { "endpoint_attribute": "groups", "attributes": {}, "unsupported_attributes": {} }, "0x0005": { "endpoint_attribute": "scenes", "attributes": {}, "unsupported_attributes": {} }, "0x0201": { "endpoint_attribute": "thermostat", "attributes": { "0x0000": { "attribute_name": "local_temperature", "value": 2010 }, "0x0012": { "attribute_name": "occupied_heating_setpoint", "value": 1900 }, "0x0015": { "attribute_name": "min_heat_setpoint_limit", "value": 500 }, "0x0016": { "attribute_name": "max_heat_setpoint_limit", "value": 3000 }, "0x001b": { "attribute_name": "ctrl_sequence_of_oper", "value": 2 }, "0x001c": { "attribute_name": "system_mode", "value": 4 }, "0x001e": { "attribute_name": "running_mode", "value": 4 } }, "unsupported_attributes": {} }, "0xef00": { "endpoint_attribute": "tuya_manufacturer", "attributes": { "0x0165": { "attribute_name": "state", "value": 1 }, "0x0267": { "attribute_name": "heating_setpoint", "value": 190 }, "0x0266": { "attribute_name": "local_temperature", "value": 201 }, "0x0569": { "attribute_name": "battery_state", "value": 0 }, "0x016c": { "attribute_name": "schedule_enabled", "value": 0 } }, "unsupported_attributes": {} } }, "out_clusters": { "0x000a": { "endpoint_attribute": "time", "attributes": {}, "unsupported_attributes": {} }, "0x0019": { "endpoint_attribute": "ota", "attributes": {}, "unsupported_attributes": {} } } } } } } ```
Additional logs ``` Paste any additional debug logs here. Don't remove the extra line breaks outside the ``` marks. ```

Additional context Add any other context or screenshots about the feature request here.

javicalle commented 1 year ago

I'm sorry to say I'm not familiar with TRVs, but we're going to help where we can.

The TRV device don't use the 'MCU approach' (not in the same way that other implementations) so you will not find any DPToAttributeMapping here 🤷🏻‍♂️

As you have reported, it seems that in Z2M it is possible to configure the saswell_thermostat_calibration. This part is done here:

As you have stated, the DP value for the attribute is saswellTempCalibration: 27, in hexadecimal will be 0x1B.

Let's see how is it in ZHA...

Your device is already loading a quirk (zhaquirks.tuya.ts0601_trv_sas.Thermostat_TZE200_c88teujp)

Most of the magic happens here:

and here:

...but no reference to any calibration attribute.

Interestingly, I did find a reference to the attribute elsewhere:

Right name and right DP, that can not be a coincidence.

So the ZonnsmartTV01_ZG seems to implement the temperature calibration:

Since it doesn't seem like this quirk can be used for your device, I guess the functionality should be implemented in the Thermostat_TZE200_c88teujp quirk.

That would be more or less:

I hope it's enough to guide you

jclsn commented 1 year ago

Yeah thanks, that is plenty. I will see if I can implement this when I find the time.

How can I test this after implementing?

Also: Is it possible to expose this as a HA entity? It would be much easier to find for people.

javicalle commented 1 year ago

How can I test this after implementing?

Enable the local quirk folder in your instalation and put inside the new version of the quirk (the full file). Restart HA and check if the device signature changes.

Is it possible to expose this as a HA entity?

That is the function of the ZONNSMARTTemperatureOffset class.

jclsn commented 1 year ago

So I got it kinda working. Seems like only values from -6 to 6 are accepted. I can only calibrate the temperature in steps of full degrees, which does not make sense.

Also: What is the first byte of the values? The value is 0x1B, but in the ZHA file it is 0x021B.

This is what I have so far. Maybe my scaling is wrong somehow? I can't image that there are just steps of 1. If there really are, I guess the DIRECT_MAPPED_ATTRS section is redundant. This was used in the ZONNSMART example for scaling. I can scale the slider alright, but the values accepted by the temperature_calibration data endpoint only range from -6 to 6 = -6°C to 6°C, which is weird because for the heating_setpoint and local_temperature endpoints 6 = 0.6°C.

Code ```python """Saswell (Tuya whitelabel) 88teujp thermostat valve quirk.""" import logging from zigpy.profiles import zha import zigpy.types as t from zigpy.zcl import foundation from zigpy.zcl.clusters.general import ( AnalogOutput, Basic, Groups, Identify, Ota, Scenes, Time ) from zigpy.zcl.clusters.hvac import Thermostat from zhaquirks import LocalDataCluster, Bus from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, INPUT_CLUSTERS, MODELS_INFO, OUTPUT_CLUSTERS, PROFILE_ID, ) from zhaquirks.tuya import ( TuyaManufClusterAttributes, TuyaPowerConfigurationCluster, TuyaThermostat, TuyaThermostatCluster, ) _LOGGER = logging.getLogger(__name__) SASWELL_HEATING_SETPOINT_ATTR = 0x0267 SASWELL_STATE_ATTR = 0x0165 SASWELL_LOCAL_TEMP_ATTR = 0x0266 SASWELL_BATTERY_LOW_ATTR = 0x0569 SASWELL_SCHEDULE_ENABLE_ATTR = 0x016C SASWELL_TEMPERATURE_CALIBRATION_ATTR = 0x021B # temperature calibration (decidegree) CTRL_SEQ_OF_OPER_ATTR = 0x001B MIN_HEAT_SETPOINT_ATTR = 0x0015 MAX_HEAT_SETPOINT_ATTR = 0x0016 class ManufacturerThermostatCluster(TuyaManufClusterAttributes): """Tuya manufacturer specific cluster.""" class State(t.enum8): """State option.""" Off = 0x00 On = 0x01 class BatteryState(t.enum8): """Battery state option.""" Normal = 0x00 Low = 0x01 class ScheduleState(t.enum8): """Schedule state option.""" Disabled = 0x00 Enabled = 0x01 attributes = TuyaManufClusterAttributes.attributes.copy() attributes.update( { SASWELL_STATE_ATTR: ("state", State, True), SASWELL_HEATING_SETPOINT_ATTR: ("heating_setpoint", t.uint32_t, True), SASWELL_LOCAL_TEMP_ATTR: ("local_temperature", t.uint32_t, True), SASWELL_BATTERY_LOW_ATTR: ("battery_state", BatteryState, True), SASWELL_SCHEDULE_ENABLE_ATTR: ("schedule_enabled", ScheduleState, True), SASWELL_TEMPERATURE_CALIBRATION_ATTR: ( "temperature_calibration", t.int32s, True, ), } ) TEMPERATURE_ATTRS = { SASWELL_HEATING_SETPOINT_ATTR: "occupied_heating_setpoint", SASWELL_LOCAL_TEMP_ATTR: "local_temperature", } DIRECT_MAPPED_ATTRS = { SASWELL_TEMPERATURE_CALIBRATION_ATTR: ( "local_temperature_calibration", lambda value: value, ), } 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, ) elif attrid == SASWELL_STATE_ATTR: self.endpoint.device.thermostat_bus.listener_event( "system_mode_reported", value ) elif attrid == SASWELL_BATTERY_LOW_ATTR: # this device doesn't have battery level reporting, only battery low alert # when the alert is active (1) we report 0% and 100% otherwise (0) self.endpoint.device.battery_bus.listener_event( "battery_change", 0 if value == 1 else 100 ) if attrid == (SASWELL_TEMPERATURE_CALIBRATION_ATTR): self.endpoint.device.temperature_calibration_bus.listener_event( "set_value", value ) elif attrid in (SASWELL_LOCAL_TEMP_ATTR, SASWELL_HEATING_SETPOINT_ATTR): self.endpoint.device.thermostat_bus.listener_event( "state_temp_change", attrid, value ) class ThermostatCluster(TuyaThermostatCluster): """Thermostat cluster.""" cluster_id = Thermostat.cluster_id _CONSTANT_ATTRIBUTES = { MIN_HEAT_SETPOINT_ATTR: 500, MAX_HEAT_SETPOINT_ATTR: 3000, CTRL_SEQ_OF_OPER_ATTR: Thermostat.ControlSequenceOfOperation.Heating_Only, # the device supports heating mode } def system_mode_reported(self, value): """Handle reported system mode.""" if value == 1: self._update_attribute( self.attributes_by_name["system_mode"].id, Thermostat.SystemMode.Heat ) self._update_attribute( self.attributes_by_name["running_mode"].id, Thermostat.RunningMode.Heat ) _LOGGER.debug("reported system_mode: heat") else: self._update_attribute( self.attributes_by_name["system_mode"].id, Thermostat.SystemMode.Off ) self._update_attribute( self.attributes_by_name["running_mode"].id, Thermostat.RunningMode.Off ) _LOGGER.debug("reported system_mode: off") def map_attribute(self, attribute, value): """Map standardized attribute value to dict of manufacturer values.""" if attribute == "occupied_heating_setpoint": # centidegree to decidegree return {SASWELL_HEATING_SETPOINT_ATTR: round(value / 10)} if attribute == "system_mode": # this quirk does not support programmig modes so we force the schedule mode to be always off # more details: https://github.com/zigpy/zha-device-handlers/issues/1815 if value == self.SystemMode.Off: return {SASWELL_STATE_ATTR: 0, SASWELL_SCHEDULE_ENABLE_ATTR: 0} if value == self.SystemMode.Heat: return {SASWELL_STATE_ATTR: 1, SASWELL_SCHEDULE_ENABLE_ATTR: 0} class Thermostat_TYST11_c88teujp(TuyaThermostat): """Saswell 88teujp thermostat valve.""" signature = { MODELS_INFO: [ ("_TYST11_KGbxAXL2", "GbxAXL2"), ("_TYST11_c88teujp", "88teujp"), ("_TYST11_azqp6ssj", "zqp6ssj"), ("_TYST11_yw7cahqs", "w7cahqs"), ("_TYST11_9gvruqf5", "gvruqf5"), ("_TYST11_zuhszj9s", "uhszj9s"), ], ENDPOINTS: { # 1: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, INPUT_CLUSTERS: [ Basic.cluster_id, Identify.cluster_id, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, Ota.cluster_id, ], } }, } replacement = { ENDPOINTS: { 1: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.THERMOSTAT, INPUT_CLUSTERS: [ Basic.cluster_id, TuyaPowerConfigurationCluster, Identify.cluster_id, ThermostatCluster, ManufacturerThermostatCluster, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, Ota.cluster_id, ], } }, } class Thermostat_TZE200_c88teujp_TemperatureOffset(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._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( {SASWELL_TEMPERATURE_CALIBRATION_ATTR: value}, manufacturer=None ) return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) class Thermostat_TZE200_c88teujp(TuyaThermostat): """Saswell 88teujp thermostat valve.""" def __init__(self, *args, **kwargs): """Init device.""" self.temperature_calibration_bus = Bus() super().__init__(*args, **kwargs) signature = { MODELS_INFO: [ ("_TZE200_c88teujp", "TS0601"), ("_TZE200_azqp6ssj", "TS0601"), ("_TZE200_yw7cahqs", "TS0601"), ("_TZE200_9gvruqf5", "TS0601"), ("_TZE200_zuhszj9s", "TS0601"), ("_TZE200_2ekuz3dz", "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, TuyaPowerConfigurationCluster, Groups.cluster_id, Scenes.cluster_id, ThermostatCluster, ManufacturerThermostatCluster, Thermostat_TZE200_c88teujp_TemperatureOffset, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], } } } ```