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
749 stars 687 forks source link

[Device Support Request] Futurehome Termostat (Co020) #3332

Open esuomi opened 2 months ago

esuomi commented 2 months ago

Problem description

I'm re-reporting https://github.com/zigpy/zha-device-handlers/issues/2985 as the information in that request is quite muddled, and I wanted to provide it in more legible format with some additional info. That said, I also have this thermostat, two of them in fact, so there is actual need beyond just polishing someone else's text :)


The device is Futurehome Termostat (note the missing h, the product is Norwegian) and shows up as Router in Home Assistant. Like reported in the other issue, all the details match for me as well, such as the device type reporting itself as Router and no entities available.

This device is already supported by Zigbee2MQTT Futurehome TS0601_futurehome_thermostat although there is an open issue of missing support for power measurement: https://github.com/Koenkk/zigbee2mqtt/issues/23139

EDIT: Did a bit of digging on the other side of the fence, two interesting bits:

Solution description

The device should function like a thermostat :) Feature parity with Zigbee2MQTT's support would be great.

Screenshots/Video

Screenshots/Video [Paste/upload your media here]

Device signature

Device signature ```json { "node_descriptor": { "logical_type": 1, "complex_descriptor_available": 0, "user_descriptor_available": 0, "reserved": 0, "aps_flags": 0, "frequency_band": 8, "mac_capability_flags": 142, "manufacturer_code": 4098, "maximum_buffer_size": 82, "maximum_incoming_transfer_size": 82, "server_mask": 11264, "maximum_outgoing_transfer_size": 82, "descriptor_capability_field": 0 }, "endpoints": { "1": { "profile_id": "0x0104", "device_type": "0x0051", "input_clusters": [ "0x0000", "0x0004", "0x0005", "0xef00" ], "output_clusters": [ "0x000a", "0x0019" ] } }, "manufacturer": "_TZE200_e5hpkc6d", "model": "TS0601", "class": "zigpy.device.Device" } ```

Diagnostic information

Diagnostic information ```json { "home_assistant": { "installation_type": "Home Assistant OS", "version": "2024.8.2", "dev": false, "hassio": true, "virtualenv": false, "python_version": "3.12.4", "docker": true, "arch": "aarch64", "timezone": "Europe/Helsinki", "os_name": "Linux", "os_version": "6.6.31-haos-raspi", "supervisor": "2024.08.0", "host_os": "Home Assistant OS 13.0", "docker_version": "26.1.4", "chassis": "embedded", "run_as_root": true }, "custom_components": {}, "integration_manifest": { "domain": "zha", "name": "Zigbee Home Automation", "after_dependencies": [ "onboarding", "usb" ], "codeowners": [ "dmulcahey", "adminiuga", "puddly", "TheJulianJES" ], "config_flow": true, "dependencies": [ "file_upload" ], "documentation": "https://www.home-assistant.io/integrations/zha", "iot_class": "local_polling", "loggers": [ "aiosqlite", "bellows", "crccheck", "pure_pcapy3", "zhaquirks", "zigpy", "zigpy_deconz", "zigpy_xbee", "zigpy_zigate", "zigpy_znp", "zha", "universal_silabs_flasher" ], "requirements": [ "universal-silabs-flasher==0.0.22", "zha==0.0.31" ], "usb": [ { "vid": "10C4", "pid": "EA60", "description": "*2652*", "known_devices": [ "slae.sh cc2652rb stick" ] }, { "vid": "10C4", "pid": "EA60", "description": "*slzb-07*", "known_devices": [ "smlight slzb-07" ] }, { "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": "0403", "pid": "6015", "description": "*conbee*", "known_devices": [ "Conbee III" ] }, { "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" ] } ], "zeroconf": [ { "type": "_esphomelib._tcp.local.", "name": "tube*" }, { "type": "_zigate-zigbee-gateway._tcp.local.", "name": "*zigate*" }, { "type": "_zigstar_gw._tcp.local.", "name": "*zigstar*" }, { "type": "_uzg-01._tcp.local.", "name": "uzg-01*" }, { "type": "_slzb-06._tcp.local.", "name": "slzb-06*" }, { "type": "_xzg._tcp.local.", "name": "xzg*" }, { "type": "_czc._tcp.local.", "name": "czc*" } ], "is_built_in": true }, "setup_times": { "null": { "setup": 7.785200000398618e-05 }, "8557d9677225771a1be301a22c96471e": { "wait_import_platforms": -0.021232295999993767, "wait_base_component": -0.0004887970000027053, "config_entry_setup": 10.50507001199999 } }, "data": { "ieee": "**REDACTED**", "nwk": 55953, "manufacturer": "_TZE200_e5hpkc6d", "model": "TS0601", "name": "_TZE200_e5hpkc6d TS0601", "quirk_applied": false, "quirk_class": "zigpy.device.Device", "quirk_id": null, "manufacturer_code": 4098, "power_source": "Mains", "lqi": 96, "rssi": -76, "last_seen": "2024-08-29T14:02:36", "available": true, "device_type": "Router", "signature": { "node_descriptor": { "logical_type": 1, "complex_descriptor_available": 0, "user_descriptor_available": 0, "reserved": 0, "aps_flags": 0, "frequency_band": 8, "mac_capability_flags": 142, "manufacturer_code": 4098, "maximum_buffer_size": 82, "maximum_incoming_transfer_size": 82, "server_mask": 11264, "maximum_outgoing_transfer_size": 82, "descriptor_capability_field": 0 }, "endpoints": { "1": { "profile_id": "0x0104", "device_type": "0x0051", "input_clusters": [ "0x0000", "0x0004", "0x0005", "0xef00" ], "output_clusters": [ "0x000a", "0x0019" ] } }, "manufacturer": "_TZE200_e5hpkc6d", "model": "TS0601" }, "active_coordinator": false, "entities": [ { "entity_id": "sensor.tze200_e5hpkc6d_ts0601_rssi", "name": "_TZE200_e5hpkc6d TS0601" }, { "entity_id": "sensor.tze200_e5hpkc6d_ts0601_lqi", "name": "_TZE200_e5hpkc6d TS0601" }, { "entity_id": "update.pesuhuone_termostaatti_firmware", "name": "_TZE200_e5hpkc6d TS0601" } ], "neighbors": [ { "device_type": "Coordinator", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x0000", "permit_joining": "Unknown", "depth": "0", "lqi": "85" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x1A74", "permit_joining": "Unknown", "depth": "15", "lqi": "216" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x2E69", "permit_joining": "Unknown", "depth": "15", "lqi": "208" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x2E69", "permit_joining": "Unknown", "depth": "15", "lqi": "208" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x3476", "permit_joining": "Unknown", "depth": "15", "lqi": "212" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x3D27", "permit_joining": "Unknown", "depth": "15", "lqi": "96" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x40BF", "permit_joining": "Unknown", "depth": "15", "lqi": "111" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x5939", "permit_joining": "Unknown", "depth": "15", "lqi": "117" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x5AF1", "permit_joining": "Unknown", "depth": "15", "lqi": "192" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x5FCD", "permit_joining": "Unknown", "depth": "15", "lqi": "100" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x6167", "permit_joining": "Unknown", "depth": "15", "lqi": "120" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x75CC", "permit_joining": "Unknown", "depth": "15", "lqi": "130" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x8131", "permit_joining": "Unknown", "depth": "15", "lqi": "135" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x88A8", "permit_joining": "Unknown", "depth": "15", "lqi": "76" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x8A65", "permit_joining": "Unknown", "depth": "15", "lqi": "105" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x92AB", "permit_joining": "Unknown", "depth": "15", "lqi": "60" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Parent", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0xB0AE", "permit_joining": "Unknown", "depth": "15", "lqi": "236" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0xB576", "permit_joining": "Unknown", "depth": "15", "lqi": "183" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0xC293", "permit_joining": "Unknown", "depth": "15", "lqi": "97" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0xC959", "permit_joining": "Unknown", "depth": "15", "lqi": "76" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0xCA7A", "permit_joining": "Unknown", "depth": "15", "lqi": "104" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0xCE73", "permit_joining": "Unknown", "depth": "15", "lqi": "146" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0xCF26", "permit_joining": "Unknown", "depth": "15", "lqi": "120" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0xD2C5", "permit_joining": "Unknown", "depth": "15", "lqi": "148" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0xD5C1", "permit_joining": "Unknown", "depth": "15", "lqi": "114" } ], "routes": [ { "dest_nwk": "0x0000", "route_status": "Active", "memory_constrained": true, "many_to_one": true, "route_record_required": false, "next_hop": "0xCE73" }, { "dest_nwk": "0xB0AE", "route_status": "Active", "memory_constrained": false, "many_to_one": false, "route_record_required": false, "next_hop": "0xB0AE" }, { "dest_nwk": "0xD2C5", "route_status": "Active", "memory_constrained": false, "many_to_one": false, "route_record_required": false, "next_hop": "0xD2C5" }, { "dest_nwk": "0x3476", "route_status": "Active", "memory_constrained": false, "many_to_one": false, "route_record_required": false, "next_hop": "0x8A65" }, { "dest_nwk": "0x75CC", "route_status": "Active", "memory_constrained": false, "many_to_one": false, "route_record_required": false, "next_hop": "0x75CC" }, { "dest_nwk": "0xC959", "route_status": "Active", "memory_constrained": false, "many_to_one": false, "route_record_required": false, "next_hop": "0x5FCD" }, { "dest_nwk": "0x5AF1", "route_status": "Active", "memory_constrained": false, "many_to_one": false, "route_record_required": false, "next_hop": "0x5AF1" } ], "endpoint_names": [ { "name": "SMART_PLUG" } ], "user_given_name": "pesuhuone_termostaatti", "device_reg_id": "717da20cc9ede42d286e4cb3e9d928c4", "area_id": "pesuhuone", "cluster_details": { "1": { "device_type": { "name": "SMART_PLUG", "id": 81 }, "profile_id": 260, "in_clusters": { "0x0000": { "endpoint_attribute": "basic", "attributes": { "0x0001": { "attribute_name": "app_version", "value": 65 }, "0x0004": { "attribute_name": "manufacturer", "value": "_TZE200_e5hpkc6d" }, "0x0005": { "attribute_name": "model", "value": "TS0601" } }, "unsupported_attributes": {} }, "0x0004": { "endpoint_attribute": "groups", "attributes": {}, "unsupported_attributes": {} }, "0x0005": { "endpoint_attribute": "scenes", "attributes": {}, "unsupported_attributes": {} }, "0xef00": { "endpoint_attribute": null, "attributes": {}, "unsupported_attributes": {} } }, "out_clusters": { "0x0019": { "endpoint_attribute": "ota", "attributes": { "0x0002": { "attribute_name": "current_file_version", "value": 65 } }, "unsupported_attributes": { "0x0002": { "attribute_name": "current_file_version" } } }, "0x000a": { "endpoint_attribute": "time", "attributes": {}, "unsupported_attributes": {} } } } } } } ```

Logs

Logs ```python [Paste the logs here] ```

Custom quirk

Custom quirk ```python """Avatto TRV devices support. Original from https://github.com/jacekk015/zha_quirks/blob/main/ts0601_thermostat_avatto.py Adapted for Futurehome which is the same Tuya whitelabel thermostat """ import logging from typing import Optional, Union import zigpy.types as t from zhaquirks import Bus, LocalDataCluster from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, INPUT_CLUSTERS, MODELS_INFO, OUTPUT_CLUSTERS, PROFILE_ID, ) from zhaquirks.tuya import ( NoManufacturerCluster, TuyaManufCluster, TuyaManufClusterAttributes, TuyaPowerConfigurationCluster, TuyaThermostat, TuyaThermostatCluster, TuyaTimePayload, TuyaUserInterfaceCluster, ) from zhaquirks.tuya.mcu import EnchantedDevice from zigpy.profiles import zha from zigpy.zcl import foundation from zigpy.zcl.clusters.general import ( AnalogOutput, Basic, GreenPowerProxy, Groups, OnOff, Ota, Scenes, Time, ) from zigpy.zcl.clusters.hvac import Thermostat _LOGGER = logging.getLogger(__name__) AVATTO_TARGET_TEMP_ATTR = 0x0210 # target room temp (degree) AVATTO_TEMPERATURE_ATTR = 0x0218 # current room temp (degree) AVATTO_MODE_ATTR = 0x0402 # [0] manual [1] schedule AVATTO_SYSTEM_MODE_ATTR = 0x0101 # device [0] off [1] on AVATTO_HEAT_STATE_ATTR = 0x0424 # [0] heating icon on [1] heating icon off BEOK_HEAT_STATE_ATTR = 0x0403 # [1] heating icon on [0] heating icon off AVATTO_CHILD_LOCK_ATTR = 0x0128 # [0] unlocked [1] locked AVATTO_TEMP_CALIBRATION_ATTR = 0x021B # temperature calibration (degree) AVATTO_MIN_TEMPERATURE_VAL = 500 # minimum limit of temperature setting (degree/100) AVATTO_MAX_TEMPERATURE_VAL = 3000 # maximum limit of temperature setting (degree/100) AvattoManufClusterSelf = {} class CustomTuyaOnOff(LocalDataCluster, OnOff): """Custom Tuya OnOff cluster.""" def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) self.endpoint.device.thermostat_onoff_bus.add_listener(self) # pylint: disable=R0201 def map_attribute(self, attribute, value): """Map standardized attribute value to dict of manufacturer values.""" return {} async def write_attributes(self, attributes, manufacturer=None): """Implement writeable attributes.""" records = self._write_attr_records(attributes) if not records: return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] manufacturer_attrs = {} for record in records: attr_name = self.attributes[record.attrid].name new_attrs = self.map_attribute(attr_name, record.value.value) _LOGGER.debug( "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " "with value %s to custom %s", self.endpoint.device.nwk, self.endpoint.endpoint_id, self.cluster_id, attr_name, record.attrid, repr(record.value.value), repr(new_attrs), ) manufacturer_attrs.update(new_attrs) if not manufacturer_attrs: return [ [ foundation.WriteAttributesStatusRecord( foundation.Status.FAILURE, r.attrid ) for r in records ] ] await AvattoManufClusterSelf[ self.endpoint.device.ieee ].endpoint.tuya_manufacturer.write_attributes( manufacturer_attrs, manufacturer=manufacturer ) return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 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.Status.FAILURE value = not value (res,) = await self.write_attributes( {"on_off": value}, manufacturer=manufacturer, ) return [command_id, res[0].status] return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] class AvattoManufCluster(TuyaManufClusterAttributes): """Manufacturer Specific Cluster of thermostatic valves.""" def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) global AvattoManufClusterSelf AvattoManufClusterSelf[self.endpoint.device.ieee] = self set_time_offset = 1970 server_commands = { 0x0000: foundation.ZCLCommandDef( "set_data", {"param": TuyaManufCluster.Command}, False, is_manufacturer_specific=False, ), 0x0010: foundation.ZCLCommandDef( "mcu_version_req", {"param": t.uint16_t}, False, is_manufacturer_specific=True, ), 0x0024: foundation.ZCLCommandDef( "set_time", {"param": TuyaTimePayload}, False, is_manufacturer_specific=False, ), } attributes = TuyaManufClusterAttributes.attributes.copy() attributes.update( { AVATTO_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True), AVATTO_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True), AVATTO_MODE_ATTR: ("mode", t.uint8_t, True), AVATTO_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True), AVATTO_HEAT_STATE_ATTR: ("heat_state", t.uint8_t, True), BEOK_HEAT_STATE_ATTR: ("beok_heat_state", t.uint8_t, True), AVATTO_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True), AVATTO_TEMP_CALIBRATION_ATTR: ("temperature_calibration", t.int32s, True), } ) DIRECT_MAPPED_ATTRS = { AVATTO_TEMPERATURE_ATTR: ( "local_temperature", lambda value: value * 100, lambda value: value * 10, ), AVATTO_TARGET_TEMP_ATTR: ( "occupied_heating_setpoint", lambda value: value * 100, lambda value: value * 10, ), } def _update_attribute(self, attrid, value): """Override default _update_attribute.""" super()._update_attribute(attrid, value) if attrid in self.DIRECT_MAPPED_ATTRS and value < 500: if self.endpoint.device.manufacturer in ( "_TZE200_2ekuz3dz", "_TZE200_g9a3awaj", ) or ( attrid == AVATTO_TEMPERATURE_ATTR and self.endpoint.device.manufacturer in ( "_TZE204_u9bfwha0", "_TZE200_u9bfwha0", "_TZE200_aoclfnxz", "_TZE204_aoclfnxz", ) ): self.endpoint.device.thermostat_bus.listener_event( "temperature_change", self.DIRECT_MAPPED_ATTRS[attrid][0], ( value if self.DIRECT_MAPPED_ATTRS[attrid][2] is None else self.DIRECT_MAPPED_ATTRS[attrid][2](value) ), ) else: self.endpoint.device.thermostat_bus.listener_event( "temperature_change", self.DIRECT_MAPPED_ATTRS[attrid][0], ( value if self.DIRECT_MAPPED_ATTRS[attrid][1] is None else self.DIRECT_MAPPED_ATTRS[attrid][1](value) ), ) if attrid == AVATTO_TEMP_CALIBRATION_ATTR: if self.endpoint.device.manufacturer in ( "_TZE200_2ekuz3dz", "_TZE200_g9a3awaj", ): self.endpoint.device.AvattoTempCalibration_bus.listener_event( "set_value", value / 10 ) else: self.endpoint.device.AvattoTempCalibration_bus.listener_event( "set_value", value ) if attrid == AVATTO_CHILD_LOCK_ATTR: self.endpoint.device.ui_bus.listener_event("child_lock_change", value) self.endpoint.device.thermostat_onoff_bus.listener_event( "child_lock_change", value ) elif attrid == AVATTO_MODE_ATTR: self.endpoint.device.thermostat_bus.listener_event("mode_change", value) elif attrid == AVATTO_HEAT_STATE_ATTR: if self.endpoint.device.manufacturer == "_TZE200_g9a3awaj": self.endpoint.device.thermostat_bus.listener_event( "state_change", value ) else: self.endpoint.device.thermostat_bus.listener_event( "state_change", not value ) elif attrid == BEOK_HEAT_STATE_ATTR: self.endpoint.device.thermostat_bus.listener_event("state_change", value) elif attrid == AVATTO_SYSTEM_MODE_ATTR: self.endpoint.device.thermostat_bus.listener_event( "system_mode_change", value ) class AvattoThermostat(TuyaThermostatCluster): """Thermostat cluster for thermostatic valves.""" class Preset(t.enum8): """Working modes of the thermostat.""" Away = 0x00 Schedule = 0x01 Manual = 0x02 Comfort = 0x03 Eco = 0x04 Boost = 0x05 Complex = 0x06 TempManual = 0x07 class WorkDays(t.enum8): """Workday configuration for scheduler operation mode.""" MonToFri = 0x00 MonToSat = 0x01 MonToSun = 0x02 class ForceValveState(t.enum8): """Force valve state option.""" Normal = 0x00 Open = 0x01 Close = 0x02 _CONSTANT_ATTRIBUTES = { 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, } attributes = TuyaThermostatCluster.attributes.copy() attributes.update( { 0x4002: ("operation_preset", Preset, True), } ) DIRECT_MAPPING_ATTRS = { "occupied_heating_setpoint": ( AVATTO_TARGET_TEMP_ATTR, lambda value: round(value / 100), lambda value: round(value / 10), ), } def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) self.endpoint.device.thermostat_bus.add_listener(self) self.endpoint.device.thermostat_bus.listener_event( "temperature_change", "min_heat_setpoint_limit", AVATTO_MIN_TEMPERATURE_VAL, ) self.endpoint.device.thermostat_bus.listener_event( "temperature_change", "max_heat_setpoint_limit", AVATTO_MAX_TEMPERATURE_VAL, ) def map_attribute(self, attribute, value): """Map standardized attribute value to dict of manufacturer values.""" if attribute in self.DIRECT_MAPPING_ATTRS: if self.endpoint.device.manufacturer in ( "_TZE200_2ekuz3dz", "_TZE200_g9a3awaj", ): return { self.DIRECT_MAPPING_ATTRS[attribute][0]: ( value if self.DIRECT_MAPPING_ATTRS[attribute][2] is None else self.DIRECT_MAPPING_ATTRS[attribute][2](value) ) } else: return { self.DIRECT_MAPPING_ATTRS[attribute][0]: ( value if self.DIRECT_MAPPING_ATTRS[attribute][1] is None else self.DIRECT_MAPPING_ATTRS[attribute][1](value) ) } if attribute == "operation_preset": if value == 1: return {AVATTO_MODE_ATTR: 1} if value == 2: return {AVATTO_MODE_ATTR: 0} if attribute in ("programing_oper_mode", "occupancy"): if attribute == "occupancy": occupancy = value oper_mode = self._attr_cache.get( self.attributes_by_name["programing_oper_mode"].id, self.ProgrammingOperationMode.Simple, ) else: occupancy = self._attr_cache.get( self.attributes_by_name["occupancy"].id, self.Occupancy.Occupied ) oper_mode = value if occupancy == self.Occupancy.Occupied: if oper_mode == self.ProgrammingOperationMode.Schedule_programming_mode: return {AVATTO_MODE_ATTR: 1} if oper_mode == self.ProgrammingOperationMode.Simple: return {AVATTO_MODE_ATTR: 0} self.error("Unsupported value for ProgrammingOperationMode") else: self.error("Unsupported value for Occupancy") if attribute == "system_mode": if value == self.SystemMode.Off: mode = 0 else: mode = 1 return {AVATTO_SYSTEM_MODE_ATTR: mode} def mode_change(self, value): """Preset Mode change.""" if value == 0: operation_preset = self.Preset.Manual prog_mode = self.ProgrammingOperationMode.Simple occupancy = self.Occupancy.Occupied else: operation_preset = self.Preset.Schedule prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode occupancy = self.Occupancy.Occupied self._update_attribute( self.attributes_by_name["programing_oper_mode"].id, prog_mode ) self._update_attribute(self.attributes_by_name["occupancy"].id, occupancy) self._update_attribute( self.attributes_by_name["operation_preset"].id, operation_preset ) def system_mode_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 AvattoUserInterface(TuyaUserInterfaceCluster): """HVAC User interface cluster for tuya electric heating thermostats.""" _CHILD_LOCK_ATTR = AVATTO_CHILD_LOCK_ATTR class AvattoChildLock(CustomTuyaOnOff): """On/Off cluster for the child lock function of the electric heating thermostats.""" def child_lock_change(self, value): """Child lock change.""" self._update_attribute(self.attributes_by_name["on_off"].id, value) def map_attribute(self, attribute, value): """Map standardized attribute value to dict of manufacturer values.""" if attribute == "on_off": return {AVATTO_CHILD_LOCK_ATTR: value} class AvattoTempCalibration(LocalDataCluster, AnalogOutput): """Analog output for Temp Calibration.""" def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) self.endpoint.device.AvattoTempCalibration_bus.add_listener(self) self._update_attribute( self.attributes_by_name["description"].id, "Temperature Calibration" ) self._update_attribute(self.attributes_by_name["max_present_value"].id, 10) self._update_attribute(self.attributes_by_name["min_present_value"].id, -10) self._update_attribute(self.attributes_by_name["resolution"].id, 0.1) self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) def set_value(self, value): """Set value.""" self._update_attribute(self.attributes_by_name["present_value"].id, value) def get_value(self): """Get value.""" return self._attr_cache.get(self.attributes_by_name["present_value"].id) async def write_attributes(self, attributes, manufacturer=None): """Override the default Cluster write_attributes.""" 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) if self.endpoint.device.manufacturer in ( "_TZE200_2ekuz3dz", "_TZE200_g9a3awaj", ): await AvattoManufClusterSelf[ self.endpoint.device.ieee ].endpoint.tuya_manufacturer.write_attributes( {AVATTO_TEMP_CALIBRATION_ATTR: value * 10}, manufacturer=None, ) else: await AvattoManufClusterSelf[ self.endpoint.device.ieee ].endpoint.tuya_manufacturer.write_attributes( {AVATTO_TEMP_CALIBRATION_ATTR: value}, manufacturer=None, ) return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) class Avatto(EnchantedDevice, TuyaThermostat): """Avatto Thermostatic radiator valve.""" def __init__(self, *args, **kwargs): """Init device.""" self.thermostat_onoff_bus = Bus() self.AvattoTempCalibration_bus = Bus() super().__init__(*args, **kwargs) signature = { # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] # output_clusters=[10, 25]> MODELS_INFO: [ ("_TZE200_ye5jkfsb", "TS0601"), ("_TZE200_aoclfnxz", "TS0601"), ("_TZE200_ztvwu4nk", "TS0601"), ("_TZE200_5toc8efa", "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, AvattoManufCluster, AvattoThermostat, AvattoUserInterface, TuyaPowerConfigurationCluster, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], }, 2: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, INPUT_CLUSTERS: [AvattoChildLock], OUTPUT_CLUSTERS: [], }, 3: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, INPUT_CLUSTERS: [AvattoTempCalibration], OUTPUT_CLUSTERS: [], }, } } class Beok(EnchantedDevice, TuyaThermostat): """Beok Thermostatic radiator valve.""" def __init__(self, *args, **kwargs): """Init device.""" self.thermostat_onoff_bus = Bus() self.AvattoTempCalibration_bus = Bus() super().__init__(*args, **kwargs) signature = { # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] # output_clusters=[10, 25]> MODELS_INFO: [ ("_TZE200_2ekuz3dz", "TS0601"), ("_TZE204_aoclfnxz", "TS0601"), ("_TZE204_u9bfwha0", "TS0601"), ("_TZE200_g9a3awaj", "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], }, 242: { PROFILE_ID: 41440, DEVICE_TYPE: 97, INPUT_CLUSTERS: [], OUTPUT_CLUSTERS: [GreenPowerProxy.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, AvattoManufCluster, AvattoThermostat, AvattoUserInterface, TuyaPowerConfigurationCluster, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], }, 2: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, INPUT_CLUSTERS: [AvattoChildLock], OUTPUT_CLUSTERS: [], }, 3: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, INPUT_CLUSTERS: [AvattoTempCalibration], OUTPUT_CLUSTERS: [], }, 242: { PROFILE_ID: 41440, DEVICE_TYPE: 97, INPUT_CLUSTERS: [], OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], }, } } class Beok2(EnchantedDevice, TuyaThermostat): """Beok Thermostatic radiator valve.""" def __init__(self, *args, **kwargs): """Init device.""" self.thermostat_onoff_bus = Bus() self.AvattoTempCalibration_bus = Bus() super().__init__(*args, **kwargs) signature = { # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] # output_clusters=[10, 25]> MODELS_INFO: [ ("_TZE200_g9a3awaj", "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, AvattoManufCluster, AvattoThermostat, AvattoUserInterface, TuyaPowerConfigurationCluster, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], }, 2: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, INPUT_CLUSTERS: [AvattoChildLock], OUTPUT_CLUSTERS: [], }, 3: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, INPUT_CLUSTERS: [AvattoTempCalibration], OUTPUT_CLUSTERS: [], }, } } class Futurehome(EnchantedDevice, TuyaThermostat): """Futurehome Thermostatic radiator valve.""" def __init__(self, *args, **kwargs): """Init device.""" self.thermostat_onoff_bus = Bus() self.AvattoTempCalibration_bus = Bus() super().__init__(*args, **kwargs) signature = { # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] # output_clusters=[10, 25]> MODELS_INFO: [ ("_TZE200_e5hpkc6d", "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, AvattoManufCluster, AvattoThermostat, AvattoUserInterface, TuyaPowerConfigurationCluster, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], }, 2: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, INPUT_CLUSTERS: [AvattoChildLock], OUTPUT_CLUSTERS: [], }, 3: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, INPUT_CLUSTERS: [AvattoTempCalibration], OUTPUT_CLUSTERS: [], }, } } ```

Additional information

No response

esuomi commented 1 month ago

As update (and to avoid staleness), modifying this ZHA quirk by adding the model to MODELS_INFO of either the Avatto entry or creating a copy of the Beok class and changing the model accordingly. and all controls become available with at least at glance reasonable values.

More official support would of course be nice, but this works as a great stopgap until such can be produced.

928driver commented 1 month ago

@esuomi , thanks for finding a solution for this :) But I am not able to get it to work for some reason. I downloaded the ts0601_thermostat_avatto.py file, added _TZE200_4hbx5cvx which is my futurehome thermostat, under models and modifed configuration.yaml to fetch the quirk. After rebooting, logs indicate that the quirk is picked up but when re-add the thermostat, it just shows up as a router. Do you have any idea why it is not working for me?

esuomi commented 1 month ago

@928driver For what it's worth, I didn't re-add the device, I simply loaded the quirk, rebooted and it jumped up and showed All The Things. It's survived multiple reboots and updates already (I'm the kind of person who keeps everything always updated to latest) so can't really help with that.

My modified quirk uses the lattest thing I mentioned, effectively I added this at the bottom of the quirk:

class Futurehome(EnchantedDevice, TuyaThermostat):
    """Futurehome Thermostatic radiator valve."""

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

    signature = {
        #  endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184]
        #  output_clusters=[10, 25]>
        MODELS_INFO: [
            ("_TZE200_e5hpkc6d", "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,
                    AvattoManufCluster,
                    AvattoThermostat,
                    AvattoUserInterface,
                    TuyaPowerConfigurationCluster,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
                INPUT_CLUSTERS: [AvattoChildLock],
                OUTPUT_CLUSTERS: [],
            },
            3: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE,
                INPUT_CLUSTERS: [AvattoTempCalibration],
                OUTPUT_CLUSTERS: [],
            },
        }
    }

which is mostly a copy-paste from the Beok2 class above, and it just worked, so...yay? If I had to guess, there's some specifics about device pairing which don't work correctly with this quirk but do for the generic handling, but it is beyond my knowledge to figure out what exactly could be the cause.

...of course, we could try emailing Futurehome as well...

jpakanen2 commented 3 weeks ago

@esuomi Do you know if OTA updates are working with ZHA using the configuration above?

esuomi commented 3 weeks ago

@jpakanen2 The device firmware reports itself as Unknown so probably not.

esuomi commented 2 weeks ago

Another minor update, seems Futurehome has added information about available channels and their data to their support page: https://support.futurehome.no/hc/en-no/articles/4834513064605-Channels

To quote,

kuva

We see the thermostat entering the system with 5 channels:

Channel 0 = Physical device for configurations and the like. Channel 1 = Thermostat. Channel 2 = Internal room sensor. Channel 3 = External room sensor. Channel 4 = Floor sensor.

Channel 0 should never be used for anything other than settings (parameter on Z-wave) or associations, i.e. it should not be placed in any room in the app.

Channel 1 (ch_1) represents the thermostat function on the device. Channel 1 is used for the “core function” of the device. On a dimmer, the actual dimming function will be on channel 1, while on the thermostat, temperature adjustment and energy measurement are on channel 1. This is the case in almost all situations, which is why we always place channel 1 in the room.

The next channels are often more device specific than channel 1, which is universal. On this thermostat, they each represent a temperature sensor. Therefore, place the sensor you want to use in the room to control it.

One thing I'd like to have added from here is actually ch_4 / floor sensor, but I have no idea how to do that. Anyway, interesting information nonetheless.