├── LICENSE ├── README.md ├── trv_saswell.py ├── ts0601_temphumid.py ├── ts0601_thermostat_avatto.py ├── ts0601_thermostat_avatto2.py ├── ts0601_thermostat_electsmart.py ├── ts0601_thermostat_zwt198.py ├── ts0601_trv_beca.py ├── ts0601_trv_etop.py ├── ts0601_trv_maxsmart.py ├── ts0601_trv_me167.py ├── ts0601_trv_moes.py ├── ts0601_trv_rtitek.py ├── ts0601_trv_rtitek2.py ├── ts0601_trv_siterwell.py └── ts0601_trv_zonnsmart.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jacek Kończewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zha_quirks 2 | All quirks in one place: 3 | 4 | # Usage 5 | 6 | 1. Create a custom quirk dir in HA, e.g., /config/custom_zha_quirks 7 | 2. In configuration.yaml, point to this directory: 8 | ``` 9 | zha: 10 | custom_quirks_path: /config/custom_zha_quirks/ 11 | ``` 12 | 3. Download and put the chosen quirk file in the directory above. 13 | 4. Restart Home Assistant 14 | 15 | # In case of errors or to support new function 16 | 17 | 1. Enable debug log level 18 | ``` 19 | logger: 20 | default: info 21 | logs: 22 | homeassistant.components.zha: debug 23 | zigpy: debug 24 | zhaquirks: debug 25 | ``` 26 | 2. Restart Home Assistant 27 | 3. Repeat actions that create errors or enable/disable functions on the device 28 | 4. Wait for another minimum 5 minutes 29 | 5. Download Home Assistant logs - attach them as a file in new issue 30 | 31 | # Supported TRV's and Thermostats 32 | 33 | ## trv_saswell.py 34 | ``` 35 | MODELS_INFO: [ 36 | ("_TZE200_yw7cahqs", "TS0601"), 37 | ("_TZE200_c88teujp", "TS0601"), 38 | ("_TZE200_azqp6ssj", "TS0601"), 39 | ("_TZE200_9gvruqf5", "TS0601"), 40 | ("_TZE200_zuhszj9s", "TS0601"), 41 | ("_TZE200_zr9c0day", "TS0601"), 42 | ("_TZE200_h4cgnbzg", "TS0601"), 43 | ("_TZE200_0dvm9mva", "TS0601"), 44 | ("_TZE200_exfrnlow", "TS0601"), 45 | ("_TZE200_9m4kmbfu", "TS0601"), 46 | ("_TZE200_3yp57tby", "TS0601"), 47 | ("_TZE200_mz5y07w2", "TS0601"), #Garza Smart TRV 48 | ], 49 | MODELS_INFO: [ 50 | ("_TYST11_KGbxAXL2", "GbxAXL2"), 51 | ("_TYST11_c88teujp", "88teujp"), 52 | ("_TYST11_azqp6ssj", "zqp6ssj"), 53 | ("_TYST11_yw7cahqs", "w7cahqs"), 54 | ("_TYST11_9gvruqf5", "gvruqf5"), 55 | ("_TYST11_zuhszj9s", "uhszj9s"), 56 | ("_TYST11_caj4jz0i", "aj4jz0i"), 57 | ], 58 | ``` 59 | ## ts0601_temphumid.py 60 | ``` 61 | MODELS_INFO: [ 62 | ("_TZE200_bq5c8xfe", "TS0601"), 63 | ("_TZE200_locansqn", "TS0601"), 64 | ], 65 | ``` 66 | ## ts0601_thermostat_avatto.py 67 | ``` 68 | MODELS_INFO: [ 69 | ("_TZE200_ye5jkfsb", "TS0601"), 70 | ("_TZE200_aoclfnxz", "TS0601"), 71 | ("_TZE200_ztvwu4nk", "TS0601"), 72 | ("_TZE200_5toc8efa", "TS0601"), 73 | ("_TZE200_u9bfwha0", "TS0601"), 74 | ], 75 | MODELS_INFO: [ 76 | ("_TZE200_2ekuz3dz", "TS0601"), 77 | ("_TZE204_aoclfnxz", "TS0601"), 78 | ("_TZE204_u9bfwha0", "TS0601"), 79 | ("_TZE200_g9a3awaj", "TS0601"), 80 | ], 81 | ``` 82 | ## ts0601_thermostat_avatto2.py 83 | ``` 84 | MODELS_INFO: [ 85 | ("_TZE204_lzriup1j", "TS0601"), #Avatto - Electric Heating version 86 | ], 87 | ``` 88 | ## ts0601_trv_beca.py 89 | ``` 90 | MODELS_INFO: [ 91 | ("_TZE200_b6wax7g0", "TS0601"), 92 | ], 93 | ``` 94 | ## ts0601_trv_etop.py 95 | ``` 96 | MODELS_INFO: [ 97 | ("_TZE200_0hg58wyk", "TS0601"), 98 | ], 99 | ``` 100 | ## ts0601_trv_maxsmart.py 101 | ``` 102 | MODELS_INFO: [ 103 | ("_TZE200_chyvmhay", "TS0601"), 104 | ("_TZE200_i48qyn9s", "TS0601"), #ESSENTIALS Smart Home Heizkörperthermostat 105 | ("_TZE200_qc4fpmcn", "TS0601"), 106 | ("_TZE200_fhn3negr", "TS0601"), 107 | ("_TZE200_thbr5z34", "TS0601"), 108 | ("_TZE200_uiyqstza", "TS0601"), 109 | ], 110 | ``` 111 | ## ts0601_trv_me167.py 112 | ``` 113 | MODELS_INFO: [ 114 | ("_TZE200_bvu2wnxz", "TS0601"), 115 | ("_TZE200_6rdj8dzm", "TS0601"), 116 | ("_TZE200_p3dbf6qs", "TS0601"), # model: 'ME168', vendor: 'Avatto' 117 | ("_TZE200_rxntag7i", "TS0601"), # model: 'ME168', vendor: 'Avatto' 118 | ("_TZE200_rxq4iti9", "TS0601"), 119 | ("_TZE200_9xfjixap", "TS0601"), 120 | ], 121 | ``` 122 | ## ts0601_trv_moes.py 123 | ``` 124 | MODELS_INFO: [ 125 | ("_TZE200_ckud7u2l", "TS0601"), 126 | ("_TZE200_ywdxldoj", "TS0601"), 127 | ("_TZE200_do5qy8zo", "TS0601"), 128 | ("_TZE200_cwnjrr72", "TS0601"), 129 | ("_TZE200_pvvbommb", "TS0601"), 130 | ("_TZE200_9sfg7gm0", "TS0601"), 131 | ("_TZE200_2atgpdho", "TS0601"), 132 | ("_TZE200_cpmgn2cf", "TS0601"), 133 | ("_TZE200_8thwkzxl", "TS0601"), 134 | ("_TZE200_4eeyebrt", "TS0601"), 135 | ("_TZE200_8whxpsiw", "TS0601"), 136 | ("_TZE200_xby0s3ta", "TS0601"), 137 | ("_TZE200_7fqkphoq", "TS0601"), 138 | 139 | ], 140 | MODELS_INFO: [ 141 | ("_TYST11_ckud7u2l", "kud7u2l"), 142 | ("_TYST11_ywdxldoj", "wdxldoj"), 143 | ("_TYST11_cwnjrr72", "wnjrr72"), 144 | ], 145 | ``` 146 | ## ts0601_trv_rtitek.py 147 | ``` 148 | MODELS_INFO: [ 149 | ("_TZE200_a4bpgplm", "TS0601"), 150 | ("_TZE200_dv8abrrz", "TS0601"), 151 | ("_TZE200_z1tyspqw", "TS0601"), 152 | ("_TZE200_rtrmfadk", "TS0601"), 153 | ], 154 | ``` 155 | ## ts0601_trv_rtitek2.py 156 | ``` 157 | MODELS_INFO: [ 158 | ("_TZE200_bvrlmajk", "TS0601"), 159 | #MOES TRV 160 | ("_TZE204_9mjy74mp", "TS0601"), 161 | ("_TZE200_9mjy74mp", "TS0601"), 162 | ("_TZE200_rtrmfadk", "TS0601"), 163 | ], 164 | ``` 165 | ## ts0601_trv_siterwell.py 166 | ``` 167 | MODELS_INFO: [ 168 | ("_TYST11_jeaxp72v", "eaxp72v"), 169 | ("_TYST11_kfvq6avy", "fvq6avy"), 170 | ("_TYST11_zivfvd7h", "ivfvd7h"), 171 | ("_TYST11_hhrtiq0x", "hrtiq0x"), 172 | ("_TYST11_ps5v5jor", "s5v5jor"), 173 | ("_TYST11_owwdxjbx", "wwdxjbx"), 174 | ("_TYST11_8daqwrsj", "daqwrsj"), 175 | ("_TYST11_czk78ptr", "zk78ptr"), 176 | ], 177 | MODELS_INFO: [ 178 | ("_TZE200_jeaxp72v", "TS0601"), 179 | ("_TZE200_kfvq6avy", "TS0601"), 180 | ("_TZE200_zivfvd7h", "TS0601"), 181 | ("_TZE200_hhrtiq0x", "TS0601"), 182 | ("_TZE200_ps5v5jor", "TS0601"), 183 | ("_TZE200_owwdxjbx", "TS0601"), 184 | ("_TZE200_8daqwrsj", "TS0601"), 185 | ("_TZE200_2cs6g9i7", "TS0601"), # Brennenstuhl HT CZ 01 186 | ], 187 | ``` 188 | ## ts0601_trv_zonnsmart.py 189 | ``` 190 | MODELS_INFO: [ 191 | ("_TZE200_7yoranx2", "TS0601"), # MOES TV01 ZTRV-ZX-TV01-MS 192 | ("_TZE200_e9ba97vf", "TS0601"), # Zonnsmart TV01-ZG 193 | ("_TZE200_hue3yfsn", "TS0601"), # Zonnsmart TV02-ZG 194 | ("_TZE200_husqqvux", "TS0601"), # Tesla Smart TSL-TRV-TV01ZG 195 | ("_TZE200_kly8gjlz", "TS0601"), # EARU TV05-ZG 196 | ("_TZE200_lnbfnyxd", "TS0601"), # Tesla Smart TSL-TRV-TV01ZG 197 | ("_TZE200_mudxchsu", "TS0601"), # Foluu TV05 198 | ("_TZE200_kds0pmmv", "TS0601"), # MOES TV02 199 | ("_TZE200_sur6q7ko", "TS0601"), # LSC Smart Connect 3012732 200 | ("_TZE200_lllliz3p", "TS0601"), # tuya TV02-Zigbee 201 | ], 202 | ``` 203 | ## ts0601_thermostat_zwt198.py 204 | ``` 205 | MODELS_INFO: [ 206 | ("_TZE200_viy9ihs7", "TS0601"), 207 | ], 208 | ``` 209 | -------------------------------------------------------------------------------- /trv_saswell.py: -------------------------------------------------------------------------------- 1 | """Map from manufacturer to standard clusters for thermostatic valves.""" 2 | import logging 3 | from typing import Optional, Union 4 | 5 | import zigpy.types as t 6 | from zhaquirks import Bus, LocalDataCluster 7 | from zhaquirks.const import ( 8 | DEVICE_TYPE, 9 | ENDPOINTS, 10 | INPUT_CLUSTERS, 11 | MODELS_INFO, 12 | OUTPUT_CLUSTERS, 13 | PROFILE_ID, 14 | ) 15 | from zhaquirks.tuya import ( 16 | TuyaManufCluster, 17 | TuyaManufClusterAttributes, 18 | TuyaThermostat, 19 | TuyaThermostatCluster, 20 | TuyaTimePayload, 21 | TuyaUserInterfaceCluster, 22 | ) 23 | from zigpy.profiles import zha 24 | from zigpy.zcl import foundation 25 | from zigpy.zcl.clusters.general import ( 26 | AnalogOutput, 27 | Basic, 28 | Groups, 29 | Identify, 30 | OnOff, 31 | Ota, 32 | PowerConfiguration, 33 | Scenes, 34 | Time, 35 | ) 36 | from zigpy.zcl.clusters.hvac import Thermostat 37 | 38 | # Setup logger 39 | _LOGGER = logging.getLogger(__name__) 40 | 41 | 42 | SASWELL_CHILD_LOCK_ATTR = 0x0128 # [0/1] on/off 296 43 | SASWELL_ANTI_FREEZE_ATTR = 0x010A # [0/1] on/off 266 44 | SASWELL_WINDOW_DETECT_ATTR = 0x0108 # [0/1] on/off 264 45 | SASWELL_LIMESCALE_PROTECT_ATTR = 0x0182 # [0/1] on/off 386 46 | SASWELL_TEMP_CORRECTION_ATTR = 0x021B # uint32 - temp correction 539 47 | SASWELL_ROOM_TEMP_ATTR = 0x0266 # uint32 - current room temp 614 48 | SASWELL_AWAY_MODE_ATTR = 0x016A # [0/1] on/off 362 49 | SASWELL_SCHEDULE_MODE_ATTR = 0x016C # [0/1] on/off 364 50 | SASWELL_ONOFF_ATTR = 0x0165 # [0/1] on/off 357 51 | SASWELL_TARGET_TEMP_ATTR = 0x0267 # uint32 - target temp 615 52 | SASWELL_BATTERY_ALARM_ATTR = 0x569 # [0/1] on/off - battery low 1385 53 | 54 | # Global 55 | SaswellManufClusterSelf = {} 56 | 57 | 58 | class CustomTuyaOnOff(LocalDataCluster, OnOff): 59 | def __init__(self, *args, **kwargs): 60 | """Init.""" 61 | super().__init__(*args, **kwargs) 62 | self.endpoint.device.thermostat_onoff_bus.add_listener(self) 63 | 64 | # pylint: disable=R0201 65 | def map_attribute(self, attribute, value): 66 | """Map standardized attribute value to dict of manufacturer values.""" 67 | return {} 68 | 69 | async def write_attributes(self, attributes, manufacturer=None): 70 | """Implement writeable attributes.""" 71 | 72 | records = self._write_attr_records(attributes) 73 | 74 | if not records: 75 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 76 | 77 | manufacturer_attrs = {} 78 | for record in records: 79 | attr_name = self.attributes[record.attrid].name 80 | new_attrs = self.map_attribute(attr_name, record.value.value) 81 | 82 | _LOGGER.debug( 83 | "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " 84 | "with value %s to custom %s", 85 | self.endpoint.device.nwk, 86 | self.endpoint.endpoint_id, 87 | self.cluster_id, 88 | attr_name, 89 | record.attrid, 90 | repr(record.value.value), 91 | repr(new_attrs), 92 | ) 93 | 94 | manufacturer_attrs.update(new_attrs) 95 | 96 | if not manufacturer_attrs: 97 | return [ 98 | [ 99 | foundation.WriteAttributesStatusRecord( 100 | foundation.Status.FAILURE, r.attrid 101 | ) 102 | for r in records 103 | ] 104 | ] 105 | 106 | await SaswellManufClusterSelf[ 107 | self.endpoint.device.ieee 108 | ].endpoint.tuya_manufacturer.write_attributes( 109 | manufacturer_attrs, manufacturer=manufacturer 110 | ) 111 | 112 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 113 | 114 | async def command( 115 | self, 116 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 117 | *args, 118 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 119 | expect_reply: bool = True, 120 | tsn: Optional[Union[int, t.uint8_t]] = None, 121 | ): 122 | """Override the default Cluster command.""" 123 | 124 | if command_id in (0x0000, 0x0001, 0x0002): 125 | 126 | if command_id == 0x0000: 127 | value = False 128 | elif command_id == 0x0001: 129 | value = True 130 | else: 131 | attrid = self.attributes_by_name["on_off"].id 132 | success, _ = await self.read_attributes( 133 | (attrid,), manufacturer=manufacturer 134 | ) 135 | try: 136 | value = success[attrid] 137 | except KeyError: 138 | return foundation.Status.FAILURE 139 | value = not value 140 | 141 | (res,) = await self.write_attributes( 142 | {"on_off": value}, 143 | manufacturer=manufacturer, 144 | ) 145 | return [command_id, res[0].status] 146 | 147 | return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] 148 | 149 | 150 | class SaswellManufCluster(TuyaManufClusterAttributes): 151 | """Manufacturer specific cluster for Tuya converting attributes <-> commands.""" 152 | 153 | def __init__(self, *args, **kwargs): 154 | """Init.""" 155 | super().__init__(*args, **kwargs) 156 | global SaswellManufClusterSelf 157 | SaswellManufClusterSelf[self.endpoint.device.ieee] = self 158 | 159 | server_commands = { 160 | 0x0000: foundation.ZCLCommandDef( 161 | "set_data", 162 | {"param": TuyaManufCluster.Command}, 163 | False, 164 | is_manufacturer_specific=False, 165 | ), 166 | 0x0010: foundation.ZCLCommandDef( 167 | "mcu_version_req", 168 | {"param": t.uint16_t}, 169 | False, 170 | is_manufacturer_specific=True, 171 | ), 172 | 0x0024: foundation.ZCLCommandDef( 173 | "set_time", 174 | {"param": TuyaTimePayload}, 175 | False, 176 | is_manufacturer_specific=True, 177 | ), 178 | } 179 | 180 | attributes = TuyaManufClusterAttributes.attributes.copy() 181 | attributes.update( 182 | { 183 | SASWELL_ONOFF_ATTR: ("on_off", t.uint8_t, True), 184 | SASWELL_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True), 185 | SASWELL_ROOM_TEMP_ATTR: ("current_room_temp", t.uint32_t, True), 186 | SASWELL_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True), 187 | SASWELL_SCHEDULE_MODE_ATTR: ("schedule_mode", t.uint8_t, True), 188 | SASWELL_WINDOW_DETECT_ATTR: ("window_detection", t.uint8_t, True), 189 | SASWELL_ANTI_FREEZE_ATTR: ("anti_freeze_protection", t.uint8_t, True), 190 | SASWELL_LIMESCALE_PROTECT_ATTR: ("limescale_protection", t.uint8_t, True), 191 | SASWELL_AWAY_MODE_ATTR: ("away_mode", t.uint8_t, True), 192 | SASWELL_BATTERY_ALARM_ATTR: ("battery_low", t.uint8_t, True), 193 | SASWELL_TEMP_CORRECTION_ATTR: ( 194 | "room_temperature_correction", 195 | t.int32s, 196 | True, 197 | ), 198 | } 199 | ) 200 | 201 | DIRECT_MAPPED_ATTRS = { 202 | SASWELL_ROOM_TEMP_ATTR: ("local_temperature", lambda value: value * 10), 203 | SASWELL_TARGET_TEMP_ATTR: ( 204 | "occupied_heating_setpoint", 205 | lambda value: value * 10, 206 | ), 207 | SASWELL_TEMP_CORRECTION_ATTR: ("local_temperature_calibration", None), 208 | } 209 | 210 | def _update_attribute(self, attrid, value): 211 | super()._update_attribute(attrid, value) 212 | if attrid in self.DIRECT_MAPPED_ATTRS: 213 | self.endpoint.device.thermostat_bus.listener_event( 214 | "temperature_change", 215 | self.DIRECT_MAPPED_ATTRS[attrid][0], 216 | value 217 | if self.DIRECT_MAPPED_ATTRS[attrid][1] is None 218 | else self.DIRECT_MAPPED_ATTRS[attrid][1](value), 219 | ) 220 | 221 | if attrid == SASWELL_ONOFF_ATTR: 222 | self.endpoint.device.thermostat_bus.listener_event("on_off_event", value) 223 | elif attrid == SASWELL_SCHEDULE_MODE_ATTR: 224 | self.endpoint.device.thermostat_onoff_bus.listener_event( 225 | "schedule_mode_change", value 226 | ) 227 | elif attrid == SASWELL_AWAY_MODE_ATTR: 228 | self.endpoint.device.thermostat_onoff_bus.listener_event( 229 | "away_mode_change", value 230 | ) 231 | elif attrid == SASWELL_CHILD_LOCK_ATTR: 232 | self.endpoint.device.ui_bus.listener_event("child_lock_change", value) 233 | self.endpoint.device.thermostat_onoff_bus.listener_event( 234 | "child_lock_change", value 235 | ) 236 | elif attrid == SASWELL_WINDOW_DETECT_ATTR: 237 | self.endpoint.device.thermostat_onoff_bus.listener_event( 238 | "window_detect_change", value 239 | ) 240 | elif attrid == SASWELL_ANTI_FREEZE_ATTR: 241 | self.endpoint.device.thermostat_onoff_bus.listener_event( 242 | "anti_freeze_change", value 243 | ) 244 | elif attrid == SASWELL_LIMESCALE_PROTECT_ATTR: 245 | self.endpoint.device.thermostat_onoff_bus.listener_event( 246 | "limescale_protection_change", value 247 | ) 248 | elif attrid == SASWELL_BATTERY_ALARM_ATTR: 249 | self.endpoint.device.battery_bus.listener_event( 250 | "battery_alarm_event", value 251 | ) 252 | elif attrid == SASWELL_TEMP_CORRECTION_ATTR: 253 | self.endpoint.device.SaswellTempCalibration_bus.listener_event( 254 | "set_value", value 255 | ) 256 | elif attrid in (SASWELL_ROOM_TEMP_ATTR, SASWELL_TARGET_TEMP_ATTR): 257 | self.endpoint.device.thermostat_bus.listener_event( 258 | "hass_climate_state_change", attrid, value 259 | ) 260 | 261 | 262 | class SaswellChildLock(CustomTuyaOnOff): 263 | """Child Lock setting support. Please remember that CL has to be set manually on the device. This only controls if locking is possible at all""" 264 | 265 | def child_lock_change(self, value): 266 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 267 | 268 | def map_attribute(self, attribute, value): 269 | if attribute == "on_off": 270 | return {SASWELL_CHILD_LOCK_ATTR: value} 271 | 272 | 273 | class SaswellWindowDectection(CustomTuyaOnOff): 274 | """Open Window Detection support""" 275 | 276 | def window_detect_change(self, value): 277 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 278 | 279 | def map_attribute(self, attribute, value): 280 | if attribute == "on_off": 281 | return {SASWELL_WINDOW_DETECT_ATTR: value} 282 | 283 | 284 | class SaswellAntiFreezeDectection(CustomTuyaOnOff): 285 | """Anti-Freeze support""" 286 | 287 | def anti_freeze_change(self, value): 288 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 289 | 290 | def map_attribute(self, attribute, value): 291 | if attribute == "on_off": 292 | return {SASWELL_ANTI_FREEZE_ATTR: value} 293 | 294 | 295 | class SaswellLimescaleProtectionDectection(CustomTuyaOnOff): 296 | """Limescale Protection support""" 297 | 298 | def limescale_protection_change(self, value): 299 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 300 | 301 | def map_attribute(self, attribute, value): 302 | if attribute == "on_off": 303 | return {SASWELL_LIMESCALE_PROTECT_ATTR: value} 304 | 305 | 306 | class SaswellScheduleModeDectection(CustomTuyaOnOff): 307 | """Schedule Mode On/Off support""" 308 | 309 | def schedule_mode_change(self, value): 310 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 311 | 312 | def map_attribute(self, attribute, value): 313 | if attribute == "on_off": 314 | return {SASWELL_SCHEDULE_MODE_ATTR: value} 315 | 316 | 317 | class SaswellAwayModeDectection(CustomTuyaOnOff): 318 | """Away Mode On/Off support""" 319 | 320 | def away_mode_change(self, value): 321 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 322 | 323 | def map_attribute(self, attribute, value): 324 | if attribute == "on_off": 325 | return {SASWELL_AWAY_MODE_ATTR: value} 326 | 327 | 328 | class SaswellPowerConfigurationCluster(LocalDataCluster, PowerConfiguration): 329 | """Power configuration cluster.""" 330 | 331 | def __init__(self, *args, **kwargs): 332 | """Init.""" 333 | super().__init__(*args, **kwargs) 334 | self.endpoint.device.battery_bus.add_listener(self) 335 | 336 | def battery_alarm_event(self, value): 337 | """Handle reported battery state.""" 338 | _LOGGER.debug("reported battery alert: %d", value) 339 | if value == 1: # alert 340 | self._update_attribute( 341 | self.attributes_by_name["battery_percentage_remaining"].id, 0 342 | ) # report 0% battery 343 | else: 344 | self._update_attribute( 345 | self.attributes_by_name["battery_percentage_remaining"].id, 200 346 | ) # report 100% battery 347 | 348 | 349 | class SaswellTempCalibration(LocalDataCluster, AnalogOutput): 350 | """Analog output for Temp Calibration""" 351 | 352 | def __init__(self, *args, **kwargs): 353 | """Init.""" 354 | super().__init__(*args, **kwargs) 355 | self.endpoint.device.SaswellTempCalibration_bus.add_listener(self) 356 | self._update_attribute( 357 | self.attributes_by_name["description"].id, "Temperature Calibration" 358 | ) 359 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 6) 360 | self._update_attribute(self.attributes_by_name["min_present_value"].id, -6) 361 | self._update_attribute(self.attributes_by_name["resolution"].id, 1) 362 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 363 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 364 | 365 | def set_value(self, value): 366 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 367 | 368 | def get_value(self): 369 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 370 | 371 | async def write_attributes(self, attributes, manufacturer=None): 372 | for attrid, value in attributes.items(): 373 | if isinstance(attrid, str): 374 | attrid = self.attributes_by_name[attrid].id 375 | if attrid not in self.attributes: 376 | self.error("%d is not a valid attribute id", attrid) 377 | continue 378 | self._update_attribute(attrid, value) 379 | 380 | await SaswellManufClusterSelf[ 381 | self.endpoint.device.ieee 382 | ].endpoint.tuya_manufacturer.write_attributes( 383 | {SASWELL_TEMP_CORRECTION_ATTR: value}, 384 | manufacturer=None, 385 | ) 386 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 387 | 388 | 389 | class SaswellThermostatCluster(TuyaThermostatCluster): 390 | """Thermostat cluster for Tuya thermostats.""" 391 | 392 | _CONSTANT_ATTRIBUTES = { 393 | 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, 394 | } 395 | 396 | DIRECT_MAPPING_ATTRS = { 397 | "local_temperature_calibration": ( 398 | SASWELL_TEMP_CORRECTION_ATTR, 399 | lambda value: value, 400 | ), 401 | "occupied_heating_setpoint": ( 402 | SASWELL_TARGET_TEMP_ATTR, 403 | lambda value: round(value / 10), 404 | ), 405 | } 406 | 407 | def __init__(self, *args, **kwargs): 408 | """Init.""" 409 | super().__init__(*args, **kwargs) 410 | self.endpoint.device.thermostat_bus.add_listener(self) 411 | self.endpoint.device.thermostat_bus.listener_event( 412 | "temperature_change", 413 | "min_heat_setpoint_limit", 414 | 500, 415 | ) 416 | self.endpoint.device.thermostat_bus.listener_event( 417 | "temperature_change", 418 | "max_heat_setpoint_limit", 419 | 3000, 420 | ) 421 | 422 | def map_attribute(self, attribute, value): 423 | """Map standardized attribute value to dict of manufacturer values.""" 424 | 425 | if attribute in self.DIRECT_MAPPING_ATTRS: 426 | return { 427 | self.DIRECT_MAPPING_ATTRS[attribute][0]: value 428 | if self.DIRECT_MAPPING_ATTRS[attribute][1] is None 429 | else self.DIRECT_MAPPING_ATTRS[attribute][1](value) 430 | } 431 | 432 | if attribute == "system_mode": 433 | if value == self.SystemMode.Off: 434 | return {SASWELL_ONOFF_ATTR: 0} 435 | if value == self.SystemMode.Heat: 436 | return {SASWELL_ONOFF_ATTR: 1} 437 | 438 | def on_off_event(self, value): 439 | """Handle on/off event""" 440 | if value == 1: 441 | self._update_attribute( 442 | self.attributes_by_name["system_mode"].id, Thermostat.SystemMode.Heat 443 | ) 444 | self._update_attribute( 445 | self.attributes_by_name["running_mode"].id, Thermostat.RunningMode.Heat 446 | ) 447 | self._update_attribute( 448 | self.attributes_by_name["running_state"].id, 449 | Thermostat.RunningState.Heat_State_On, 450 | ) 451 | _LOGGER.debug("reported system_mode: heat") 452 | else: 453 | self._update_attribute( 454 | self.attributes_by_name["system_mode"].id, Thermostat.SystemMode.Off 455 | ) 456 | self._update_attribute( 457 | self.attributes_by_name["running_mode"].id, Thermostat.RunningMode.Off 458 | ) 459 | self._update_attribute( 460 | self.attributes_by_name["running_state"].id, 461 | Thermostat.RunningState.Idle, 462 | ) 463 | _LOGGER.debug("reported system_mode: off") 464 | _LOGGER.debug("on/off event with value %d", value) 465 | 466 | def hass_climate_state_change(self, attrid, value): 467 | """Update of the HASS Climate gui state according to temp difference.""" 468 | if ( 469 | self._attr_cache.get(self.attributes_by_name["system_mode"].id) 470 | != Thermostat.SystemMode.Heat 471 | ): 472 | self.endpoint.device.thermostat_bus.listener_event("state_change", 0) 473 | return 474 | if attrid == SASWELL_ROOM_TEMP_ATTR: 475 | temp_current = value * 10 476 | temp_set = self._attr_cache.get( 477 | self.attributes_by_name["occupied_heating_setpoint"].id 478 | ) 479 | else: 480 | temp_set = value * 10 481 | temp_current = self._attr_cache.get( 482 | self.attributes_by_name["local_temperature"].id 483 | ) 484 | 485 | state = 0 if (int(temp_current) >= int(temp_set + 2)) else 1 486 | self.endpoint.device.thermostat_bus.listener_event("state_change", state) 487 | 488 | 489 | class SaswellUserInterface(TuyaUserInterfaceCluster): 490 | """HVAC User interface cluster for tuya electric heating thermostats.""" 491 | 492 | _CHILD_LOCK_ATTR = SASWELL_CHILD_LOCK_ATTR 493 | 494 | 495 | class Saswell_Thermostat_TZE200(TuyaThermostat): 496 | """Saswell Thermostatic Radiator Valve.""" 497 | 498 | def __init__(self, *args, **kwargs): 499 | """Init device.""" 500 | self.thermostat_onoff_bus = Bus() 501 | self.SaswellTempCalibration_bus = Bus() 502 | super().__init__(*args, **kwargs) 503 | 504 | signature = { 505 | # (endpoint=1, profile=260, device_type=81, device_version=1, input_clusters=[0, 4, 5, 61184], output_clusters=[25, 10]) 506 | # 621 | MODELS_INFO: [ 622 | ("_TYST11_KGbxAXL2", "GbxAXL2"), 623 | ("_TYST11_c88teujp", "88teujp"), 624 | ("_TYST11_azqp6ssj", "zqp6ssj"), 625 | ("_TYST11_yw7cahqs", "w7cahqs"), 626 | ("_TYST11_9gvruqf5", "gvruqf5"), 627 | ("_TYST11_zuhszj9s", "uhszj9s"), 628 | ("_TYST11_caj4jz0i", "aj4jz0i"), 629 | ], 630 | ENDPOINTS: { 631 | 1: { 632 | PROFILE_ID: zha.PROFILE_ID, 633 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 634 | INPUT_CLUSTERS: [ 635 | Basic.cluster_id, 636 | Identify.cluster_id, 637 | ], 638 | OUTPUT_CLUSTERS: [ 639 | Identify.cluster_id, 640 | Ota.cluster_id, 641 | ], 642 | } 643 | }, 644 | } 645 | replacement = { 646 | ENDPOINTS: { 647 | 1: { 648 | PROFILE_ID: zha.PROFILE_ID, 649 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 650 | INPUT_CLUSTERS: [ 651 | Basic.cluster_id, 652 | Identify.cluster_id, 653 | SaswellManufCluster, 654 | SaswellThermostatCluster, 655 | SaswellWindowDectection, 656 | SaswellPowerConfigurationCluster, 657 | ], 658 | OUTPUT_CLUSTERS: [ 659 | Ota.cluster_id, 660 | Identify.cluster_id, 661 | ], 662 | }, 663 | 2: { 664 | PROFILE_ID: zha.PROFILE_ID, 665 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 666 | INPUT_CLUSTERS: [ 667 | SaswellChildLock, 668 | ], 669 | OUTPUT_CLUSTERS: [], 670 | }, 671 | 3: { 672 | PROFILE_ID: zha.PROFILE_ID, 673 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 674 | INPUT_CLUSTERS: [ 675 | SaswellAntiFreezeDectection, 676 | ], 677 | OUTPUT_CLUSTERS: [], 678 | }, 679 | 4: { 680 | PROFILE_ID: zha.PROFILE_ID, 681 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 682 | INPUT_CLUSTERS: [ 683 | SaswellLimescaleProtectionDectection, 684 | ], 685 | OUTPUT_CLUSTERS: [], 686 | }, 687 | 5: { 688 | PROFILE_ID: zha.PROFILE_ID, 689 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 690 | INPUT_CLUSTERS: [ 691 | SaswellScheduleModeDectection, 692 | ], 693 | OUTPUT_CLUSTERS: [], 694 | }, 695 | 6: { 696 | PROFILE_ID: zha.PROFILE_ID, 697 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 698 | INPUT_CLUSTERS: [ 699 | SaswellAwayModeDectection, 700 | ], 701 | OUTPUT_CLUSTERS: [], 702 | }, 703 | 7: { 704 | PROFILE_ID: zha.PROFILE_ID, 705 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 706 | INPUT_CLUSTERS: [ 707 | SaswellTempCalibration, 708 | ], 709 | OUTPUT_CLUSTERS: [], 710 | }, 711 | } 712 | } 713 | -------------------------------------------------------------------------------- /ts0601_temphumid.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any, Dict, Optional, Union 3 | 4 | import zigpy.types as t 5 | from zhaquirks.const import ( 6 | DEVICE_TYPE, 7 | ENDPOINTS, 8 | INPUT_CLUSTERS, 9 | MODELS_INFO, 10 | OUTPUT_CLUSTERS, 11 | PROFILE_ID, 12 | SKIP_CONFIGURATION, 13 | ) 14 | from zhaquirks.tuya import ( 15 | TUYA_MCU_COMMAND, 16 | TUYA_SET_TIME, 17 | TuyaLocalCluster, 18 | TuyaTimePayload, 19 | ) 20 | from zhaquirks.tuya.mcu import ( 21 | DPToAttributeMapping, 22 | TuyaClusterData, 23 | TuyaMCUCluster, 24 | TuyaOnOffNM, 25 | ) 26 | from zigpy.profiles import zha 27 | from zigpy.quirks import CustomDevice 28 | from zigpy.zcl import foundation 29 | from zigpy.zcl.clusters.general import ( 30 | AnalogOutput, 31 | Basic, 32 | Groups, 33 | Identify, 34 | OnOff, 35 | Ota, 36 | PowerConfiguration, 37 | Scenes, 38 | Time, 39 | ) 40 | from zigpy.zcl.clusters.measurement import RelativeHumidity, TemperatureMeasurement 41 | 42 | 43 | class TemperatureUnitConvert(t.enum8): 44 | """Tuya Temp unit convert enum.""" 45 | 46 | Celsius = 0x00 47 | Fahrenheit = 0x01 48 | 49 | 50 | class TuyaRelativeHumidity(RelativeHumidity, TuyaLocalCluster): 51 | """Tuya local RelativeHumidity cluster.""" 52 | 53 | 54 | class TuyaTemperatureMeasurement(TemperatureMeasurement, TuyaLocalCluster): 55 | """Tuya local TemperatureMeasurement cluster.""" 56 | 57 | attributes = TemperatureMeasurement.attributes.copy() 58 | attributes.update( 59 | { 60 | 0xEF01: ("temp_unit_convert", t.enum8), 61 | 0xEF02: ("alarm_max_temperature", t.Single), 62 | 0xEF03: ("alarm_min_temperature", t.Single), 63 | 0xEF04: ("temperature_sensitivity", t.Single), 64 | } 65 | ) 66 | 67 | 68 | class TuyaPowerConfigurationCluster3AAA(PowerConfiguration, TuyaLocalCluster): 69 | """PowerConfiguration cluster for battery-operated TRVs with 3 AAA.""" 70 | 71 | BATTERY_SIZE = 0x0031 72 | BATTERY_RATED_VOLTAGE = 0x0034 73 | BATTERY_QUANTITY = 0x0033 74 | 75 | _CONSTANT_ATTRIBUTES = { 76 | BATTERY_SIZE: 0x04, 77 | BATTERY_RATED_VOLTAGE: 15, 78 | BATTERY_QUANTITY: 3, 79 | } 80 | 81 | 82 | class TuyaTempSensivity(AnalogOutput, TuyaLocalCluster): 83 | """Analog output for temperature sensivity.""" 84 | 85 | def __init__(self, *args, **kwargs): 86 | """Init.""" 87 | super().__init__(*args, **kwargs) 88 | self._update_attribute( 89 | self.attributes_by_name["description"].id, "Temperature sensivity" 90 | ) 91 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 10) 92 | self._update_attribute(self.attributes_by_name["min_present_value"].id, 0.5) 93 | self._update_attribute(self.attributes_by_name["resolution"].id, 0.5) 94 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 95 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 96 | 97 | async def command( 98 | self, 99 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 100 | *args, 101 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 102 | expect_reply: bool = True, 103 | tsn: Optional[Union[int, t.uint8_t]] = None, 104 | **kwargs: Any, 105 | ): 106 | """Override the default Cluster command.""" 107 | self.debug( 108 | "Sending Tuya Cluster Command. Cluster Command is %x, Arguments are %s, %s", 109 | command_id, 110 | args, 111 | kwargs, 112 | ) 113 | 114 | if command_id in (0x0000, 0x0001, 0x0004): 115 | cluster_data = TuyaClusterData( 116 | endpoint_id=self.endpoint.endpoint_id, 117 | cluster_name=self.ep_attribute, 118 | cluster_attr="present_value", 119 | attr_value=7, 120 | expect_reply=expect_reply, 121 | manufacturer=manufacturer, 122 | ) 123 | self.endpoint.device.command_bus.listener_event( 124 | TUYA_MCU_COMMAND, 125 | cluster_data, 126 | ) 127 | return foundation.GENERAL_COMMANDS[ 128 | foundation.GeneralCommand.Default_Response 129 | ].schema(command_id=command_id, status=foundation.Status.SUCCESS) 130 | 131 | self.warning("Unsupported command_id: %s", command_id) 132 | return foundation.GENERAL_COMMANDS[ 133 | foundation.GeneralCommand.Default_Response 134 | ].schema(command_id=command_id, status=foundation.Status.UNSUP_CLUSTER_COMMAND) 135 | 136 | 137 | class TuyaManufCluster(TuyaMCUCluster): 138 | """Manufacturer Specific Cluster""" 139 | 140 | set_time_offset = 1970 141 | set_time_local_offset = 1970 142 | 143 | server_commands = TuyaMCUCluster.server_commands.copy() 144 | server_commands.update( 145 | { 146 | TUYA_SET_TIME: foundation.ZCLCommandDef( 147 | "set_time", 148 | {"time": TuyaTimePayload}, 149 | False, 150 | is_manufacturer_specific=False, 151 | ), 152 | } 153 | ) 154 | 155 | dp_to_attribute: Dict[int, DPToAttributeMapping] = { 156 | 1: DPToAttributeMapping( 157 | TuyaTemperatureMeasurement.ep_attribute, 158 | "measured_value", 159 | converter=lambda x: x * 10, # Zigbee to HA 160 | # dp_converter=lambda x: x / 10, # HA to Zigbee 161 | # endpoint_id=2, # 162 | ), 163 | 2: DPToAttributeMapping( 164 | TuyaRelativeHumidity.ep_attribute, 165 | "measured_value", 166 | converter=lambda x: x * 100, # 0.01 to 1.0 167 | ), 168 | 4: DPToAttributeMapping( 169 | TuyaPowerConfigurationCluster3AAA.ep_attribute, 170 | "battery_percentage_remaining", 171 | converter=lambda x: x * 2, 172 | ), 173 | 9: DPToAttributeMapping( 174 | TuyaOnOffNM.ep_attribute, 175 | "on_off", 176 | converter=lambda x: bool(x), 177 | dp_converter=lambda x: TemperatureUnitConvert(x), 178 | ), 179 | # 19: DPToAttributeMapping( 180 | # TuyaTemperatureMeasurement.ep_attribute, 181 | # "temperature_sensitivity", 182 | # converter=lambda x: x / 2, 183 | # ), 184 | 19: DPToAttributeMapping( 185 | TuyaTempSensivity.ep_attribute, 186 | "present_value", 187 | converter=lambda x: x / 2, 188 | ), 189 | } 190 | 191 | data_point_handlers = { 192 | 1: "_dp_2_attr_update", 193 | 2: "_dp_2_attr_update", 194 | 4: "_dp_2_attr_update", 195 | 9: "_dp_2_attr_update", 196 | 19: "_dp_2_attr_update", 197 | } 198 | 199 | 200 | class TuyaDevice(CustomDevice): 201 | signature = { 202 | # endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184] 203 | # output_clusters=[10, 25]> 204 | MODELS_INFO: [ 205 | ("_TZE200_bq5c8xfe", "TS0601"), 206 | ("_TZE200_locansqn", "TS0601"), 207 | ], 208 | ENDPOINTS: { 209 | 1: { 210 | PROFILE_ID: zha.PROFILE_ID, 211 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 212 | INPUT_CLUSTERS: [ 213 | Basic.cluster_id, 214 | Groups.cluster_id, 215 | Scenes.cluster_id, 216 | TuyaManufCluster.cluster_id, 217 | ], 218 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 219 | } 220 | }, 221 | } 222 | 223 | replacement = { 224 | SKIP_CONFIGURATION: True, 225 | ENDPOINTS: { 226 | 1: { 227 | PROFILE_ID: zha.PROFILE_ID, 228 | DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR, 229 | INPUT_CLUSTERS: [ 230 | Basic.cluster_id, 231 | Groups.cluster_id, 232 | Scenes.cluster_id, 233 | TuyaManufCluster, 234 | TuyaTemperatureMeasurement, 235 | TuyaRelativeHumidity, 236 | TuyaPowerConfigurationCluster3AAA, 237 | TuyaOnOffNM, 238 | TuyaTempSensivity, 239 | ], 240 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 241 | }, 242 | }, 243 | } 244 | -------------------------------------------------------------------------------- /ts0601_thermostat_avatto.py: -------------------------------------------------------------------------------- 1 | """Avatto TRV devices support.""" 2 | 3 | import logging 4 | from typing import Optional, Union 5 | 6 | import zigpy.types as t 7 | from zhaquirks import Bus, LocalDataCluster 8 | from zhaquirks.const import ( 9 | DEVICE_TYPE, 10 | ENDPOINTS, 11 | INPUT_CLUSTERS, 12 | MODELS_INFO, 13 | OUTPUT_CLUSTERS, 14 | PROFILE_ID, 15 | ) 16 | from zhaquirks.tuya import ( 17 | EnchantedDevice, 18 | NoManufacturerCluster, 19 | TuyaManufCluster, 20 | TuyaManufClusterAttributes, 21 | TuyaPowerConfigurationCluster, 22 | TuyaThermostat, 23 | TuyaThermostatCluster, 24 | TuyaTimePayload, 25 | TuyaUserInterfaceCluster, 26 | ) 27 | from zigpy.profiles import zha 28 | from zigpy.zcl import foundation 29 | from zigpy.zcl.clusters.general import ( 30 | AnalogOutput, 31 | Basic, 32 | GreenPowerProxy, 33 | Groups, 34 | OnOff, 35 | Ota, 36 | Scenes, 37 | Time, 38 | ) 39 | from zigpy.zcl.clusters.hvac import Thermostat 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | AVATTO_TARGET_TEMP_ATTR = 0x0210 # target room temp (degree) 44 | AVATTO_TEMPERATURE_ATTR = 0x0218 # current room temp (degree) 45 | AVATTO_MODE_ATTR = 0x0402 # [0] manual [1] schedule 46 | AVATTO_SYSTEM_MODE_ATTR = 0x0101 # device [0] off [1] on 47 | AVATTO_HEAT_STATE_ATTR = 0x0424 # [0] heating icon on [1] heating icon off 48 | BEOK_HEAT_STATE_ATTR = 0x0403 # [1] heating icon on [0] heating icon off 49 | AVATTO_CHILD_LOCK_ATTR = 0x0128 # [0] unlocked [1] locked 50 | AVATTO_TEMP_CALIBRATION_ATTR = 0x021B # temperature calibration (degree) 51 | AVATTO_MIN_TEMPERATURE_VAL = 500 # minimum limit of temperature setting (degree/100) 52 | AVATTO_MAX_TEMPERATURE_VAL = 3000 # maximum limit of temperature setting (degree/100) 53 | AVATTO_DEADZONE_ATTR = 0x0214 54 | AvattoManufClusterSelf = {} 55 | 56 | 57 | class CustomTuyaOnOff(LocalDataCluster, OnOff): 58 | """Custom Tuya OnOff cluster.""" 59 | 60 | def __init__(self, *args, **kwargs): 61 | """Init.""" 62 | super().__init__(*args, **kwargs) 63 | self.endpoint.device.thermostat_onoff_bus.add_listener(self) 64 | 65 | # pylint: disable=R0201 66 | def map_attribute(self, attribute, value): 67 | """Map standardized attribute value to dict of manufacturer values.""" 68 | return {} 69 | 70 | async def write_attributes(self, attributes, manufacturer=None): 71 | """Implement writeable attributes.""" 72 | 73 | records = self._write_attr_records(attributes) 74 | 75 | if not records: 76 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 77 | 78 | manufacturer_attrs = {} 79 | for record in records: 80 | attr_name = self.attributes[record.attrid].name 81 | new_attrs = self.map_attribute(attr_name, record.value.value) 82 | 83 | _LOGGER.debug( 84 | "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " 85 | "with value %s to custom %s", 86 | self.endpoint.device.nwk, 87 | self.endpoint.endpoint_id, 88 | self.cluster_id, 89 | attr_name, 90 | record.attrid, 91 | repr(record.value.value), 92 | repr(new_attrs), 93 | ) 94 | 95 | manufacturer_attrs.update(new_attrs) 96 | 97 | if not manufacturer_attrs: 98 | return [ 99 | [ 100 | foundation.WriteAttributesStatusRecord( 101 | foundation.Status.FAILURE, r.attrid 102 | ) 103 | for r in records 104 | ] 105 | ] 106 | 107 | await AvattoManufClusterSelf[ 108 | self.endpoint.device.ieee 109 | ].endpoint.tuya_manufacturer.write_attributes( 110 | manufacturer_attrs, manufacturer=manufacturer 111 | ) 112 | 113 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 114 | 115 | async def command( 116 | self, 117 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 118 | *args, 119 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 120 | expect_reply: bool = True, 121 | tsn: Optional[Union[int, t.uint8_t]] = None, 122 | ): 123 | """Override the default Cluster command.""" 124 | 125 | if command_id in (0x0000, 0x0001, 0x0002): 126 | if command_id == 0x0000: 127 | value = False 128 | elif command_id == 0x0001: 129 | value = True 130 | else: 131 | attrid = self.attributes_by_name["on_off"].id 132 | success, _ = await self.read_attributes( 133 | (attrid,), manufacturer=manufacturer 134 | ) 135 | try: 136 | value = success[attrid] 137 | except KeyError: 138 | return foundation.Status.FAILURE 139 | value = not value 140 | 141 | (res,) = await self.write_attributes( 142 | {"on_off": value}, 143 | manufacturer=manufacturer, 144 | ) 145 | return [command_id, res[0].status] 146 | 147 | return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] 148 | 149 | 150 | class AvattoManufCluster(TuyaManufClusterAttributes): 151 | """Manufacturer Specific Cluster of thermostatic valves.""" 152 | 153 | def __init__(self, *args, **kwargs): 154 | """Init.""" 155 | super().__init__(*args, **kwargs) 156 | global AvattoManufClusterSelf 157 | AvattoManufClusterSelf[self.endpoint.device.ieee] = self 158 | 159 | set_time_offset = 1970 160 | 161 | server_commands = { 162 | 0x0000: foundation.ZCLCommandDef( 163 | "set_data", 164 | {"param": TuyaManufCluster.Command}, 165 | False, 166 | is_manufacturer_specific=False, 167 | ), 168 | 0x0010: foundation.ZCLCommandDef( 169 | "mcu_version_req", 170 | {"param": t.uint16_t}, 171 | False, 172 | is_manufacturer_specific=True, 173 | ), 174 | 0x0024: foundation.ZCLCommandDef( 175 | "set_time", 176 | {"param": TuyaTimePayload}, 177 | False, 178 | is_manufacturer_specific=False, 179 | ), 180 | } 181 | 182 | attributes = TuyaManufClusterAttributes.attributes.copy() 183 | attributes.update( 184 | { 185 | AVATTO_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True), 186 | AVATTO_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True), 187 | AVATTO_MODE_ATTR: ("mode", t.uint8_t, True), 188 | AVATTO_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True), 189 | AVATTO_HEAT_STATE_ATTR: ("heat_state", t.uint8_t, True), 190 | BEOK_HEAT_STATE_ATTR: ("beok_heat_state", t.uint8_t, True), 191 | AVATTO_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True), 192 | AVATTO_TEMP_CALIBRATION_ATTR: ("temperature_calibration", t.int32s, True), 193 | AVATTO_DEADZONE_ATTR: ("deadzone_temp", t.int32s, True), 194 | } 195 | ) 196 | 197 | DIRECT_MAPPED_ATTRS = { 198 | AVATTO_TEMPERATURE_ATTR: ( 199 | "local_temperature", 200 | lambda value: value * 100, 201 | lambda value: value * 10, 202 | ), 203 | AVATTO_TARGET_TEMP_ATTR: ( 204 | "occupied_heating_setpoint", 205 | lambda value: value * 100, 206 | lambda value: value * 10, 207 | ), 208 | } 209 | 210 | def _update_attribute(self, attrid, value): 211 | """Override default _update_attribute.""" 212 | super()._update_attribute(attrid, value) 213 | if attrid in self.DIRECT_MAPPED_ATTRS and value < 500: 214 | if self.endpoint.device.manufacturer in ( 215 | "_TZE200_2ekuz3dz", 216 | "_TZE200_g9a3awaj", 217 | ) or ( 218 | attrid == AVATTO_TEMPERATURE_ATTR 219 | and self.endpoint.device.manufacturer 220 | in ( 221 | "_TZE204_u9bfwha0", 222 | "_TZE200_u9bfwha0", 223 | "_TZE200_aoclfnxz", 224 | "_TZE204_aoclfnxz", 225 | ) 226 | ): 227 | self.endpoint.device.thermostat_bus.listener_event( 228 | "temperature_change", 229 | self.DIRECT_MAPPED_ATTRS[attrid][0], 230 | ( 231 | value 232 | if self.DIRECT_MAPPED_ATTRS[attrid][2] is None 233 | else self.DIRECT_MAPPED_ATTRS[attrid][2](value) 234 | ), 235 | ) 236 | else: 237 | self.endpoint.device.thermostat_bus.listener_event( 238 | "temperature_change", 239 | self.DIRECT_MAPPED_ATTRS[attrid][0], 240 | ( 241 | value 242 | if self.DIRECT_MAPPED_ATTRS[attrid][1] is None 243 | else self.DIRECT_MAPPED_ATTRS[attrid][1](value) 244 | ), 245 | ) 246 | 247 | if attrid == AVATTO_TEMP_CALIBRATION_ATTR: 248 | if self.endpoint.device.manufacturer in ( 249 | "_TZE200_2ekuz3dz", 250 | "_TZE200_g9a3awaj", 251 | ): 252 | self.endpoint.device.AvattoTempCalibration_bus.listener_event( 253 | "set_value", value / 10 254 | ) 255 | else: 256 | self.endpoint.device.AvattoTempCalibration_bus.listener_event( 257 | "set_value", value 258 | ) 259 | 260 | if attrid == AVATTO_CHILD_LOCK_ATTR: 261 | self.endpoint.device.ui_bus.listener_event("child_lock_change", value) 262 | self.endpoint.device.thermostat_onoff_bus.listener_event( 263 | "child_lock_change", value 264 | ) 265 | elif attrid == AVATTO_MODE_ATTR: 266 | self.endpoint.device.thermostat_bus.listener_event("mode_change", value) 267 | elif attrid == AVATTO_HEAT_STATE_ATTR: 268 | if self.endpoint.device.manufacturer == "_TZE200_g9a3awaj": 269 | self.endpoint.device.thermostat_bus.listener_event( 270 | "state_change", value 271 | ) 272 | else: 273 | self.endpoint.device.thermostat_bus.listener_event( 274 | "state_change", not value 275 | ) 276 | elif attrid == BEOK_HEAT_STATE_ATTR: 277 | self.endpoint.device.thermostat_bus.listener_event("state_change", value) 278 | elif attrid == AVATTO_SYSTEM_MODE_ATTR: 279 | self.endpoint.device.thermostat_bus.listener_event( 280 | "system_mode_change", value 281 | ) 282 | 283 | if attrid == AVATTO_DEADZONE_ATTR: 284 | self.endpoint.device.AvattoDeadzoneTemp_bus.listener_event( 285 | "set_value", value 286 | ) 287 | 288 | 289 | class AvattoThermostat(TuyaThermostatCluster): 290 | """Thermostat cluster for thermostatic valves.""" 291 | 292 | class Preset(t.enum8): 293 | """Working modes of the thermostat.""" 294 | 295 | Away = 0x00 296 | Schedule = 0x01 297 | Manual = 0x02 298 | Comfort = 0x03 299 | Eco = 0x04 300 | Boost = 0x05 301 | Complex = 0x06 302 | TempManual = 0x07 303 | 304 | class WorkDays(t.enum8): 305 | """Workday configuration for scheduler operation mode.""" 306 | 307 | MonToFri = 0x00 308 | MonToSat = 0x01 309 | MonToSun = 0x02 310 | 311 | class ForceValveState(t.enum8): 312 | """Force valve state option.""" 313 | 314 | Normal = 0x00 315 | Open = 0x01 316 | Close = 0x02 317 | 318 | _CONSTANT_ATTRIBUTES = { 319 | 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, 320 | } 321 | 322 | attributes = TuyaThermostatCluster.attributes.copy() 323 | attributes.update( 324 | { 325 | 0x4002: ("operation_preset", Preset, True), 326 | } 327 | ) 328 | 329 | DIRECT_MAPPING_ATTRS = { 330 | "occupied_heating_setpoint": ( 331 | AVATTO_TARGET_TEMP_ATTR, 332 | lambda value: round(value / 100), 333 | lambda value: round(value / 10), 334 | ), 335 | } 336 | 337 | def __init__(self, *args, **kwargs): 338 | """Init.""" 339 | super().__init__(*args, **kwargs) 340 | self.endpoint.device.thermostat_bus.add_listener(self) 341 | self.endpoint.device.thermostat_bus.listener_event( 342 | "temperature_change", 343 | "min_heat_setpoint_limit", 344 | AVATTO_MIN_TEMPERATURE_VAL, 345 | ) 346 | self.endpoint.device.thermostat_bus.listener_event( 347 | "temperature_change", 348 | "max_heat_setpoint_limit", 349 | AVATTO_MAX_TEMPERATURE_VAL, 350 | ) 351 | 352 | def map_attribute(self, attribute, value): 353 | """Map standardized attribute value to dict of manufacturer values.""" 354 | 355 | if attribute in self.DIRECT_MAPPING_ATTRS: 356 | if self.endpoint.device.manufacturer in ( 357 | "_TZE200_2ekuz3dz", 358 | "_TZE200_g9a3awaj", 359 | ): 360 | return { 361 | self.DIRECT_MAPPING_ATTRS[attribute][0]: ( 362 | value 363 | if self.DIRECT_MAPPING_ATTRS[attribute][2] is None 364 | else self.DIRECT_MAPPING_ATTRS[attribute][2](value) 365 | ) 366 | } 367 | else: 368 | return { 369 | self.DIRECT_MAPPING_ATTRS[attribute][0]: ( 370 | value 371 | if self.DIRECT_MAPPING_ATTRS[attribute][1] is None 372 | else self.DIRECT_MAPPING_ATTRS[attribute][1](value) 373 | ) 374 | } 375 | 376 | if attribute == "operation_preset": 377 | if value == 1: 378 | return {AVATTO_MODE_ATTR: 1} 379 | if value == 2: 380 | return {AVATTO_MODE_ATTR: 0} 381 | 382 | if attribute in ("programing_oper_mode", "occupancy"): 383 | if attribute == "occupancy": 384 | occupancy = value 385 | oper_mode = self._attr_cache.get( 386 | self.attributes_by_name["programing_oper_mode"].id, 387 | self.ProgrammingOperationMode.Simple, 388 | ) 389 | else: 390 | occupancy = self._attr_cache.get( 391 | self.attributes_by_name["occupancy"].id, self.Occupancy.Occupied 392 | ) 393 | oper_mode = value 394 | if occupancy == self.Occupancy.Occupied: 395 | if oper_mode == self.ProgrammingOperationMode.Schedule_programming_mode: 396 | return {AVATTO_MODE_ATTR: 1} 397 | if oper_mode == self.ProgrammingOperationMode.Simple: 398 | return {AVATTO_MODE_ATTR: 0} 399 | self.error("Unsupported value for ProgrammingOperationMode") 400 | else: 401 | self.error("Unsupported value for Occupancy") 402 | 403 | if attribute == "system_mode": 404 | if value == self.SystemMode.Off: 405 | mode = 0 406 | else: 407 | mode = 1 408 | return {AVATTO_SYSTEM_MODE_ATTR: mode} 409 | 410 | def mode_change(self, value): 411 | """Preset Mode change.""" 412 | if value == 0: 413 | operation_preset = self.Preset.Manual 414 | prog_mode = self.ProgrammingOperationMode.Simple 415 | occupancy = self.Occupancy.Occupied 416 | else: 417 | operation_preset = self.Preset.Schedule 418 | prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode 419 | occupancy = self.Occupancy.Occupied 420 | 421 | self._update_attribute( 422 | self.attributes_by_name["programing_oper_mode"].id, prog_mode 423 | ) 424 | self._update_attribute(self.attributes_by_name["occupancy"].id, occupancy) 425 | self._update_attribute( 426 | self.attributes_by_name["operation_preset"].id, operation_preset 427 | ) 428 | 429 | def system_mode_change(self, value): 430 | """System Mode change.""" 431 | if value == 0: 432 | mode = self.SystemMode.Off 433 | else: 434 | mode = self.SystemMode.Heat 435 | self._update_attribute(self.attributes_by_name["system_mode"].id, mode) 436 | 437 | 438 | class AvattoUserInterface(TuyaUserInterfaceCluster): 439 | """HVAC User interface cluster for tuya electric heating thermostats.""" 440 | 441 | _CHILD_LOCK_ATTR = AVATTO_CHILD_LOCK_ATTR 442 | 443 | 444 | class AvattoChildLock(CustomTuyaOnOff): 445 | """On/Off cluster for the child lock function of the electric heating thermostats.""" 446 | 447 | def child_lock_change(self, value): 448 | """Child lock change.""" 449 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 450 | 451 | def map_attribute(self, attribute, value): 452 | """Map standardized attribute value to dict of manufacturer values.""" 453 | if attribute == "on_off": 454 | return {AVATTO_CHILD_LOCK_ATTR: value} 455 | 456 | 457 | class AvattoTempCalibration(LocalDataCluster, AnalogOutput): 458 | """Analog output for Temp Calibration.""" 459 | 460 | def __init__(self, *args, **kwargs): 461 | """Init.""" 462 | super().__init__(*args, **kwargs) 463 | self.endpoint.device.AvattoTempCalibration_bus.add_listener(self) 464 | self._update_attribute( 465 | self.attributes_by_name["description"].id, "Temperature Calibration" 466 | ) 467 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 10) 468 | self._update_attribute(self.attributes_by_name["min_present_value"].id, -10) 469 | self._update_attribute(self.attributes_by_name["resolution"].id, 0.1) 470 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 471 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 472 | 473 | def set_value(self, value): 474 | """Set value.""" 475 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 476 | 477 | def get_value(self): 478 | """Get value.""" 479 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 480 | 481 | async def write_attributes(self, attributes, manufacturer=None): 482 | """Override the default Cluster write_attributes.""" 483 | for attrid, value in attributes.items(): 484 | if isinstance(attrid, str): 485 | attrid = self.attributes_by_name[attrid].id 486 | if attrid not in self.attributes: 487 | self.error("%d is not a valid attribute id", attrid) 488 | continue 489 | self._update_attribute(attrid, value) 490 | 491 | if self.endpoint.device.manufacturer in ( 492 | "_TZE200_2ekuz3dz", 493 | "_TZE200_g9a3awaj", 494 | ): 495 | await AvattoManufClusterSelf[ 496 | self.endpoint.device.ieee 497 | ].endpoint.tuya_manufacturer.write_attributes( 498 | {AVATTO_TEMP_CALIBRATION_ATTR: value * 10}, 499 | manufacturer=None, 500 | ) 501 | else: 502 | await AvattoManufClusterSelf[ 503 | self.endpoint.device.ieee 504 | ].endpoint.tuya_manufacturer.write_attributes( 505 | {AVATTO_TEMP_CALIBRATION_ATTR: value}, 506 | manufacturer=None, 507 | ) 508 | 509 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 510 | 511 | 512 | class AvattoDeadzoneTemp(LocalDataCluster, AnalogOutput): 513 | """Analog output for Deadzone Temp.""" 514 | 515 | def __init__(self, *args, **kwargs): 516 | """Init.""" 517 | super().__init__(*args, **kwargs) 518 | self.endpoint.device.AvattoDeadzoneTemp_bus.add_listener(self) 519 | self._update_attribute( 520 | self.attributes_by_name["description"].id, "Deadzone Temperature" 521 | ) 522 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 5) 523 | self._update_attribute(self.attributes_by_name["min_present_value"].id, 0) 524 | self._update_attribute(self.attributes_by_name["resolution"].id, 1) 525 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 526 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 527 | 528 | def set_value(self, value): 529 | """Set value.""" 530 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 531 | 532 | def get_value(self): 533 | """Get value.""" 534 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 535 | 536 | async def write_attributes(self, attributes, manufacturer=None): 537 | """Override the default Cluster write_attributes.""" 538 | for attrid, value in attributes.items(): 539 | if isinstance(attrid, str): 540 | attrid = self.attributes_by_name[attrid].id 541 | if attrid not in self.attributes: 542 | self.error("%d is not a valid attribute id", attrid) 543 | continue 544 | self._update_attribute(attrid, value) 545 | 546 | await AvattoManufClusterSelf[ 547 | self.endpoint.device.ieee 548 | ].endpoint.tuya_manufacturer.write_attributes( 549 | {AVATTO_DEADZONE_ATTR: value}, 550 | manufacturer=None, 551 | ) 552 | 553 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 554 | 555 | 556 | class Avatto(EnchantedDevice, TuyaThermostat): 557 | """Avatto Thermostatic radiator valve.""" 558 | 559 | def __init__(self, *args, **kwargs): 560 | """Init device.""" 561 | self.thermostat_onoff_bus = Bus() 562 | self.AvattoTempCalibration_bus = Bus() 563 | self.AvattoDeadzoneTemp_bus = Bus() 564 | super().__init__(*args, **kwargs) 565 | 566 | signature = { 567 | # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] 568 | # output_clusters=[10, 25]> 569 | MODELS_INFO: [ 570 | ("_TZE200_ye5jkfsb", "TS0601"), 571 | ("_TZE200_aoclfnxz", "TS0601"), 572 | ("_TZE200_ztvwu4nk", "TS0601"), 573 | ("_TZE200_5toc8efa", "TS0601"), 574 | ("_TZE200_u9bfwha0", "TS0601"), 575 | ], 576 | ENDPOINTS: { 577 | 1: { 578 | PROFILE_ID: zha.PROFILE_ID, 579 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 580 | INPUT_CLUSTERS: [ 581 | Basic.cluster_id, 582 | Groups.cluster_id, 583 | Scenes.cluster_id, 584 | TuyaManufClusterAttributes.cluster_id, 585 | ], 586 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 587 | } 588 | }, 589 | } 590 | 591 | replacement = { 592 | ENDPOINTS: { 593 | 1: { 594 | PROFILE_ID: zha.PROFILE_ID, 595 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 596 | INPUT_CLUSTERS: [ 597 | Basic.cluster_id, 598 | Groups.cluster_id, 599 | Scenes.cluster_id, 600 | AvattoManufCluster, 601 | AvattoThermostat, 602 | AvattoUserInterface, 603 | TuyaPowerConfigurationCluster, 604 | ], 605 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 606 | }, 607 | 2: { 608 | PROFILE_ID: zha.PROFILE_ID, 609 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 610 | INPUT_CLUSTERS: [AvattoChildLock], 611 | OUTPUT_CLUSTERS: [], 612 | }, 613 | 3: { 614 | PROFILE_ID: zha.PROFILE_ID, 615 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 616 | INPUT_CLUSTERS: [AvattoTempCalibration], 617 | OUTPUT_CLUSTERS: [], 618 | }, 619 | 4: { 620 | PROFILE_ID: zha.PROFILE_ID, 621 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 622 | INPUT_CLUSTERS: [AvattoDeadzoneTemp], 623 | OUTPUT_CLUSTERS: [], 624 | }, 625 | } 626 | } 627 | 628 | 629 | class Beok(EnchantedDevice, TuyaThermostat): 630 | """Beok Thermostatic radiator valve.""" 631 | 632 | def __init__(self, *args, **kwargs): 633 | """Init device.""" 634 | self.thermostat_onoff_bus = Bus() 635 | self.AvattoTempCalibration_bus = Bus() 636 | self.AvattoDeadzoneTemp_bus = Bus() 637 | super().__init__(*args, **kwargs) 638 | 639 | signature = { 640 | # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] 641 | # output_clusters=[10, 25]> 642 | MODELS_INFO: [ 643 | ("_TZE200_2ekuz3dz", "TS0601"), 644 | ("_TZE204_aoclfnxz", "TS0601"), 645 | ("_TZE204_u9bfwha0", "TS0601"), 646 | ("_TZE200_g9a3awaj", "TS0601"), 647 | ], 648 | ENDPOINTS: { 649 | 1: { 650 | PROFILE_ID: zha.PROFILE_ID, 651 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 652 | INPUT_CLUSTERS: [ 653 | Basic.cluster_id, 654 | Groups.cluster_id, 655 | Scenes.cluster_id, 656 | TuyaManufClusterAttributes.cluster_id, 657 | ], 658 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 659 | }, 660 | 242: { 661 | PROFILE_ID: 41440, 662 | DEVICE_TYPE: 97, 663 | INPUT_CLUSTERS: [], 664 | OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], 665 | }, 666 | }, 667 | } 668 | 669 | replacement = { 670 | ENDPOINTS: { 671 | 1: { 672 | PROFILE_ID: zha.PROFILE_ID, 673 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 674 | INPUT_CLUSTERS: [ 675 | Basic.cluster_id, 676 | Groups.cluster_id, 677 | Scenes.cluster_id, 678 | AvattoManufCluster, 679 | AvattoThermostat, 680 | AvattoUserInterface, 681 | TuyaPowerConfigurationCluster, 682 | ], 683 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 684 | }, 685 | 2: { 686 | PROFILE_ID: zha.PROFILE_ID, 687 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 688 | INPUT_CLUSTERS: [AvattoChildLock], 689 | OUTPUT_CLUSTERS: [], 690 | }, 691 | 3: { 692 | PROFILE_ID: zha.PROFILE_ID, 693 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 694 | INPUT_CLUSTERS: [AvattoTempCalibration], 695 | OUTPUT_CLUSTERS: [], 696 | }, 697 | 4: { 698 | PROFILE_ID: zha.PROFILE_ID, 699 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 700 | INPUT_CLUSTERS: [AvattoDeadzoneTemp], 701 | OUTPUT_CLUSTERS: [], 702 | }, 703 | 242: { 704 | PROFILE_ID: 41440, 705 | DEVICE_TYPE: 97, 706 | INPUT_CLUSTERS: [], 707 | OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], 708 | }, 709 | } 710 | } 711 | 712 | 713 | class Beok2(EnchantedDevice, TuyaThermostat): 714 | """Beok Thermostatic radiator valve.""" 715 | 716 | def __init__(self, *args, **kwargs): 717 | """Init device.""" 718 | self.thermostat_onoff_bus = Bus() 719 | self.AvattoTempCalibration_bus = Bus() 720 | self.AvattoDeadzoneTemp_bus = Bus() 721 | super().__init__(*args, **kwargs) 722 | 723 | signature = { 724 | # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] 725 | # output_clusters=[10, 25]> 726 | MODELS_INFO: [ 727 | ("_TZE200_g9a3awaj", "TS0601"), 728 | ], 729 | ENDPOINTS: { 730 | 1: { 731 | PROFILE_ID: zha.PROFILE_ID, 732 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 733 | INPUT_CLUSTERS: [ 734 | Basic.cluster_id, 735 | Groups.cluster_id, 736 | Scenes.cluster_id, 737 | TuyaManufClusterAttributes.cluster_id, 738 | ], 739 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 740 | }, 741 | }, 742 | } 743 | 744 | replacement = { 745 | ENDPOINTS: { 746 | 1: { 747 | PROFILE_ID: zha.PROFILE_ID, 748 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 749 | INPUT_CLUSTERS: [ 750 | Basic.cluster_id, 751 | Groups.cluster_id, 752 | Scenes.cluster_id, 753 | AvattoManufCluster, 754 | AvattoThermostat, 755 | AvattoUserInterface, 756 | TuyaPowerConfigurationCluster, 757 | ], 758 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 759 | }, 760 | 2: { 761 | PROFILE_ID: zha.PROFILE_ID, 762 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 763 | INPUT_CLUSTERS: [AvattoChildLock], 764 | OUTPUT_CLUSTERS: [], 765 | }, 766 | 3: { 767 | PROFILE_ID: zha.PROFILE_ID, 768 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 769 | INPUT_CLUSTERS: [AvattoTempCalibration], 770 | OUTPUT_CLUSTERS: [], 771 | }, 772 | 4: { 773 | PROFILE_ID: zha.PROFILE_ID, 774 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 775 | INPUT_CLUSTERS: [AvattoDeadzoneTemp], 776 | OUTPUT_CLUSTERS: [], 777 | }, 778 | } 779 | } 780 | -------------------------------------------------------------------------------- /ts0601_thermostat_avatto2.py: -------------------------------------------------------------------------------- 1 | """Avatto2 TRV devices support.""" 2 | 3 | import logging 4 | from typing import Optional, Union 5 | 6 | import zigpy.types as t 7 | from zhaquirks import Bus, LocalDataCluster 8 | from zhaquirks.const import ( 9 | DEVICE_TYPE, 10 | ENDPOINTS, 11 | INPUT_CLUSTERS, 12 | MODELS_INFO, 13 | OUTPUT_CLUSTERS, 14 | PROFILE_ID, 15 | ) 16 | from zhaquirks.tuya import ( 17 | EnchantedDevice, 18 | NoManufacturerCluster, 19 | TuyaManufCluster, 20 | TuyaManufClusterAttributes, 21 | TuyaPowerConfigurationCluster, 22 | TuyaThermostat, 23 | TuyaThermostatCluster, 24 | TuyaTimePayload, 25 | TuyaUserInterfaceCluster, 26 | ) 27 | from zigpy.profiles import zha 28 | from zigpy.zcl import foundation 29 | from zigpy.zcl.clusters.general import ( 30 | AnalogOutput, 31 | Basic, 32 | GreenPowerProxy, 33 | Groups, 34 | OnOff, 35 | Ota, 36 | Scenes, 37 | Time, 38 | ) 39 | from zigpy.zcl.clusters.hvac import Thermostat 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | AVATTO2_TARGET_TEMP_ATTR = 0x0202 # target room temp (degree) 44 | AVATTO2_TEMPERATURE_ATTR = 0x0203 # current room temp (degree/10) 45 | AVATTO2_MODE_ATTR = 0x0404 # [0] home [1] auto [2] temporary 46 | AVATTO2_SYSTEM_MODE_ATTR = 0x0101 # device [0] off [1] on 47 | AVATTO2_HEAT_STATE_ATTR = 0x0405 # [0] heating icon off [1] heating icon on 48 | AVATTO2_CHILD_LOCK_ATTR = 0x0109 # [0] unlocked [1] locked 49 | AVATTO2_SENSOR_ATTR = 0x046E # [1], [2], [3] 50 | AVATTO2_TEMP_CALIBRATION_ATTR = 0x0213 # temperature calibration (degree) 51 | AVATTO2_MIN_TEMPERATURE_VAL = 500 # minimum limit of temperature setting (degree/100) 52 | AVATTO2_MAX_TEMPERATURE_VAL = 9500 # maximum limit of temperature setting (degree/100) 53 | Avatto2ManufClusterSelf = {} 54 | 55 | 56 | class CustomTuyaOnOff(LocalDataCluster, OnOff): 57 | """Custom Tuya OnOff cluster.""" 58 | 59 | def __init__(self, *args, **kwargs): 60 | """Init.""" 61 | super().__init__(*args, **kwargs) 62 | self.endpoint.device.thermostat_onoff_bus.add_listener(self) 63 | 64 | # pylint: disable=R0201 65 | def map_attribute(self, attribute, value): 66 | """Map standardized attribute value to dict of manufacturer values.""" 67 | return {} 68 | 69 | async def write_attributes(self, attributes, manufacturer=None): 70 | """Implement writeable attributes.""" 71 | 72 | records = self._write_attr_records(attributes) 73 | 74 | if not records: 75 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 76 | 77 | manufacturer_attrs = {} 78 | for record in records: 79 | attr_name = self.attributes[record.attrid].name 80 | new_attrs = self.map_attribute(attr_name, record.value.value) 81 | 82 | _LOGGER.debug( 83 | "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " 84 | "with value %s to custom %s", 85 | self.endpoint.device.nwk, 86 | self.endpoint.endpoint_id, 87 | self.cluster_id, 88 | attr_name, 89 | record.attrid, 90 | repr(record.value.value), 91 | repr(new_attrs), 92 | ) 93 | 94 | manufacturer_attrs.update(new_attrs) 95 | 96 | if not manufacturer_attrs: 97 | return [ 98 | [ 99 | foundation.WriteAttributesStatusRecord( 100 | foundation.Status.FAILURE, r.attrid 101 | ) 102 | for r in records 103 | ] 104 | ] 105 | 106 | await Avatto2ManufClusterSelf[ 107 | self.endpoint.device.ieee 108 | ].endpoint.tuya_manufacturer.write_attributes( 109 | manufacturer_attrs, manufacturer=manufacturer 110 | ) 111 | 112 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 113 | 114 | async def command( 115 | self, 116 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 117 | *args, 118 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 119 | expect_reply: bool = True, 120 | tsn: Optional[Union[int, t.uint8_t]] = None, 121 | ): 122 | """Override the default Cluster command.""" 123 | 124 | if command_id in (0x0000, 0x0001, 0x0002): 125 | 126 | if command_id == 0x0000: 127 | value = False 128 | elif command_id == 0x0001: 129 | value = True 130 | else: 131 | attrid = self.attributes_by_name["on_off"].id 132 | success, _ = await self.read_attributes( 133 | (attrid,), manufacturer=manufacturer 134 | ) 135 | try: 136 | value = success[attrid] 137 | except KeyError: 138 | return foundation.Status.FAILURE 139 | value = not value 140 | 141 | (res,) = await self.write_attributes( 142 | {"on_off": value}, 143 | manufacturer=manufacturer, 144 | ) 145 | return [command_id, res[0].status] 146 | 147 | return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] 148 | 149 | 150 | class Avatto2ManufCluster(TuyaManufClusterAttributes): 151 | """Manufacturer Specific Cluster of thermostatic valves.""" 152 | 153 | def __init__(self, *args, **kwargs): 154 | """Init.""" 155 | super().__init__(*args, **kwargs) 156 | global Avatto2ManufClusterSelf 157 | Avatto2ManufClusterSelf[self.endpoint.device.ieee] = self 158 | 159 | set_time_offset = 1970 160 | 161 | server_commands = { 162 | 0x0000: foundation.ZCLCommandDef( 163 | "set_data", 164 | {"param": TuyaManufCluster.Command}, 165 | False, 166 | is_manufacturer_specific=False, 167 | ), 168 | 0x0010: foundation.ZCLCommandDef( 169 | "mcu_version_req", 170 | {"param": t.uint16_t}, 171 | False, 172 | is_manufacturer_specific=True, 173 | ), 174 | 0x0024: foundation.ZCLCommandDef( 175 | "set_time", 176 | {"param": TuyaTimePayload}, 177 | False, 178 | is_manufacturer_specific=True, 179 | ), 180 | } 181 | 182 | attributes = TuyaManufClusterAttributes.attributes.copy() 183 | attributes.update( 184 | { 185 | AVATTO2_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True), 186 | AVATTO2_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True), 187 | AVATTO2_MODE_ATTR: ("mode", t.uint8_t, True), 188 | AVATTO2_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True), 189 | AVATTO2_HEAT_STATE_ATTR: ("heat_state", t.uint8_t, True), 190 | AVATTO2_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True), 191 | AVATTO2_SENSOR_ATTR: ("sensor_choose", t.uint8_t, True), 192 | AVATTO2_TEMP_CALIBRATION_ATTR: ("temperature_calibration", t.int32s, True), 193 | } 194 | ) 195 | 196 | DIRECT_MAPPED_ATTRS = { 197 | AVATTO2_TEMPERATURE_ATTR: ( 198 | "local_temperature", 199 | lambda value: value * 10, 200 | ), 201 | AVATTO2_TARGET_TEMP_ATTR: ( 202 | "occupied_heating_setpoint", 203 | lambda value: value * 10, 204 | ), 205 | AVATTO2_TEMP_CALIBRATION_ATTR: ( 206 | "local_temperature_calibration", 207 | lambda value: value * 10, 208 | ), 209 | } 210 | 211 | def _update_attribute(self, attrid, value): 212 | """Override default _update_attribute.""" 213 | super()._update_attribute(attrid, value) 214 | if attrid in self.DIRECT_MAPPED_ATTRS: 215 | self.endpoint.device.thermostat_bus.listener_event( 216 | "temperature_change", 217 | self.DIRECT_MAPPED_ATTRS[attrid][0], 218 | ( 219 | value 220 | if self.DIRECT_MAPPED_ATTRS[attrid][1] is None 221 | else self.DIRECT_MAPPED_ATTRS[attrid][1](value) 222 | ), 223 | ) 224 | 225 | if attrid == AVATTO2_CHILD_LOCK_ATTR: 226 | self.endpoint.device.ui_bus.listener_event("child_lock_change", value) 227 | self.endpoint.device.thermostat_onoff_bus.listener_event( 228 | "child_lock_change", value 229 | ) 230 | elif attrid == AVATTO2_MODE_ATTR: 231 | self.endpoint.device.thermostat_bus.listener_event("mode_change", value) 232 | elif attrid == AVATTO2_TEMP_CALIBRATION_ATTR: 233 | self.endpoint.device.AvattoTempCalibration_bus.listener_event( 234 | "set_value", value / 10 235 | ) 236 | elif attrid == AVATTO2_HEAT_STATE_ATTR: 237 | self.endpoint.device.thermostat_bus.listener_event("state_change", value) 238 | elif attrid == AVATTO2_SYSTEM_MODE_ATTR: 239 | self.endpoint.device.thermostat_bus.listener_event( 240 | "system_mode_change", value 241 | ) 242 | 243 | 244 | class Avatto2Thermostat(TuyaThermostatCluster): 245 | """Thermostat cluster for thermostatic valves.""" 246 | 247 | class Preset(t.enum8): 248 | """Working modes of the thermostat.""" 249 | 250 | Away = 0x00 251 | Schedule = 0x01 252 | Manual = 0x02 253 | Comfort = 0x03 254 | Eco = 0x04 255 | Boost = 0x05 256 | Complex = 0x06 257 | TempManual = 0x07 258 | 259 | class WorkDays(t.enum8): 260 | """Workday configuration for scheduler operation mode.""" 261 | 262 | MonToFri = 0x00 263 | MonToSat = 0x01 264 | MonToSun = 0x02 265 | 266 | class ForceValveState(t.enum8): 267 | """Force valve state option.""" 268 | 269 | Normal = 0x00 270 | Open = 0x01 271 | Close = 0x02 272 | 273 | _CONSTANT_ATTRIBUTES = { 274 | 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, 275 | } 276 | 277 | attributes = TuyaThermostatCluster.attributes.copy() 278 | attributes.update( 279 | { 280 | 0x4000: ("sensor_choose", t.uint8_t, True), 281 | 0x4002: ("operation_preset", Preset, True), 282 | } 283 | ) 284 | 285 | DIRECT_MAPPING_ATTRS = { 286 | "local_temperature_calibration": ( 287 | AVATTO2_TEMP_CALIBRATION_ATTR, 288 | lambda value: round(value / 10), 289 | ), 290 | "occupied_heating_setpoint": ( 291 | AVATTO2_TARGET_TEMP_ATTR, 292 | lambda value: round(value / 10), 293 | ), 294 | "sensor_choose": (AVATTO2_SENSOR_ATTR, None), 295 | } 296 | 297 | def __init__(self, *args, **kwargs): 298 | """Init.""" 299 | super().__init__(*args, **kwargs) 300 | self.endpoint.device.thermostat_bus.add_listener(self) 301 | self.endpoint.device.thermostat_bus.listener_event( 302 | "temperature_change", 303 | "min_heat_setpoint_limit", 304 | AVATTO2_MIN_TEMPERATURE_VAL, 305 | ) 306 | self.endpoint.device.thermostat_bus.listener_event( 307 | "temperature_change", 308 | "max_heat_setpoint_limit", 309 | AVATTO2_MAX_TEMPERATURE_VAL, 310 | ) 311 | 312 | def map_attribute(self, attribute, value): 313 | """Map standardized attribute value to dict of manufacturer values.""" 314 | 315 | if attribute in self.DIRECT_MAPPING_ATTRS: 316 | return { 317 | self.DIRECT_MAPPING_ATTRS[attribute][0]: ( 318 | value 319 | if self.DIRECT_MAPPING_ATTRS[attribute][1] is None 320 | else self.DIRECT_MAPPING_ATTRS[attribute][1](value) 321 | ) 322 | } 323 | 324 | if attribute == "operation_preset": 325 | if value == 1: 326 | return {AVATTO2_MODE_ATTR: 1} 327 | if value == 2: 328 | return {AVATTO2_MODE_ATTR: 0} 329 | 330 | if attribute in ("programing_oper_mode", "occupancy"): 331 | if attribute == "occupancy": 332 | occupancy = value 333 | oper_mode = self._attr_cache.get( 334 | self.attributes_by_name["programing_oper_mode"].id, 335 | self.ProgrammingOperationMode.Simple, 336 | ) 337 | else: 338 | occupancy = self._attr_cache.get( 339 | self.attributes_by_name["occupancy"].id, self.Occupancy.Occupied 340 | ) 341 | oper_mode = value 342 | if occupancy == self.Occupancy.Occupied: 343 | if oper_mode == self.ProgrammingOperationMode.Schedule_programming_mode: 344 | return {AVATTO2_MODE_ATTR: 1} 345 | if oper_mode == self.ProgrammingOperationMode.Simple: 346 | return {AVATTO2_MODE_ATTR: 0} 347 | self.error("Unsupported value for ProgrammingOperationMode") 348 | else: 349 | self.error("Unsupported value for Occupancy") 350 | 351 | if attribute == "system_mode": 352 | if value == self.SystemMode.Off: 353 | mode = 0 354 | else: 355 | mode = 1 356 | return {AVATTO2_SYSTEM_MODE_ATTR: mode} 357 | 358 | def mode_change(self, value): 359 | """Preset Mode change.""" 360 | if value in (0, 1): 361 | operation_preset = self.Preset.Schedule 362 | prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode 363 | occupancy = self.Occupancy.Occupied 364 | else: 365 | operation_preset = self.Preset.Manual 366 | prog_mode = self.ProgrammingOperationMode.Simple 367 | occupancy = self.Occupancy.Occupied 368 | 369 | self._update_attribute( 370 | self.attributes_by_name["programing_oper_mode"].id, prog_mode 371 | ) 372 | self._update_attribute(self.attributes_by_name["occupancy"].id, occupancy) 373 | self._update_attribute( 374 | self.attributes_by_name["operation_preset"].id, operation_preset 375 | ) 376 | 377 | def system_mode_change(self, value): 378 | """System Mode change.""" 379 | if value == 0: 380 | mode = self.SystemMode.Off 381 | else: 382 | mode = self.SystemMode.Heat 383 | self._update_attribute(self.attributes_by_name["system_mode"].id, mode) 384 | 385 | 386 | class Avatto2UserInterface(TuyaUserInterfaceCluster): 387 | """HVAC User interface cluster for tuya electric heating thermostats.""" 388 | 389 | _CHILD_LOCK_ATTR = AVATTO2_CHILD_LOCK_ATTR 390 | 391 | 392 | class Avatto2ChildLock(CustomTuyaOnOff): 393 | """On/Off cluster for the child lock function of the electric heating thermostats.""" 394 | 395 | def child_lock_change(self, value): 396 | """Child lock change.""" 397 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 398 | 399 | def map_attribute(self, attribute, value): 400 | """Map standardized attribute value to dict of manufacturer values.""" 401 | if attribute == "on_off": 402 | return {AVATTO2_CHILD_LOCK_ATTR: value} 403 | 404 | 405 | class Avatto2TempCalibration(LocalDataCluster, AnalogOutput): 406 | """Analog output for Temp Calibration.""" 407 | 408 | def __init__(self, *args, **kwargs): 409 | """Init.""" 410 | super().__init__(*args, **kwargs) 411 | self.endpoint.device.AvattoTempCalibration_bus.add_listener(self) 412 | self._update_attribute( 413 | self.attributes_by_name["description"].id, "Temperature Calibration" 414 | ) 415 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 10) 416 | self._update_attribute(self.attributes_by_name["min_present_value"].id, -10) 417 | self._update_attribute(self.attributes_by_name["resolution"].id, 0.1) 418 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 419 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 420 | 421 | def set_value(self, value): 422 | """Set value.""" 423 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 424 | 425 | def get_value(self): 426 | """Get value.""" 427 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 428 | 429 | async def write_attributes(self, attributes, manufacturer=None): 430 | """Override the default Cluster write_attributes.""" 431 | for attrid, value in attributes.items(): 432 | if isinstance(attrid, str): 433 | attrid = self.attributes_by_name[attrid].id 434 | if attrid not in self.attributes: 435 | self.error("%d is not a valid attribute id", attrid) 436 | continue 437 | self._update_attribute(attrid, value) 438 | 439 | await Avatto2ManufClusterSelf[ 440 | self.endpoint.device.ieee 441 | ].endpoint.tuya_manufacturer.write_attributes( 442 | {AVATTO2_TEMP_CALIBRATION_ATTR: value * 10}, 443 | manufacturer=None, 444 | ) 445 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 446 | 447 | 448 | class Avatto2(EnchantedDevice, TuyaThermostat): 449 | """Avatto2 Thermostatic radiator valve.""" 450 | 451 | def __init__(self, *args, **kwargs): 452 | """Init device.""" 453 | self.thermostat_onoff_bus = Bus() 454 | self.AvattoTempCalibration_bus = Bus() 455 | super().__init__(*args, **kwargs) 456 | 457 | signature = { 458 | # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] 459 | # output_clusters=[10, 25]> 460 | MODELS_INFO: [ 461 | ("_TZE204_lzriup1j", "TS0601"), 462 | ], 463 | ENDPOINTS: { 464 | 1: { 465 | PROFILE_ID: zha.PROFILE_ID, 466 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 467 | INPUT_CLUSTERS: [ 468 | Basic.cluster_id, 469 | Groups.cluster_id, 470 | Scenes.cluster_id, 471 | TuyaManufClusterAttributes.cluster_id, 472 | ], 473 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 474 | }, 475 | 242: { 476 | PROFILE_ID: 41440, 477 | DEVICE_TYPE: 97, 478 | INPUT_CLUSTERS: [], 479 | OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], 480 | }, 481 | }, 482 | } 483 | 484 | replacement = { 485 | ENDPOINTS: { 486 | 1: { 487 | PROFILE_ID: zha.PROFILE_ID, 488 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 489 | INPUT_CLUSTERS: [ 490 | Basic.cluster_id, 491 | Groups.cluster_id, 492 | Scenes.cluster_id, 493 | Avatto2ManufCluster, 494 | Avatto2Thermostat, 495 | Avatto2UserInterface, 496 | TuyaPowerConfigurationCluster, 497 | ], 498 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 499 | }, 500 | 2: { 501 | PROFILE_ID: zha.PROFILE_ID, 502 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 503 | INPUT_CLUSTERS: [Avatto2ChildLock], 504 | OUTPUT_CLUSTERS: [], 505 | }, 506 | 3: { 507 | PROFILE_ID: zha.PROFILE_ID, 508 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 509 | INPUT_CLUSTERS: [Avatto2TempCalibration], 510 | OUTPUT_CLUSTERS: [], 511 | }, 512 | 242: { 513 | PROFILE_ID: 41440, 514 | DEVICE_TYPE: 97, 515 | INPUT_CLUSTERS: [], 516 | OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], 517 | }, 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /ts0601_thermostat_electsmart.py: -------------------------------------------------------------------------------- 1 | """Electsmart TRV devices support.""" 2 | 3 | import logging 4 | from typing import Optional, Union 5 | 6 | import zigpy.types as t 7 | from zhaquirks import Bus, LocalDataCluster 8 | from zhaquirks.const import ( 9 | DEVICE_TYPE, 10 | ENDPOINTS, 11 | INPUT_CLUSTERS, 12 | MODELS_INFO, 13 | OUTPUT_CLUSTERS, 14 | PROFILE_ID, 15 | ) 16 | from zhaquirks.tuya import ( 17 | EnchantedDevice, 18 | NoManufacturerCluster, 19 | TuyaManufCluster, 20 | TuyaManufClusterAttributes, 21 | TuyaPowerConfigurationCluster, 22 | TuyaThermostat, 23 | TuyaThermostatCluster, 24 | TuyaTimePayload, 25 | TuyaUserInterfaceCluster, 26 | ) 27 | from zigpy.profiles import zha 28 | from zigpy.zcl import foundation 29 | from zigpy.zcl.clusters.general import ( 30 | AnalogOutput, 31 | Basic, 32 | GreenPowerProxy, 33 | Groups, 34 | OnOff, 35 | Ota, 36 | Scenes, 37 | Time, 38 | ) 39 | from zigpy.zcl.clusters.hvac import Thermostat 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | ELECTSMART_TARGET_TEMP_ATTR = 0x0210 # target room temp (degree) 44 | ELECTSMART_TEMPERATURE_ATTR = 0x0266 # current room temp (degree) 45 | ELECTSMART_MODE_ATTR = 0x0402 # [0] manual [1] schedule 46 | ELECTSMART_SYSTEM_MODE_ATTR = 0x0101 # device [0] off [1] on 47 | ELECTSMART_HEAT_STATE_ATTR = 0x0424 # [0] heating icon on [1] heating icon off 48 | ELECTSMART_CHILD_LOCK_ATTR = 0x0128 # [0] unlocked [1] locked 49 | ELECTSMART_TEMP_CALIBRATION_ATTR = 0x021B # temperature calibration (degree) 50 | ELECTSMART_MIN_TEMPERATURE_VAL = ( 51 | 500 # minimum limit of temperature setting (degree/100) 52 | ) 53 | ELECTSMART_MAX_TEMPERATURE_VAL = ( 54 | 3500 # maximum limit of temperature setting (degree/100) 55 | ) 56 | ElectsmartManufClusterSelf = {} 57 | 58 | 59 | class CustomTuyaOnOff(LocalDataCluster, OnOff): 60 | """Custom Tuya OnOff cluster.""" 61 | 62 | def __init__(self, *args, **kwargs): 63 | """Init.""" 64 | super().__init__(*args, **kwargs) 65 | self.endpoint.device.thermostat_onoff_bus.add_listener(self) 66 | 67 | # pylint: disable=R0201 68 | def map_attribute(self, attribute, value): 69 | """Map standardized attribute value to dict of manufacturer values.""" 70 | return {} 71 | 72 | async def write_attributes(self, attributes, manufacturer=None): 73 | """Implement writeable attributes.""" 74 | 75 | records = self._write_attr_records(attributes) 76 | 77 | if not records: 78 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 79 | 80 | manufacturer_attrs = {} 81 | for record in records: 82 | attr_name = self.attributes[record.attrid].name 83 | new_attrs = self.map_attribute(attr_name, record.value.value) 84 | 85 | _LOGGER.debug( 86 | "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " 87 | "with value %s to custom %s", 88 | self.endpoint.device.nwk, 89 | self.endpoint.endpoint_id, 90 | self.cluster_id, 91 | attr_name, 92 | record.attrid, 93 | repr(record.value.value), 94 | repr(new_attrs), 95 | ) 96 | 97 | manufacturer_attrs.update(new_attrs) 98 | 99 | if not manufacturer_attrs: 100 | return [ 101 | [ 102 | foundation.WriteAttributesStatusRecord( 103 | foundation.Status.FAILURE, r.attrid 104 | ) 105 | for r in records 106 | ] 107 | ] 108 | 109 | await ElectsmartManufClusterSelf[ 110 | self.endpoint.device.ieee 111 | ].endpoint.tuya_manufacturer.write_attributes( 112 | manufacturer_attrs, manufacturer=manufacturer 113 | ) 114 | 115 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 116 | 117 | async def command( 118 | self, 119 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 120 | *args, 121 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 122 | expect_reply: bool = True, 123 | tsn: Optional[Union[int, t.uint8_t]] = None, 124 | ): 125 | """Override the default Cluster command.""" 126 | 127 | if command_id in (0x0000, 0x0001, 0x0002): 128 | if command_id == 0x0000: 129 | value = False 130 | elif command_id == 0x0001: 131 | value = True 132 | else: 133 | attrid = self.attributes_by_name["on_off"].id 134 | success, _ = await self.read_attributes( 135 | (attrid,), manufacturer=manufacturer 136 | ) 137 | try: 138 | value = success[attrid] 139 | except KeyError: 140 | return foundation.Status.FAILURE 141 | value = not value 142 | 143 | (res,) = await self.write_attributes( 144 | {"on_off": value}, 145 | manufacturer=manufacturer, 146 | ) 147 | return [command_id, res[0].status] 148 | 149 | return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] 150 | 151 | 152 | class ElectsmartManufCluster(TuyaManufClusterAttributes): 153 | """Manufacturer Specific Cluster of thermostatic valves.""" 154 | 155 | def __init__(self, *args, **kwargs): 156 | """Init.""" 157 | super().__init__(*args, **kwargs) 158 | global ElectsmartManufClusterSelf 159 | ElectsmartManufClusterSelf[self.endpoint.device.ieee] = self 160 | 161 | set_time_offset = 1970 162 | 163 | server_commands = { 164 | 0x0000: foundation.ZCLCommandDef( 165 | "set_data", 166 | {"param": TuyaManufCluster.Command}, 167 | False, 168 | is_manufacturer_specific=False, 169 | ), 170 | 0x0010: foundation.ZCLCommandDef( 171 | "mcu_version_req", 172 | {"param": t.uint16_t}, 173 | False, 174 | is_manufacturer_specific=True, 175 | ), 176 | 0x0024: foundation.ZCLCommandDef( 177 | "set_time", 178 | {"param": TuyaTimePayload}, 179 | False, 180 | is_manufacturer_specific=False, 181 | ), 182 | } 183 | 184 | attributes = TuyaManufClusterAttributes.attributes.copy() 185 | attributes.update( 186 | { 187 | ELECTSMART_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True), 188 | ELECTSMART_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True), 189 | ELECTSMART_MODE_ATTR: ("mode", t.uint8_t, True), 190 | ELECTSMART_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True), 191 | ELECTSMART_HEAT_STATE_ATTR: ("heat_state", t.uint8_t, True), 192 | ELECTSMART_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True), 193 | ELECTSMART_TEMP_CALIBRATION_ATTR: ( 194 | "temperature_calibration", 195 | t.int32s, 196 | True, 197 | ), 198 | } 199 | ) 200 | 201 | DIRECT_MAPPED_ATTRS = { 202 | ELECTSMART_TEMPERATURE_ATTR: ( 203 | "local_temperature", 204 | lambda value: value * 10, 205 | ), 206 | ELECTSMART_TARGET_TEMP_ATTR: ( 207 | "occupied_heating_setpoint", 208 | lambda value: value * 10, 209 | ), 210 | } 211 | 212 | def _update_attribute(self, attrid, value): 213 | """Override default _update_attribute.""" 214 | super()._update_attribute(attrid, value) 215 | if attrid in self.DIRECT_MAPPED_ATTRS: 216 | self.endpoint.device.thermostat_bus.listener_event( 217 | "temperature_change", 218 | self.DIRECT_MAPPED_ATTRS[attrid][0], 219 | ( 220 | value 221 | if self.DIRECT_MAPPED_ATTRS[attrid][1] is None 222 | else self.DIRECT_MAPPED_ATTRS[attrid][1](value) 223 | ), 224 | ) 225 | 226 | if attrid == ELECTSMART_TEMP_CALIBRATION_ATTR: 227 | self.endpoint.device.ElectsmartTempCalibration_bus.listener_event( 228 | "set_value", value / 10 229 | ) 230 | 231 | if attrid == ELECTSMART_CHILD_LOCK_ATTR: 232 | self.endpoint.device.ui_bus.listener_event("child_lock_change", value) 233 | self.endpoint.device.thermostat_onoff_bus.listener_event( 234 | "child_lock_change", value 235 | ) 236 | elif attrid == ELECTSMART_MODE_ATTR: 237 | self.endpoint.device.thermostat_bus.listener_event("mode_change", value) 238 | elif attrid == ELECTSMART_HEAT_STATE_ATTR: 239 | if self.endpoint.device.manufacturer == "_TZE200_g9a3awaj": 240 | self.endpoint.device.thermostat_bus.listener_event( 241 | "state_change", value 242 | ) 243 | else: 244 | self.endpoint.device.thermostat_bus.listener_event( 245 | "state_change", not value 246 | ) 247 | elif attrid == ELECTSMART_SYSTEM_MODE_ATTR: 248 | self.endpoint.device.thermostat_bus.listener_event( 249 | "system_mode_change", value 250 | ) 251 | 252 | 253 | class ElectsmartThermostat(TuyaThermostatCluster): 254 | """Thermostat cluster for thermostatic valves.""" 255 | 256 | class Preset(t.enum8): 257 | """Working modes of the thermostat.""" 258 | 259 | Away = 0x00 260 | Schedule = 0x01 261 | Manual = 0x02 262 | Comfort = 0x03 263 | Eco = 0x04 264 | Boost = 0x05 265 | Complex = 0x06 266 | TempManual = 0x07 267 | 268 | class WorkDays(t.enum8): 269 | """Workday configuration for scheduler operation mode.""" 270 | 271 | MonToFri = 0x00 272 | MonToSat = 0x01 273 | MonToSun = 0x02 274 | 275 | class ForceValveState(t.enum8): 276 | """Force valve state option.""" 277 | 278 | Normal = 0x00 279 | Open = 0x01 280 | Close = 0x02 281 | 282 | _CONSTANT_ATTRIBUTES = { 283 | 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, 284 | } 285 | 286 | attributes = TuyaThermostatCluster.attributes.copy() 287 | attributes.update( 288 | { 289 | 0x4002: ("operation_preset", Preset, True), 290 | } 291 | ) 292 | 293 | DIRECT_MAPPING_ATTRS = { 294 | "occupied_heating_setpoint": ( 295 | ELECTSMART_TARGET_TEMP_ATTR, 296 | lambda value: round(value / 100), 297 | ), 298 | } 299 | 300 | def __init__(self, *args, **kwargs): 301 | """Init.""" 302 | super().__init__(*args, **kwargs) 303 | self.endpoint.device.thermostat_bus.add_listener(self) 304 | self.endpoint.device.thermostat_bus.listener_event( 305 | "temperature_change", 306 | "min_heat_setpoint_limit", 307 | ELECTSMART_MIN_TEMPERATURE_VAL, 308 | ) 309 | self.endpoint.device.thermostat_bus.listener_event( 310 | "temperature_change", 311 | "max_heat_setpoint_limit", 312 | ELECTSMART_MAX_TEMPERATURE_VAL, 313 | ) 314 | 315 | def map_attribute(self, attribute, value): 316 | """Map standardized attribute value to dict of manufacturer values.""" 317 | 318 | if attribute in self.DIRECT_MAPPING_ATTRS: 319 | return { 320 | self.DIRECT_MAPPING_ATTRS[attribute][0]: ( 321 | value 322 | if self.DIRECT_MAPPING_ATTRS[attribute][1] is None 323 | else self.DIRECT_MAPPING_ATTRS[attribute][1](value) 324 | ) 325 | } 326 | 327 | if attribute == "operation_preset": 328 | if value == 1: 329 | return {ELECTSMART_MODE_ATTR: 1} 330 | if value == 2: 331 | return {ELECTSMART_MODE_ATTR: 0} 332 | 333 | if attribute in ("programing_oper_mode", "occupancy"): 334 | if attribute == "occupancy": 335 | occupancy = value 336 | oper_mode = self._attr_cache.get( 337 | self.attributes_by_name["programing_oper_mode"].id, 338 | self.ProgrammingOperationMode.Simple, 339 | ) 340 | else: 341 | occupancy = self._attr_cache.get( 342 | self.attributes_by_name["occupancy"].id, self.Occupancy.Occupied 343 | ) 344 | oper_mode = value 345 | if occupancy == self.Occupancy.Occupied: 346 | if oper_mode == self.ProgrammingOperationMode.Schedule_programming_mode: 347 | return {ELECTSMART_MODE_ATTR: 1} 348 | if oper_mode == self.ProgrammingOperationMode.Simple: 349 | return {ELECTSMART_MODE_ATTR: 0} 350 | self.error("Unsupported value for ProgrammingOperationMode") 351 | else: 352 | self.error("Unsupported value for Occupancy") 353 | 354 | if attribute == "system_mode": 355 | if value == self.SystemMode.Off: 356 | mode = 0 357 | else: 358 | mode = 1 359 | return {ELECTSMART_SYSTEM_MODE_ATTR: mode} 360 | 361 | def mode_change(self, value): 362 | """Preset Mode change.""" 363 | if value == 0: 364 | operation_preset = self.Preset.Manual 365 | prog_mode = self.ProgrammingOperationMode.Simple 366 | occupancy = self.Occupancy.Occupied 367 | else: 368 | operation_preset = self.Preset.Schedule 369 | prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode 370 | occupancy = self.Occupancy.Occupied 371 | 372 | self._update_attribute( 373 | self.attributes_by_name["programing_oper_mode"].id, prog_mode 374 | ) 375 | self._update_attribute(self.attributes_by_name["occupancy"].id, occupancy) 376 | self._update_attribute( 377 | self.attributes_by_name["operation_preset"].id, operation_preset 378 | ) 379 | 380 | def system_mode_change(self, value): 381 | """System Mode change.""" 382 | if value == 0: 383 | mode = self.SystemMode.Off 384 | else: 385 | mode = self.SystemMode.Heat 386 | self._update_attribute(self.attributes_by_name["system_mode"].id, mode) 387 | 388 | 389 | class ElectsmartUserInterface(TuyaUserInterfaceCluster): 390 | """HVAC User interface cluster for tuya electric heating thermostats.""" 391 | 392 | _CHILD_LOCK_ATTR = ELECTSMART_CHILD_LOCK_ATTR 393 | 394 | 395 | class ElectsmartChildLock(CustomTuyaOnOff): 396 | """On/Off cluster for the child lock function of the electric heating thermostats.""" 397 | 398 | def child_lock_change(self, value): 399 | """Child lock change.""" 400 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 401 | 402 | def map_attribute(self, attribute, value): 403 | """Map standardized attribute value to dict of manufacturer values.""" 404 | if attribute == "on_off": 405 | return {ELECTSMART_CHILD_LOCK_ATTR: value} 406 | 407 | 408 | class ElectsmartTempCalibration(LocalDataCluster, AnalogOutput): 409 | """Analog output for Temp Calibration.""" 410 | 411 | def __init__(self, *args, **kwargs): 412 | """Init.""" 413 | super().__init__(*args, **kwargs) 414 | self.endpoint.device.ElectsmartTempCalibration_bus.add_listener(self) 415 | self._update_attribute( 416 | self.attributes_by_name["description"].id, "Temperature Calibration" 417 | ) 418 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 10) 419 | self._update_attribute(self.attributes_by_name["min_present_value"].id, -10) 420 | self._update_attribute(self.attributes_by_name["resolution"].id, 0.1) 421 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 422 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 423 | 424 | def set_value(self, value): 425 | """Set value.""" 426 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 427 | 428 | def get_value(self): 429 | """Get value.""" 430 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 431 | 432 | async def write_attributes(self, attributes, manufacturer=None): 433 | """Override the default Cluster write_attributes.""" 434 | for attrid, value in attributes.items(): 435 | if isinstance(attrid, str): 436 | attrid = self.attributes_by_name[attrid].id 437 | if attrid not in self.attributes: 438 | self.error("%d is not a valid attribute id", attrid) 439 | continue 440 | self._update_attribute(attrid, value) 441 | 442 | if self.endpoint.device.manufacturer in ( 443 | "_TZE200_2ekuz3dz", 444 | "_TZE200_g9a3awaj", 445 | ): 446 | await ElectsmartManufClusterSelf[ 447 | self.endpoint.device.ieee 448 | ].endpoint.tuya_manufacturer.write_attributes( 449 | {ELECTSMART_TEMP_CALIBRATION_ATTR: value * 10}, 450 | manufacturer=None, 451 | ) 452 | else: 453 | await ElectsmartManufClusterSelf[ 454 | self.endpoint.device.ieee 455 | ].endpoint.tuya_manufacturer.write_attributes( 456 | {ELECTSMART_TEMP_CALIBRATION_ATTR: value}, 457 | manufacturer=None, 458 | ) 459 | 460 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 461 | 462 | 463 | class Electsmart(EnchantedDevice, TuyaThermostat): 464 | """Electsmart Thermostatic radiator valve.""" 465 | 466 | def __init__(self, *args, **kwargs): 467 | """Init device.""" 468 | self.thermostat_onoff_bus = Bus() 469 | self.ElectsmartTempCalibration_bus = Bus() 470 | super().__init__(*args, **kwargs) 471 | 472 | signature = { 473 | # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] 474 | # output_clusters=[10, 25]> 475 | MODELS_INFO: [ 476 | ("_TZE204_edl8pz1k", "TS0601"), 477 | ], 478 | ENDPOINTS: { 479 | 1: { 480 | PROFILE_ID: zha.PROFILE_ID, 481 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 482 | INPUT_CLUSTERS: [ 483 | Basic.cluster_id, 484 | Groups.cluster_id, 485 | Scenes.cluster_id, 486 | TuyaManufClusterAttributes.cluster_id, 487 | ], 488 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 489 | } 490 | }, 491 | } 492 | 493 | replacement = { 494 | ENDPOINTS: { 495 | 1: { 496 | PROFILE_ID: zha.PROFILE_ID, 497 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 498 | INPUT_CLUSTERS: [ 499 | Basic.cluster_id, 500 | Groups.cluster_id, 501 | Scenes.cluster_id, 502 | ElectsmartManufCluster, 503 | ElectsmartThermostat, 504 | ElectsmartUserInterface, 505 | TuyaPowerConfigurationCluster, 506 | ], 507 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 508 | }, 509 | 2: { 510 | PROFILE_ID: zha.PROFILE_ID, 511 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 512 | INPUT_CLUSTERS: [ElectsmartChildLock], 513 | OUTPUT_CLUSTERS: [], 514 | }, 515 | 3: { 516 | PROFILE_ID: zha.PROFILE_ID, 517 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 518 | INPUT_CLUSTERS: [ElectsmartTempCalibration], 519 | OUTPUT_CLUSTERS: [], 520 | }, 521 | } 522 | } 523 | -------------------------------------------------------------------------------- /ts0601_thermostat_zwt198.py: -------------------------------------------------------------------------------- 1 | """ZWT198 TRV devices support.""" 2 | 3 | import logging 4 | from typing import Optional, Union 5 | 6 | import zigpy.types as t 7 | from zhaquirks import Bus, LocalDataCluster 8 | from zhaquirks.const import ( 9 | DEVICE_TYPE, 10 | ENDPOINTS, 11 | INPUT_CLUSTERS, 12 | MODELS_INFO, 13 | OUTPUT_CLUSTERS, 14 | PROFILE_ID, 15 | ) 16 | from zhaquirks.tuya import ( 17 | EnchantedDevice, 18 | NoManufacturerCluster, 19 | TuyaManufCluster, 20 | TuyaManufClusterAttributes, 21 | TuyaPowerConfigurationCluster, 22 | TuyaThermostat, 23 | TuyaThermostatCluster, 24 | TuyaTimePayload, 25 | TuyaUserInterfaceCluster, 26 | ) 27 | from zigpy.profiles import zha 28 | from zigpy.zcl import foundation 29 | from zigpy.zcl.clusters.general import ( 30 | AnalogOutput, 31 | Basic, 32 | GreenPowerProxy, 33 | Groups, 34 | OnOff, 35 | Ota, 36 | Scenes, 37 | Time, 38 | ) 39 | from zigpy.zcl.clusters.hvac import Thermostat 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | ZWT198_TARGET_TEMP_ATTR = 0x0202 # target room temp (degree) 44 | ZWT198_TEMPERATURE_ATTR = 0x0203 # current room temp (degree/10) 45 | ZWT198_MODE_ATTR = 0x0404 # [0] schedule [1] manual 46 | ZWT198_SYSTEM_MODE_ATTR = 0x0101 # device [0] off [1] on 47 | ZWT198_HEAT_STATE_ATTR = 0x0465 # [0] heating icon off [1] heating icon on 48 | ZWT198_CHILD_LOCK_ATTR = 0x0109 # [0] unlocked [1] locked 49 | ZWT198_TEMP_CALIBRATION_ATTR = 0x0213 # temperature calibration (degree) 50 | ZWT198_MIN_TEMPERATURE_VAL = 500 # minimum limit of temperature setting (degree/100) 51 | ZWT198_MAX_TEMPERATURE_VAL = 3500 # maximum limit of temperature setting (degree/100) 52 | ZWT198ManufClusterSelf = {} 53 | 54 | 55 | class CustomTuyaOnOff(LocalDataCluster, OnOff): 56 | """Custom Tuya OnOff cluster.""" 57 | 58 | def __init__(self, *args, **kwargs): 59 | """Init.""" 60 | super().__init__(*args, **kwargs) 61 | self.endpoint.device.thermostat_onoff_bus.add_listener(self) 62 | 63 | # pylint: disable=R0201 64 | def map_attribute(self, attribute, value): 65 | """Map standardized attribute value to dict of manufacturer values.""" 66 | return {} 67 | 68 | async def write_attributes(self, attributes, manufacturer=None): 69 | """Implement writeable attributes.""" 70 | 71 | records = self._write_attr_records(attributes) 72 | 73 | if not records: 74 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 75 | 76 | manufacturer_attrs = {} 77 | for record in records: 78 | attr_name = self.attributes[record.attrid].name 79 | new_attrs = self.map_attribute(attr_name, record.value.value) 80 | 81 | _LOGGER.debug( 82 | "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " 83 | "with value %s to custom %s", 84 | self.endpoint.device.nwk, 85 | self.endpoint.endpoint_id, 86 | self.cluster_id, 87 | attr_name, 88 | record.attrid, 89 | repr(record.value.value), 90 | repr(new_attrs), 91 | ) 92 | 93 | manufacturer_attrs.update(new_attrs) 94 | 95 | if not manufacturer_attrs: 96 | return [ 97 | [ 98 | foundation.WriteAttributesStatusRecord( 99 | foundation.Status.FAILURE, r.attrid 100 | ) 101 | for r in records 102 | ] 103 | ] 104 | 105 | await ZWT198ManufClusterSelf[ 106 | self.endpoint.device.ieee 107 | ].endpoint.tuya_manufacturer.write_attributes( 108 | manufacturer_attrs, manufacturer=manufacturer 109 | ) 110 | 111 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 112 | 113 | async def command( 114 | self, 115 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 116 | *args, 117 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 118 | expect_reply: bool = True, 119 | tsn: Optional[Union[int, t.uint8_t]] = None, 120 | ): 121 | """Override the default Cluster command.""" 122 | 123 | if command_id in (0x0000, 0x0001, 0x0002): 124 | 125 | if command_id == 0x0000: 126 | value = False 127 | elif command_id == 0x0001: 128 | value = True 129 | else: 130 | attrid = self.attributes_by_name["on_off"].id 131 | success, _ = await self.read_attributes( 132 | (attrid,), manufacturer=manufacturer 133 | ) 134 | try: 135 | value = success[attrid] 136 | except KeyError: 137 | return foundation.Status.FAILURE 138 | value = not value 139 | 140 | (res,) = await self.write_attributes( 141 | {"on_off": value}, 142 | manufacturer=manufacturer, 143 | ) 144 | return [command_id, res[0].status] 145 | 146 | return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] 147 | 148 | 149 | class ZWT198ManufCluster(TuyaManufClusterAttributes): 150 | """Manufacturer Specific Cluster of thermostatic valves.""" 151 | 152 | def __init__(self, *args, **kwargs): 153 | """Init.""" 154 | super().__init__(*args, **kwargs) 155 | global ZWT198ManufClusterSelf 156 | ZWT198ManufClusterSelf[self.endpoint.device.ieee] = self 157 | 158 | set_time_offset = 1970 159 | 160 | attributes = TuyaManufClusterAttributes.attributes.copy() 161 | attributes.update( 162 | { 163 | ZWT198_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True), 164 | ZWT198_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True), 165 | ZWT198_MODE_ATTR: ("mode", t.uint8_t, True), 166 | ZWT198_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True), 167 | ZWT198_HEAT_STATE_ATTR: ("heat_state", t.uint8_t, True), 168 | ZWT198_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True), 169 | ZWT198_TEMP_CALIBRATION_ATTR: ("temperature_calibration", t.int32s, True), 170 | } 171 | ) 172 | 173 | DIRECT_MAPPED_ATTRS = { 174 | ZWT198_TEMPERATURE_ATTR: ( 175 | "local_temperature", 176 | lambda value: value * 10, 177 | ), 178 | ZWT198_TARGET_TEMP_ATTR: ( 179 | "occupied_heating_setpoint", 180 | lambda value: value * 10, 181 | ), 182 | ZWT198_TEMP_CALIBRATION_ATTR: ( 183 | "local_temperature_calibration", 184 | lambda value: value * 10, 185 | ), 186 | } 187 | 188 | def _update_attribute(self, attrid, value): 189 | """Override default _update_attribute.""" 190 | super()._update_attribute(attrid, value) 191 | if attrid in self.DIRECT_MAPPED_ATTRS: 192 | self.endpoint.device.thermostat_bus.listener_event( 193 | "temperature_change", 194 | self.DIRECT_MAPPED_ATTRS[attrid][0], 195 | ( 196 | value 197 | if self.DIRECT_MAPPED_ATTRS[attrid][1] is None 198 | else self.DIRECT_MAPPED_ATTRS[attrid][1](value) 199 | ), 200 | ) 201 | 202 | if attrid == ZWT198_CHILD_LOCK_ATTR: 203 | self.endpoint.device.ui_bus.listener_event("child_lock_change", value) 204 | self.endpoint.device.thermostat_onoff_bus.listener_event( 205 | "child_lock_change", value 206 | ) 207 | elif attrid == ZWT198_MODE_ATTR: 208 | self.endpoint.device.thermostat_bus.listener_event("mode_change", value) 209 | elif attrid == ZWT198_TEMP_CALIBRATION_ATTR: 210 | self.endpoint.device.ZWT198TempCalibration_bus.listener_event( 211 | "set_value", value / 10 212 | ) 213 | elif attrid == ZWT198_HEAT_STATE_ATTR: 214 | self.endpoint.device.thermostat_bus.listener_event("state_change", value) 215 | elif attrid == ZWT198_SYSTEM_MODE_ATTR: 216 | self.endpoint.device.thermostat_bus.listener_event( 217 | "system_mode_change", value 218 | ) 219 | 220 | 221 | class ZWT198Thermostat(TuyaThermostatCluster): 222 | """Thermostat cluster for thermostatic valves.""" 223 | 224 | class Preset(t.enum8): 225 | """Working modes of the thermostat.""" 226 | 227 | Away = 0x00 228 | Schedule = 0x01 229 | Manual = 0x02 230 | Comfort = 0x03 231 | Eco = 0x04 232 | Boost = 0x05 233 | Complex = 0x06 234 | TempManual = 0x07 235 | 236 | class WorkDays(t.enum8): 237 | """Workday configuration for scheduler operation mode.""" 238 | 239 | MonToFri = 0x00 240 | MonToSat = 0x01 241 | MonToSun = 0x02 242 | 243 | class ForceValveState(t.enum8): 244 | """Force valve state option.""" 245 | 246 | Normal = 0x00 247 | Open = 0x01 248 | Close = 0x02 249 | 250 | _CONSTANT_ATTRIBUTES = { 251 | 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, 252 | } 253 | 254 | attributes = TuyaThermostatCluster.attributes.copy() 255 | attributes.update( 256 | { 257 | 0x4002: ("operation_preset", Preset, True), 258 | } 259 | ) 260 | 261 | DIRECT_MAPPING_ATTRS = { 262 | "local_temperature_calibration": ( 263 | ZWT198_TEMP_CALIBRATION_ATTR, 264 | lambda value: round(value / 10), 265 | ), 266 | "occupied_heating_setpoint": ( 267 | ZWT198_TARGET_TEMP_ATTR, 268 | lambda value: round(value / 10), 269 | ), 270 | } 271 | 272 | def __init__(self, *args, **kwargs): 273 | """Init.""" 274 | super().__init__(*args, **kwargs) 275 | self.endpoint.device.thermostat_bus.add_listener(self) 276 | self.endpoint.device.thermostat_bus.listener_event( 277 | "temperature_change", 278 | "min_heat_setpoint_limit", 279 | ZWT198_MIN_TEMPERATURE_VAL, 280 | ) 281 | self.endpoint.device.thermostat_bus.listener_event( 282 | "temperature_change", 283 | "max_heat_setpoint_limit", 284 | ZWT198_MAX_TEMPERATURE_VAL, 285 | ) 286 | 287 | def map_attribute(self, attribute, value): 288 | """Map standardized attribute value to dict of manufacturer values.""" 289 | 290 | if attribute in self.DIRECT_MAPPING_ATTRS: 291 | return { 292 | self.DIRECT_MAPPING_ATTRS[attribute][0]: ( 293 | value 294 | if self.DIRECT_MAPPING_ATTRS[attribute][1] is None 295 | else self.DIRECT_MAPPING_ATTRS[attribute][1](value) 296 | ) 297 | } 298 | 299 | if attribute == "operation_preset": 300 | if value == 1: 301 | return {ZWT198_MODE_ATTR: 0} 302 | if value == 2: 303 | return {ZWT198_MODE_ATTR: 1} 304 | 305 | if attribute in ("programing_oper_mode", "occupancy"): 306 | if attribute == "occupancy": 307 | occupancy = value 308 | oper_mode = self._attr_cache.get( 309 | self.attributes_by_name["programing_oper_mode"].id, 310 | self.ProgrammingOperationMode.Simple, 311 | ) 312 | else: 313 | occupancy = self._attr_cache.get( 314 | self.attributes_by_name["occupancy"].id, self.Occupancy.Occupied 315 | ) 316 | oper_mode = value 317 | if occupancy == self.Occupancy.Occupied: 318 | if oper_mode == self.ProgrammingOperationMode.Schedule_programming_mode: 319 | return {ZWT198_MODE_ATTR: 0} 320 | if oper_mode == self.ProgrammingOperationMode.Simple: 321 | return {ZWT198_MODE_ATTR: 1} 322 | self.error("Unsupported value for ProgrammingOperationMode") 323 | else: 324 | self.error("Unsupported value for Occupancy") 325 | 326 | if attribute == "system_mode": 327 | if value == self.SystemMode.Off: 328 | mode = 0 329 | else: 330 | mode = 1 331 | return {ZWT198_SYSTEM_MODE_ATTR: mode} 332 | 333 | def mode_change(self, value): 334 | """Preset Mode change.""" 335 | if value == 0: 336 | operation_preset = self.Preset.Schedule 337 | prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode 338 | occupancy = self.Occupancy.Occupied 339 | else: 340 | operation_preset = self.Preset.Manual 341 | prog_mode = self.ProgrammingOperationMode.Simple 342 | occupancy = self.Occupancy.Occupied 343 | 344 | self._update_attribute( 345 | self.attributes_by_name["programing_oper_mode"].id, prog_mode 346 | ) 347 | self._update_attribute(self.attributes_by_name["occupancy"].id, occupancy) 348 | self._update_attribute( 349 | self.attributes_by_name["operation_preset"].id, operation_preset 350 | ) 351 | 352 | def system_mode_change(self, value): 353 | """System Mode change.""" 354 | if value == 0: 355 | mode = self.SystemMode.Off 356 | else: 357 | mode = self.SystemMode.Heat 358 | self._update_attribute(self.attributes_by_name["system_mode"].id, mode) 359 | 360 | 361 | class ZWT198UserInterface(TuyaUserInterfaceCluster): 362 | """HVAC User interface cluster for tuya electric heating thermostats.""" 363 | 364 | _CHILD_LOCK_ATTR = ZWT198_CHILD_LOCK_ATTR 365 | 366 | 367 | class ZWT198ChildLock(CustomTuyaOnOff): 368 | """On/Off cluster for the child lock function of the electric heating thermostats.""" 369 | 370 | def child_lock_change(self, value): 371 | """Child lock change.""" 372 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 373 | 374 | def map_attribute(self, attribute, value): 375 | """Map standardized attribute value to dict of manufacturer values.""" 376 | if attribute == "on_off": 377 | return {ZWT198_CHILD_LOCK_ATTR: value} 378 | 379 | 380 | class ZWT198TempCalibration(LocalDataCluster, AnalogOutput): 381 | """Analog output for Temp Calibration.""" 382 | 383 | def __init__(self, *args, **kwargs): 384 | """Init.""" 385 | super().__init__(*args, **kwargs) 386 | self.endpoint.device.ZWT198TempCalibration_bus.add_listener(self) 387 | self._update_attribute( 388 | self.attributes_by_name["description"].id, "Temperature Calibration" 389 | ) 390 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 10) 391 | self._update_attribute(self.attributes_by_name["min_present_value"].id, -10) 392 | self._update_attribute(self.attributes_by_name["resolution"].id, 0.1) 393 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 394 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 395 | 396 | def set_value(self, value): 397 | """Set value.""" 398 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 399 | 400 | def get_value(self): 401 | """Get value.""" 402 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 403 | 404 | async def write_attributes(self, attributes, manufacturer=None): 405 | """Override the default Cluster write_attributes.""" 406 | for attrid, value in attributes.items(): 407 | if isinstance(attrid, str): 408 | attrid = self.attributes_by_name[attrid].id 409 | if attrid not in self.attributes: 410 | self.error("%d is not a valid attribute id", attrid) 411 | continue 412 | self._update_attribute(attrid, value) 413 | 414 | await ZWT198ManufClusterSelf[ 415 | self.endpoint.device.ieee 416 | ].endpoint.tuya_manufacturer.write_attributes( 417 | {ZWT198_TEMP_CALIBRATION_ATTR: value * 10}, 418 | manufacturer=None, 419 | ) 420 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 421 | 422 | 423 | class ZWT198(EnchantedDevice, TuyaThermostat): 424 | """ZWT198 Thermostatic radiator valve.""" 425 | 426 | def __init__(self, *args, **kwargs): 427 | """Init device.""" 428 | self.thermostat_onoff_bus = Bus() 429 | self.ZWT198TempCalibration_bus = Bus() 430 | super().__init__(*args, **kwargs) 431 | 432 | signature = { 433 | # endpoint=1 profile=260 device_type=81 device_version=1 input_clusters=[0, 4, 5, 61184] 434 | # output_clusters=[10, 25]> 435 | MODELS_INFO: [ 436 | ("_TZE200_viy9ihs7", "TS0601"), 437 | ], 438 | ENDPOINTS: { 439 | 1: { 440 | PROFILE_ID: zha.PROFILE_ID, 441 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 442 | INPUT_CLUSTERS: [ 443 | Basic.cluster_id, 444 | Groups.cluster_id, 445 | Scenes.cluster_id, 446 | TuyaManufClusterAttributes.cluster_id, 447 | ], 448 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 449 | }, 450 | }, 451 | } 452 | 453 | replacement = { 454 | ENDPOINTS: { 455 | 1: { 456 | PROFILE_ID: zha.PROFILE_ID, 457 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 458 | INPUT_CLUSTERS: [ 459 | Basic.cluster_id, 460 | Groups.cluster_id, 461 | Scenes.cluster_id, 462 | ZWT198ManufCluster, 463 | ZWT198Thermostat, 464 | ZWT198UserInterface, 465 | TuyaPowerConfigurationCluster, 466 | ], 467 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 468 | }, 469 | 2: { 470 | PROFILE_ID: zha.PROFILE_ID, 471 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 472 | INPUT_CLUSTERS: [ZWT198ChildLock], 473 | OUTPUT_CLUSTERS: [], 474 | }, 475 | 3: { 476 | PROFILE_ID: zha.PROFILE_ID, 477 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 478 | INPUT_CLUSTERS: [ZWT198TempCalibration], 479 | OUTPUT_CLUSTERS: [], 480 | }, 481 | } 482 | } 483 | -------------------------------------------------------------------------------- /ts0601_trv_etop.py: -------------------------------------------------------------------------------- 1 | """Etop TRV devices support.""" 2 | 3 | import logging 4 | import math 5 | from typing import Optional, Union 6 | 7 | import zigpy.types as t 8 | from zhaquirks import Bus, LocalDataCluster 9 | from zhaquirks.const import ( 10 | DEVICE_TYPE, 11 | ENDPOINTS, 12 | INPUT_CLUSTERS, 13 | MODELS_INFO, 14 | OUTPUT_CLUSTERS, 15 | PROFILE_ID, 16 | ) 17 | from zhaquirks.tuya import ( 18 | EnchantedDevice, 19 | TuyaManufClusterAttributes, 20 | TuyaPowerConfigurationCluster, 21 | TuyaThermostat, 22 | TuyaThermostatCluster, 23 | TuyaUserInterfaceCluster, 24 | ) 25 | from zigpy.profiles import zha 26 | from zigpy.zcl import foundation 27 | from zigpy.zcl.clusters.general import ( 28 | AnalogOutput, 29 | Basic, 30 | BinaryInput, 31 | Groups, 32 | Identify, 33 | OnOff, 34 | Ota, 35 | Scenes, 36 | Time, 37 | ) 38 | from zigpy.zcl.clusters.hvac import Thermostat 39 | 40 | _LOGGER = logging.getLogger(__name__) 41 | 42 | 43 | ETOP_TARGET_TEMP_ATTR = 0x0210 # target temp, degrees/10 44 | ETOP_TEMPERATURE_ATTR = 0x0218 # current room temp, degrees/10 45 | ETOP_PRESET_ATTR = 0x0402 # [0] manual [1] away [2] scheduled 46 | ETOP_SYSTEM_MODE_ATTR = 0x0101 # [0] off [1] heat 47 | ETOP_BATTERY_STATE_ATTR = 0x0523 # [0] OK [1] Empty 48 | ETOP_WINDOW_DETECT_FUNC_ATTR = 0x0108 # [0] off [1] on 49 | ETOP_MIN_TEMPERATURE_VAL = 5 50 | ETOP_MAX_TEMPERATURE_VAL = 30 51 | EtopManufClusterSelf = {} 52 | 53 | # TUYA_DP_TYPE_RAW = 0x0000 54 | # TUYA_DP_TYPE_BOOL = 0x0100 55 | # TUYA_DP_TYPE_VALUE = 0x0200 56 | # TUYA_DP_TYPE_STRING = 0x0300 57 | # TUYA_DP_TYPE_ENUM = 0x0400 58 | # TUYA_DP_TYPE_FAULT = 0x0500 59 | 60 | 61 | class CustomTuyaOnOff(LocalDataCluster, OnOff): 62 | """Custom Tuya OnOff cluster.""" 63 | 64 | def __init__(self, *args, **kwargs): 65 | """Init.""" 66 | super().__init__(*args, **kwargs) 67 | self.endpoint.device.thermostat_onoff_bus.add_listener(self) 68 | 69 | # pylint: disable=R0201 70 | def map_attribute(self, attribute, value): 71 | """Map standardized attribute value to dict of manufacturer values.""" 72 | return {} 73 | 74 | async def write_attributes(self, attributes, manufacturer=None): 75 | """Implement writeable attributes.""" 76 | 77 | records = self._write_attr_records(attributes) 78 | 79 | if not records: 80 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 81 | 82 | manufacturer_attrs = {} 83 | for record in records: 84 | attr_name = self.attributes[record.attrid].name 85 | new_attrs = self.map_attribute(attr_name, record.value.value) 86 | 87 | _LOGGER.debug( 88 | "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " 89 | "with value %s to custom %s", 90 | self.endpoint.device.nwk, 91 | self.endpoint.endpoint_id, 92 | self.cluster_id, 93 | attr_name, 94 | record.attrid, 95 | repr(record.value.value), 96 | repr(new_attrs), 97 | ) 98 | 99 | manufacturer_attrs.update(new_attrs) 100 | 101 | if not manufacturer_attrs: 102 | return [ 103 | [ 104 | foundation.WriteAttributesStatusRecord( 105 | foundation.Status.FAILURE, r.attrid 106 | ) 107 | for r in records 108 | ] 109 | ] 110 | 111 | await EtopManufClusterSelf[ 112 | self.endpoint.device.ieee 113 | ].endpoint.tuya_manufacturer.write_attributes( 114 | manufacturer_attrs, manufacturer=manufacturer 115 | ) 116 | 117 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 118 | 119 | async def command( 120 | self, 121 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 122 | *args, 123 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 124 | expect_reply: bool = True, 125 | tsn: Optional[Union[int, t.uint8_t]] = None, 126 | ): 127 | """Override the default Cluster command.""" 128 | 129 | if command_id in (0x0000, 0x0001, 0x0002): 130 | 131 | if command_id == 0x0000: 132 | value = False 133 | elif command_id == 0x0001: 134 | value = True 135 | else: 136 | attrid = self.attributes_by_name["on_off"].id 137 | success, _ = await self.read_attributes( 138 | (attrid,), manufacturer=manufacturer 139 | ) 140 | try: 141 | value = success[attrid] 142 | except KeyError: 143 | return foundation.Status.FAILURE 144 | value = not value 145 | 146 | (res,) = await self.write_attributes( 147 | {"on_off": value}, 148 | manufacturer=manufacturer, 149 | ) 150 | return [command_id, res[0].status] 151 | 152 | return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] 153 | 154 | 155 | class EtopManufCluster(TuyaManufClusterAttributes): 156 | """Manufacturer Specific Cluster of some thermostatic valves.""" 157 | 158 | def __init__(self, *args, **kwargs): 159 | """Init.""" 160 | super().__init__(*args, **kwargs) 161 | self.endpoint.device.EtopManufCluster_bus.add_listener(self) 162 | global EtopManufClusterSelf 163 | EtopManufClusterSelf[self.endpoint.device.ieee] = self 164 | 165 | set_time_offset = 1970 166 | 167 | attributes = TuyaManufClusterAttributes.attributes.copy() 168 | attributes.update( 169 | { 170 | ETOP_TARGET_TEMP_ATTR: ( 171 | "target_temperature", 172 | t.uint32_t, 173 | True, 174 | ), 175 | ETOP_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True), 176 | ETOP_PRESET_ATTR: ("preset", t.uint8_t, True), 177 | ETOP_SYSTEM_MODE_ATTR: ("system_mode", t.uint8_t, True), 178 | ETOP_BATTERY_STATE_ATTR: ("battery_state", t.uint8_t, True), 179 | ETOP_WINDOW_DETECT_FUNC_ATTR: ("window_detection_func", t.uint8_t, True), 180 | } 181 | ) 182 | 183 | DIRECT_MAPPED_ATTRS = { 184 | ETOP_TEMPERATURE_ATTR: ( 185 | "local_temperature", 186 | lambda value: value * 10, 187 | ), 188 | ETOP_TARGET_TEMP_ATTR: ( 189 | "occupied_heating_setpoint", 190 | lambda value: value * 10, 191 | ), 192 | } 193 | 194 | def _update_attribute(self, attrid, value): 195 | """Override default _update_attribute.""" 196 | super()._update_attribute(attrid, value) 197 | 198 | if attrid in self.DIRECT_MAPPED_ATTRS: 199 | self.endpoint.device.thermostat_bus.listener_event( 200 | "temperature_change", 201 | self.DIRECT_MAPPED_ATTRS[attrid][0], 202 | ( 203 | value 204 | if self.DIRECT_MAPPED_ATTRS[attrid][1] is None 205 | else self.DIRECT_MAPPED_ATTRS[attrid][1](value) 206 | ), 207 | ) 208 | 209 | if attrid == ETOP_PRESET_ATTR: 210 | self.endpoint.device.thermostat_bus.listener_event("preset_change", value) 211 | elif attrid == ETOP_SYSTEM_MODE_ATTR: 212 | self.endpoint.device.thermostat_bus.listener_event( 213 | "system_mode_change", value 214 | ) 215 | elif attrid == ETOP_BATTERY_STATE_ATTR: 216 | self.endpoint.device.battery_bus.listener_event( 217 | "battery_change", 0 if value == 1 else 100 218 | ) 219 | elif attrid == ETOP_WINDOW_DETECT_FUNC_ATTR: 220 | self.endpoint.device.thermostat_onoff_bus.listener_event( 221 | "window_detect_func_change", value 222 | ) 223 | 224 | 225 | class EtopThermostat(TuyaThermostatCluster): 226 | """Thermostat cluster for some thermostatic valves.""" 227 | 228 | class Preset(t.enum8): 229 | """Working modes of the thermostat.""" 230 | 231 | Away = 0x00 232 | Schedule = 0x01 233 | Manual = 0x02 234 | Comfort = 0x03 235 | Eco = 0x04 236 | Boost = 0x05 237 | Complex = 0x06 238 | 239 | _CONSTANT_ATTRIBUTES = { 240 | 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, 241 | } 242 | 243 | attributes = TuyaThermostatCluster.attributes.copy() 244 | attributes.update( 245 | { 246 | 0x4002: ("operation_preset", Preset, True), 247 | } 248 | ) 249 | 250 | DIRECT_MAPPING_ATTRS = { 251 | "occupied_heating_setpoint": ( 252 | ETOP_TARGET_TEMP_ATTR, 253 | lambda value: round(value / 10), 254 | ), 255 | } 256 | 257 | def __init__(self, *args, **kwargs): 258 | """Init.""" 259 | super().__init__(*args, **kwargs) 260 | self.endpoint.device.thermostat_bus.add_listener(self) 261 | self.endpoint.device.thermostat_bus.listener_event( 262 | "temperature_change", 263 | "min_heat_setpoint_limit", 264 | ETOP_MIN_TEMPERATURE_VAL * 100, 265 | ) 266 | self.endpoint.device.thermostat_bus.listener_event( 267 | "temperature_change", 268 | "max_heat_setpoint_limit", 269 | ETOP_MAX_TEMPERATURE_VAL * 100, 270 | ) 271 | 272 | def map_attribute(self, attribute, value): 273 | """Map standardized attribute value to dict of manufacturer values.""" 274 | 275 | if attribute in self.DIRECT_MAPPING_ATTRS: 276 | return { 277 | self.DIRECT_MAPPING_ATTRS[attribute][0]: ( 278 | value 279 | if self.DIRECT_MAPPING_ATTRS[attribute][1] is None 280 | else self.DIRECT_MAPPING_ATTRS[attribute][1](value) 281 | ) 282 | } 283 | 284 | if attribute == "operation_preset": 285 | if value == self.Preset.Manual: 286 | return {ETOP_PRESET_ATTR: 0} 287 | if value == self.Preset.Away: 288 | return {ETOP_PRESET_ATTR: 1} 289 | if value == self.Preset.Schedule: 290 | return {ETOP_PRESET_ATTR: 2} 291 | 292 | if attribute == "system_mode": 293 | if value == self.SystemMode.Off: 294 | mode = 0 295 | else: 296 | mode = 1 297 | return {ETOP_SYSTEM_MODE_ATTR: mode} 298 | 299 | def preset_change(self, value): 300 | """Preset change.""" 301 | if value == 0: 302 | operation_preset = self.Preset.Manual 303 | prog_mode = self.ProgrammingOperationMode.Simple 304 | occupancy = self.Occupancy.Occupied 305 | elif value == 1: 306 | operation_preset = self.Preset.Away 307 | prog_mode = self.ProgrammingOperationMode.Simple 308 | occupancy = self.Occupancy.Unoccupied 309 | else: 310 | operation_preset = self.Preset.Schedule 311 | prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode 312 | occupancy = self.Occupancy.Occupied 313 | 314 | self._update_attribute( 315 | self.attributes_by_name["programing_oper_mode"].id, prog_mode 316 | ) 317 | self._update_attribute(self.attributes_by_name["occupancy"].id, occupancy) 318 | self._update_attribute( 319 | self.attributes_by_name["operation_preset"].id, operation_preset 320 | ) 321 | 322 | def system_mode_change(self, value): 323 | """System Mode change.""" 324 | if value == 0: 325 | mode = self.SystemMode.Off 326 | else: 327 | mode = self.SystemMode.Heat 328 | self._update_attribute(self.attributes_by_name["system_mode"].id, mode) 329 | 330 | 331 | class EtopUserInterface(TuyaUserInterfaceCluster): 332 | """HVAC User interface cluster for tuya electric heating thermostats.""" 333 | 334 | # _CHILD_LOCK_ATTR = MAXSMART_CHILD_LOCK_ATTR 335 | 336 | 337 | class EtopWindowDectection(CustomTuyaOnOff): 338 | """Open Window Detection function support""" 339 | 340 | def window_detect_func_change(self, value): 341 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 342 | 343 | def map_attribute(self, attribute, value): 344 | if attribute == "on_off": 345 | return {ETOP_WINDOW_DETECT_FUNC_ATTR: value} 346 | 347 | 348 | class Etop(EnchantedDevice, TuyaThermostat): 349 | """Etop Thermostatic radiator valve.""" 350 | 351 | def __init__(self, *args, **kwargs): 352 | """Init device.""" 353 | self.thermostat_onoff_bus = Bus() 354 | self.EtopManufCluster_bus = Bus() 355 | super().__init__(*args, **kwargs) 356 | 357 | signature = { 358 | # endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184] 359 | # output_clusters=[10, 25]> 360 | MODELS_INFO: [ 361 | ("_TZE200_0hg58wyk", "TS0601"), 362 | ], 363 | ENDPOINTS: { 364 | 1: { 365 | PROFILE_ID: zha.PROFILE_ID, 366 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 367 | INPUT_CLUSTERS: [ 368 | Basic.cluster_id, 369 | Groups.cluster_id, 370 | Scenes.cluster_id, 371 | TuyaManufClusterAttributes.cluster_id, 372 | ], 373 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 374 | } 375 | }, 376 | } 377 | 378 | replacement = { 379 | ENDPOINTS: { 380 | 1: { 381 | PROFILE_ID: zha.PROFILE_ID, 382 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 383 | INPUT_CLUSTERS: [ 384 | Basic.cluster_id, 385 | Groups.cluster_id, 386 | Scenes.cluster_id, 387 | EtopManufCluster, 388 | EtopThermostat, 389 | EtopUserInterface, 390 | EtopWindowDectection, 391 | TuyaPowerConfigurationCluster, 392 | ], 393 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 394 | } 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /ts0601_trv_me167.py: -------------------------------------------------------------------------------- 1 | """ME167 TRV devices support.""" 2 | 3 | import logging 4 | from typing import Optional, Union 5 | 6 | import zigpy.types as t 7 | from zhaquirks import Bus, LocalDataCluster 8 | from zhaquirks.const import ( 9 | DEVICE_TYPE, 10 | ENDPOINTS, 11 | INPUT_CLUSTERS, 12 | MODELS_INFO, 13 | OUTPUT_CLUSTERS, 14 | PROFILE_ID, 15 | ) 16 | from zhaquirks.tuya import ( 17 | TuyaManufCluster, 18 | TuyaManufClusterAttributes, 19 | TuyaPowerConfigurationCluster, 20 | TuyaThermostat, 21 | TuyaThermostatCluster, 22 | TuyaTimePayload, 23 | TuyaUserInterfaceCluster, 24 | ) 25 | from zigpy.profiles import zha 26 | from zigpy.zcl import foundation 27 | from zigpy.zcl.clusters.general import ( 28 | AnalogOutput, 29 | Basic, 30 | BinaryInput, 31 | Groups, 32 | Identify, 33 | OnOff, 34 | Ota, 35 | Scenes, 36 | Time, 37 | ) 38 | from zigpy.zcl.clusters.hvac import Thermostat 39 | 40 | _LOGGER = logging.getLogger(__name__) 41 | 42 | ME167_TEMPERATURE_ATTR = 0x0205 # [0, 0, 0, 210] current room temp (decidegree) 43 | ME167_TARGET_TEMP_ATTR = 0x0204 # [0, 0, 0, 190] target room temp (decidegree) 44 | ME167_TEMP_CALIBRATION_ATTR = 0x022F # (decidegree) 45 | ME167_CHILD_LOCK_ATTR = 0x0107 # [0] unlocked [1] child-locked 46 | ME167_BATTERY_STATE_ATTR = 0x0523 # [0] OK [1] Empty 47 | ME167_MODE_ATTR = 0x0402 # [0] auto [1] heat [2] off 48 | ME167_STATE_ATTR = 0x0403 # [1] idle [0] heating /!\ inverted 49 | # minimum limit of temperature setting 50 | ME167_MIN_TEMPERATURE_VAL = 5 # degrees 51 | # maximum limit of temperature setting 52 | ME167_MAX_TEMPERATURE_VAL = 35 # degrees 53 | ME167_FROST_PROTECTION = 0x0124 54 | ME167ManufClusterSelf = {} 55 | 56 | 57 | class CustomTuyaOnOff(LocalDataCluster, OnOff): 58 | """Custom Tuya OnOff cluster.""" 59 | 60 | def __init__(self, *args, **kwargs): 61 | """Init.""" 62 | super().__init__(*args, **kwargs) 63 | self.endpoint.device.thermostat_onoff_bus.add_listener(self) 64 | 65 | # pylint: disable=R0201 66 | def map_attribute(self, attribute, value): 67 | """Map standardized attribute value to dict of manufacturer values.""" 68 | return {} 69 | 70 | async def write_attributes(self, attributes, manufacturer=None): 71 | """Implement writeable attributes.""" 72 | 73 | records = self._write_attr_records(attributes) 74 | 75 | if not records: 76 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 77 | 78 | manufacturer_attrs = {} 79 | for record in records: 80 | attr_name = self.attributes[record.attrid].name 81 | new_attrs = self.map_attribute(attr_name, record.value.value) 82 | 83 | _LOGGER.debug( 84 | "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " 85 | "with value %s to custom %s", 86 | self.endpoint.device.nwk, 87 | self.endpoint.endpoint_id, 88 | self.cluster_id, 89 | attr_name, 90 | record.attrid, 91 | repr(record.value.value), 92 | repr(new_attrs), 93 | ) 94 | 95 | manufacturer_attrs.update(new_attrs) 96 | 97 | if not manufacturer_attrs: 98 | return [ 99 | [ 100 | foundation.WriteAttributesStatusRecord( 101 | foundation.Status.FAILURE, r.attrid 102 | ) 103 | for r in records 104 | ] 105 | ] 106 | 107 | await ME167ManufClusterSelf[ 108 | self.endpoint.device.ieee 109 | ].endpoint.tuya_manufacturer.write_attributes( 110 | manufacturer_attrs, manufacturer=manufacturer 111 | ) 112 | 113 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 114 | 115 | async def command( 116 | self, 117 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 118 | *args, 119 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 120 | expect_reply: bool = True, 121 | tsn: Optional[Union[int, t.uint8_t]] = None, 122 | ): 123 | """Override the default Cluster command.""" 124 | 125 | if command_id in (0x0000, 0x0001, 0x0002): 126 | if command_id == 0x0000: 127 | value = False 128 | elif command_id == 0x0001: 129 | value = True 130 | else: 131 | attrid = self.attributes_by_name["on_off"].id 132 | success, _ = await self.read_attributes( 133 | (attrid,), manufacturer=manufacturer 134 | ) 135 | try: 136 | value = success[attrid] 137 | except KeyError: 138 | return foundation.Status.FAILURE 139 | value = not value 140 | 141 | (res,) = await self.write_attributes( 142 | {"on_off": value}, 143 | manufacturer=manufacturer, 144 | ) 145 | return [command_id, res[0].status] 146 | 147 | return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] 148 | 149 | 150 | class ME167ManufCluster(TuyaManufClusterAttributes): 151 | """Manufacturer Specific Cluster of some thermostatic valves.""" 152 | 153 | def __init__(self, *args, **kwargs): 154 | """Init.""" 155 | super().__init__(*args, **kwargs) 156 | self.endpoint.device.ME167ManufCluster_bus.add_listener(self) 157 | global ME167ManufClusterSelf 158 | ME167ManufClusterSelf[self.endpoint.device.ieee] = self 159 | 160 | server_commands = { 161 | 0x0000: foundation.ZCLCommandDef( 162 | "set_data", 163 | {"param": TuyaManufCluster.Command}, 164 | False, 165 | is_manufacturer_specific=False, 166 | ), 167 | 0x0010: foundation.ZCLCommandDef( 168 | "mcu_version_req", 169 | {"param": t.uint16_t}, 170 | False, 171 | is_manufacturer_specific=True, 172 | ), 173 | 0x0024: foundation.ZCLCommandDef( 174 | "set_time", 175 | {"param": TuyaTimePayload}, 176 | False, 177 | is_manufacturer_specific=True, 178 | ), 179 | } 180 | 181 | attributes = TuyaManufClusterAttributes.attributes.copy() 182 | attributes.update( 183 | { 184 | ME167_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True), 185 | ME167_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True), 186 | ME167_TEMP_CALIBRATION_ATTR: ("temperature_calibration", t.int32s, True), 187 | ME167_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True), 188 | ME167_MODE_ATTR: ("mode", t.uint8_t, True), 189 | ME167_STATE_ATTR: ("state", t.uint8_t, True), 190 | ME167_BATTERY_STATE_ATTR: ("battery_state", t.uint8_t, True), 191 | ME167_FROST_PROTECTION: ("frost_protection", t.uint8_t, True), 192 | } 193 | ) 194 | 195 | TEMPERATURE_ATTRS = { 196 | ME167_TEMPERATURE_ATTR: ("local_temperature", lambda value: value * 10), 197 | ME167_TARGET_TEMP_ATTR: ( 198 | "occupied_heating_setpoint", 199 | lambda value: value * 10, 200 | ), 201 | ME167_TEMP_CALIBRATION_ATTR: ( 202 | "local_temperature_calibration", 203 | None, 204 | ), 205 | } 206 | 207 | def _update_attribute(self, attrid, value): 208 | super()._update_attribute(attrid, value) 209 | 210 | if attrid in self.TEMPERATURE_ATTRS: 211 | self.endpoint.device.thermostat_bus.listener_event( 212 | "temperature_change", 213 | self.TEMPERATURE_ATTRS[attrid][0], 214 | ( 215 | value 216 | if self.TEMPERATURE_ATTRS[attrid][1] is None 217 | else self.TEMPERATURE_ATTRS[attrid][1](value) 218 | ), 219 | ) 220 | elif attrid == ME167_MODE_ATTR: 221 | self.endpoint.device.thermostat_bus.listener_event("mode_change", value) 222 | elif attrid == ME167_CHILD_LOCK_ATTR: 223 | self.endpoint.device.ui_bus.listener_event("child_lock_change", value) 224 | self.endpoint.device.thermostat_onoff_bus.listener_event( 225 | "child_lock_change", value 226 | ) 227 | elif attrid == ME167_STATE_ATTR: 228 | self.endpoint.device.thermostat_bus.listener_event( 229 | "hass_climate_state_change", value 230 | ) 231 | elif attrid == ME167_BATTERY_STATE_ATTR: 232 | self.endpoint.device.battery_bus.listener_event( 233 | "battery_change", 0 if value == 1 else 100 234 | ) 235 | elif attrid == ME167_TEMP_CALIBRATION_ATTR: 236 | self.endpoint.device.ME167TempCalibration_bus.listener_event( 237 | "set_value", value 238 | ) 239 | elif attrid == ME167_FROST_PROTECTION: 240 | self.endpoint.device.thermostat_onoff_bus.listener_event( 241 | "frost_protection_change", value 242 | ) 243 | 244 | 245 | class ME167Thermostat(TuyaThermostatCluster): 246 | """Thermostat cluster for some thermostatic valves.""" 247 | 248 | class Preset(t.enum8): 249 | """Working modes of the thermostat.""" 250 | 251 | Away = 0x00 252 | Schedule = 0x01 253 | Manual = 0x02 254 | Comfort = 0x03 255 | Eco = 0x04 256 | Boost = 0x05 257 | Complex = 0x06 258 | 259 | _CONSTANT_ATTRIBUTES = { 260 | 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, 261 | } 262 | 263 | attributes = TuyaThermostatCluster.attributes.copy() 264 | attributes.update( 265 | { 266 | 0x4000: ("operation_preset", Preset, True), 267 | } 268 | ) 269 | 270 | DIRECT_MAPPING_ATTRS = { 271 | "occupied_heating_setpoint": ( 272 | ME167_TARGET_TEMP_ATTR, 273 | lambda value: round(value / 10), 274 | ), 275 | "operation_preset": (ME167_MODE_ATTR, None), 276 | "local_temperature_calibration": ( 277 | ME167_TEMP_CALIBRATION_ATTR, 278 | None, 279 | ), 280 | } 281 | 282 | def __init__(self, *args, **kwargs): 283 | """Init.""" 284 | super().__init__(*args, **kwargs) 285 | self.endpoint.device.thermostat_bus.add_listener(self) 286 | self.endpoint.device.thermostat_bus.listener_event( 287 | "temperature_change", 288 | "min_heat_setpoint_limit", 289 | ME167_MIN_TEMPERATURE_VAL * 100, 290 | ) 291 | self.endpoint.device.thermostat_bus.listener_event( 292 | "temperature_change", 293 | "max_heat_setpoint_limit", 294 | ME167_MAX_TEMPERATURE_VAL * 100, 295 | ) 296 | 297 | def map_attribute(self, attribute, value): 298 | """Map standardized attribute value to dict of manufacturer values.""" 299 | 300 | if attribute in self.DIRECT_MAPPING_ATTRS: 301 | return { 302 | self.DIRECT_MAPPING_ATTRS[attribute][0]: ( 303 | value 304 | if self.DIRECT_MAPPING_ATTRS[attribute][1] is None 305 | else self.DIRECT_MAPPING_ATTRS[attribute][1](value) 306 | ) 307 | } 308 | 309 | if attribute in ("system_mode", "programing_oper_mode"): 310 | if attribute == "system_mode": 311 | system_mode = value 312 | oper_mode = self._attr_cache.get( 313 | self.attributes_by_name["programing_oper_mode"].id, 314 | self.ProgrammingOperationMode.Simple, 315 | ) 316 | else: 317 | system_mode = self._attr_cache.get( 318 | self.attributes_by_name["system_mode"].id, self.SystemMode.Heat 319 | ) 320 | oper_mode = value 321 | if system_mode == self.SystemMode.Off: 322 | return {ME167_MODE_ATTR: 2} 323 | if system_mode == self.SystemMode.Heat: 324 | if oper_mode == self.ProgrammingOperationMode.Schedule_programming_mode: 325 | return {ME167_MODE_ATTR: 0} 326 | if oper_mode == self.ProgrammingOperationMode.Simple: 327 | return {ME167_MODE_ATTR: 1} 328 | self.error("Unsupported value for ProgrammingOperationMode") 329 | else: 330 | self.error("Unsupported value for SystemMode") 331 | 332 | def hass_climate_state_change(self, value): 333 | """Update of the HASS Climate gui state.""" 334 | self.endpoint.device.thermostat_bus.listener_event("state_change", not value) 335 | 336 | def mode_change(self, value): 337 | """System Mode change.""" 338 | if value == 0: 339 | operation_preset = self.Preset.Schedule 340 | prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode 341 | occupancy = self.Occupancy.Occupied 342 | system_mode = self.SystemMode.Heat 343 | elif value == 1: 344 | operation_preset = self.Preset.Manual 345 | prog_mode = self.ProgrammingOperationMode.Simple 346 | occupancy = self.Occupancy.Occupied 347 | system_mode = self.SystemMode.Heat 348 | elif value == 2: 349 | operation_preset = self.Preset.Manual 350 | prog_mode = self.ProgrammingOperationMode.Simple 351 | occupancy = self.Occupancy.Occupied 352 | system_mode = self.SystemMode.Off 353 | 354 | self._update_attribute(self.attributes_by_name["system_mode"].id, system_mode) 355 | self._update_attribute( 356 | self.attributes_by_name["programing_oper_mode"].id, prog_mode 357 | ) 358 | self._update_attribute(self.attributes_by_name["occupancy"].id, occupancy) 359 | self._update_attribute( 360 | self.attributes_by_name["operation_preset"].id, operation_preset 361 | ) 362 | 363 | 364 | class ME167UserInterface(TuyaUserInterfaceCluster): 365 | """HVAC User interface cluster for tuya electric heating thermostats.""" 366 | 367 | _CHILD_LOCK_ATTR = ME167_CHILD_LOCK_ATTR 368 | 369 | 370 | class ME167ChildLock(CustomTuyaOnOff): 371 | """On/Off cluster for the child lock function.""" 372 | 373 | def child_lock_change(self, value): 374 | """Child lock change.""" 375 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 376 | 377 | def map_attribute(self, attribute, value): 378 | """Map standardized attribute value to dict of manufacturer values.""" 379 | if attribute == "on_off": 380 | return {ME167_CHILD_LOCK_ATTR: value} 381 | 382 | 383 | class ME167TempCalibration(LocalDataCluster, AnalogOutput): 384 | """Analog output for Temp Calibration.""" 385 | 386 | def __init__(self, *args, **kwargs): 387 | """Init.""" 388 | super().__init__(*args, **kwargs) 389 | self.endpoint.device.ME167TempCalibration_bus.add_listener(self) 390 | self._update_attribute( 391 | self.attributes_by_name["description"].id, "Temperature Calibration" 392 | ) 393 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 10) 394 | self._update_attribute(self.attributes_by_name["min_present_value"].id, -10) 395 | self._update_attribute(self.attributes_by_name["resolution"].id, 1) 396 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 397 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 398 | 399 | def set_value(self, value): 400 | """Set value.""" 401 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 402 | 403 | def get_value(self): 404 | """Get value.""" 405 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 406 | 407 | async def write_attributes(self, attributes, manufacturer=None): 408 | """Override the default Cluster write_attributes.""" 409 | for attrid, value in attributes.items(): 410 | if isinstance(attrid, str): 411 | attrid = self.attributes_by_name[attrid].id 412 | if attrid not in self.attributes: 413 | self.error("%d is not a valid attribute id", attrid) 414 | continue 415 | self._update_attribute(attrid, value) 416 | 417 | await ME167ManufClusterSelf[ 418 | self.endpoint.device.ieee 419 | ].endpoint.tuya_manufacturer.write_attributes( 420 | {ME167_TEMP_CALIBRATION_ATTR: value}, 421 | manufacturer=None, 422 | ) 423 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 424 | 425 | 426 | class ME167FrostProtection(CustomTuyaOnOff): 427 | """On/Off cluster for the frost protection function.""" 428 | 429 | def frost_protection_change(self, value): 430 | """Frost protection change.""" 431 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 432 | 433 | def map_attribute(self, attribute, value): 434 | """Map standardized attribute value to dict of manufacturer values.""" 435 | if attribute == "on_off": 436 | return {ME167_FROST_PROTECTION: value} 437 | 438 | 439 | class ME167(TuyaThermostat): 440 | """ME167 Thermostatic radiator valve and clones.""" 441 | 442 | def __init__(self, *args, **kwargs): 443 | """Init device.""" 444 | self.thermostat_onoff_bus = Bus() 445 | self.ME167ManufCluster_bus = Bus() 446 | self.ME167TempCalibration_bus = Bus() 447 | super().__init__(*args, **kwargs) 448 | 449 | signature = { 450 | # "endpoints": { 451 | # "1": { 452 | # "profile_id": 260, 453 | # "device_type": "0x0051", 454 | # "in_clusters": [ 455 | # "0x0000", 456 | # "0x0004", 457 | # "0x0005", 458 | # "0xef00" 459 | # ], 460 | # "out_clusters": [ 461 | # "0x000a", 462 | # "0x0019" 463 | # ] 464 | # } 465 | # }, 466 | MODELS_INFO: [ 467 | ("_TZE200_bvu2wnxz", "TS0601"), 468 | ("_TZE200_6rdj8dzm", "TS0601"), 469 | ("_TZE200_p3dbf6qs", "TS0601"), # model: 'ME168', vendor: 'Avatto' 470 | ("_TZE200_rxntag7i", "TS0601"), # model: 'ME168', vendor: 'Avatto' 471 | ("_TZE200_rxq4iti9", "TS0601"), 472 | ("_TZE200_9xfjixap", "TS0601"), 473 | ], 474 | ENDPOINTS: { 475 | 1: { 476 | PROFILE_ID: zha.PROFILE_ID, 477 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 478 | INPUT_CLUSTERS: [ 479 | Basic.cluster_id, 480 | Groups.cluster_id, 481 | Scenes.cluster_id, 482 | TuyaManufClusterAttributes.cluster_id, 483 | ], 484 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 485 | } 486 | }, 487 | } 488 | 489 | replacement = { 490 | ENDPOINTS: { 491 | 1: { 492 | PROFILE_ID: zha.PROFILE_ID, 493 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 494 | INPUT_CLUSTERS: [ 495 | Basic.cluster_id, 496 | Groups.cluster_id, 497 | Scenes.cluster_id, 498 | ME167ManufCluster, 499 | ME167Thermostat, 500 | ME167UserInterface, 501 | TuyaPowerConfigurationCluster, 502 | ], 503 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 504 | }, 505 | 2: { 506 | PROFILE_ID: zha.PROFILE_ID, 507 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 508 | INPUT_CLUSTERS: [ 509 | ME167ChildLock, 510 | ], 511 | OUTPUT_CLUSTERS: [], 512 | }, 513 | 3: { 514 | PROFILE_ID: zha.PROFILE_ID, 515 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 516 | INPUT_CLUSTERS: [ME167TempCalibration], 517 | OUTPUT_CLUSTERS: [], 518 | }, 519 | 4: { 520 | PROFILE_ID: zha.PROFILE_ID, 521 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 522 | INPUT_CLUSTERS: [ 523 | ME167FrostProtection, 524 | ], 525 | OUTPUT_CLUSTERS: [], 526 | }, 527 | } 528 | } 529 | -------------------------------------------------------------------------------- /ts0601_trv_rtitek2.py: -------------------------------------------------------------------------------- 1 | """RtiTek2 TRV devices support.""" 2 | 3 | import logging 4 | from typing import Optional, Union 5 | 6 | import zigpy.types as t 7 | from zhaquirks import Bus, LocalDataCluster 8 | from zhaquirks.const import ( 9 | DEVICE_TYPE, 10 | ENDPOINTS, 11 | INPUT_CLUSTERS, 12 | MODELS_INFO, 13 | OUTPUT_CLUSTERS, 14 | PROFILE_ID, 15 | ) 16 | from zhaquirks.tuya import ( 17 | TuyaManufCluster, 18 | TuyaManufClusterAttributes, 19 | TuyaPowerConfigurationCluster, 20 | TuyaThermostat, 21 | TuyaThermostatCluster, 22 | TuyaTimePayload, 23 | TuyaUserInterfaceCluster, 24 | ) 25 | from zigpy.profiles import zha 26 | from zigpy.zcl import foundation 27 | from zigpy.zcl.clusters.general import ( 28 | AnalogOutput, 29 | Basic, 30 | BinaryInput, 31 | Groups, 32 | OnOff, 33 | Ota, 34 | Scenes, 35 | Time, 36 | ) 37 | from zigpy.zcl.clusters.hvac import Thermostat 38 | 39 | _LOGGER = logging.getLogger(__name__) 40 | 41 | RTI2_TARGET_TEMP_ATTR = 0x0202 # target room temp (decidegree) 42 | RTI2_TEMPERATURE_ATTR = 0x0203 # current room temp (decidegree) 43 | RTI2_MODE_ATTR = 0x0401 # [0] schedule [1] manual [2] off [3] on 44 | RTI2_CHILD_LOCK_ATTR = 0x010C # [0] unlocked [1] locked 45 | RTI2_TEMP_CALIBRATION_ATTR = 0x0265 # temperature calibration (degree) 46 | RTI2_MIN_TEMPERATURE_ATTR = 0x020F # minimum limit of temperature setting (decidegree) 47 | RTI2_MAX_TEMPERATURE_ATTR = 0x0210 # maximum limit of temperature setting (decidegree) 48 | RTI2_WINDOW_DETECT_ATTR = 0x0108 # [0] alarm not active [1] alarm active 49 | RTI2_VALVE_POSITION_ATTR = 0x026C # opening percentage /10 50 | RTI2_VALVE_STATE_ATTR = 0x0406 # [0] closed [1] opened 51 | RTI2_BATTERY_ATTR = 0x020D # battery percentage remaining 0-100% 52 | Rti2ManufClusterSelf = {} 53 | 54 | 55 | class CustomTuyaOnOff(LocalDataCluster, OnOff): 56 | """Custom Tuya OnOff cluster.""" 57 | 58 | def __init__(self, *args, **kwargs): 59 | """Init.""" 60 | super().__init__(*args, **kwargs) 61 | self.endpoint.device.thermostat_onoff_bus.add_listener(self) 62 | 63 | # pylint: disable=R0201 64 | def map_attribute(self, attribute, value): 65 | """Map standardized attribute value to dict of manufacturer values.""" 66 | return {} 67 | 68 | async def write_attributes(self, attributes, manufacturer=None): 69 | """Implement writeable attributes.""" 70 | 71 | records = self._write_attr_records(attributes) 72 | 73 | if not records: 74 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 75 | 76 | manufacturer_attrs = {} 77 | for record in records: 78 | attr_name = self.attributes[record.attrid].name 79 | new_attrs = self.map_attribute(attr_name, record.value.value) 80 | 81 | _LOGGER.debug( 82 | "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " 83 | "with value %s to custom %s", 84 | self.endpoint.device.nwk, 85 | self.endpoint.endpoint_id, 86 | self.cluster_id, 87 | attr_name, 88 | record.attrid, 89 | repr(record.value.value), 90 | repr(new_attrs), 91 | ) 92 | 93 | manufacturer_attrs.update(new_attrs) 94 | 95 | if not manufacturer_attrs: 96 | return [ 97 | [ 98 | foundation.WriteAttributesStatusRecord( 99 | foundation.Status.FAILURE, r.attrid 100 | ) 101 | for r in records 102 | ] 103 | ] 104 | 105 | await Rti2ManufClusterSelf[ 106 | self.endpoint.device.ieee 107 | ].endpoint.tuya_manufacturer.write_attributes( 108 | manufacturer_attrs, manufacturer=manufacturer 109 | ) 110 | 111 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 112 | 113 | async def command( 114 | self, 115 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 116 | *args, 117 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 118 | expect_reply: bool = True, 119 | tsn: Optional[Union[int, t.uint8_t]] = None, 120 | ): 121 | """Override the default Cluster command.""" 122 | 123 | if command_id in (0x0000, 0x0001, 0x0002): 124 | 125 | if command_id == 0x0000: 126 | value = False 127 | elif command_id == 0x0001: 128 | value = True 129 | else: 130 | attrid = self.attributes_by_name["on_off"].id 131 | success, _ = await self.read_attributes( 132 | (attrid,), manufacturer=manufacturer 133 | ) 134 | try: 135 | value = success[attrid] 136 | except KeyError: 137 | return foundation.Status.FAILURE 138 | value = not value 139 | 140 | (res,) = await self.write_attributes( 141 | {"on_off": value}, 142 | manufacturer=manufacturer, 143 | ) 144 | return [command_id, res[0].status] 145 | 146 | return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] 147 | 148 | 149 | class Rti2ManufCluster(TuyaManufClusterAttributes): 150 | """Manufacturer Specific Cluster of thermostatic valves.""" 151 | 152 | def __init__(self, *args, **kwargs): 153 | """Init.""" 154 | super().__init__(*args, **kwargs) 155 | global Rti2ManufClusterSelf 156 | Rti2ManufClusterSelf[self.endpoint.device.ieee] = self 157 | 158 | set_time_offset = 1970 159 | 160 | server_commands = { 161 | 0x0000: foundation.ZCLCommandDef( 162 | "set_data", 163 | {"param": TuyaManufCluster.Command}, 164 | False, 165 | is_manufacturer_specific=False, 166 | ), 167 | 0x0010: foundation.ZCLCommandDef( 168 | "mcu_version_req", 169 | {"param": t.uint16_t}, 170 | False, 171 | is_manufacturer_specific=True, 172 | ), 173 | 0x0024: foundation.ZCLCommandDef( 174 | "set_time", 175 | {"param": TuyaTimePayload}, 176 | False, 177 | is_manufacturer_specific=False, 178 | ), 179 | } 180 | 181 | attributes = TuyaManufClusterAttributes.attributes.copy() 182 | attributes.update( 183 | { 184 | RTI2_TEMPERATURE_ATTR: ("temperature", t.uint32_t), 185 | RTI2_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t), 186 | RTI2_MODE_ATTR: ("mode", t.uint8_t), 187 | RTI2_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t), 188 | RTI2_TEMP_CALIBRATION_ATTR: ("temperature_calibration", t.int32s), 189 | RTI2_MIN_TEMPERATURE_ATTR: ("min_temperature", t.uint32_t), 190 | RTI2_MAX_TEMPERATURE_ATTR: ("max_temperature", t.uint32_t), 191 | RTI2_WINDOW_DETECT_ATTR: ("window_detection", t.uint8_t), 192 | RTI2_VALVE_POSITION_ATTR: ("valve_position", t.uint32_t), 193 | RTI2_VALVE_STATE_ATTR: ("valve_state", t.uint8_t), 194 | RTI2_BATTERY_ATTR: ("battery", t.uint32_t), 195 | } 196 | ) 197 | 198 | DIRECT_MAPPED_ATTRS = { 199 | RTI2_TEMPERATURE_ATTR: ("local_temperature", lambda value: value * 10), 200 | RTI2_TARGET_TEMP_ATTR: ("occupied_heating_setpoint", lambda value: value * 10), 201 | RTI2_TEMP_CALIBRATION_ATTR: ( 202 | "local_temperature_calibration", 203 | lambda value: value * 10, 204 | ), 205 | RTI2_MIN_TEMPERATURE_ATTR: ( 206 | "min_heat_setpoint_limit", 207 | lambda value: value * 10, 208 | ), 209 | RTI2_MAX_TEMPERATURE_ATTR: ( 210 | "max_heat_setpoint_limit", 211 | lambda value: value * 10, 212 | ), 213 | RTI2_VALVE_POSITION_ATTR: ( 214 | "valve_position", 215 | lambda value: value * 10, 216 | ), 217 | } 218 | 219 | def _update_attribute(self, attrid, value): 220 | """Override default _update_attribute.""" 221 | super()._update_attribute(attrid, value) 222 | if attrid in self.DIRECT_MAPPED_ATTRS and value < 60000: 223 | self.endpoint.device.thermostat_bus.listener_event( 224 | "temperature_change", 225 | self.DIRECT_MAPPED_ATTRS[attrid][0], 226 | ( 227 | value 228 | if self.DIRECT_MAPPED_ATTRS[attrid][1] is None 229 | else self.DIRECT_MAPPED_ATTRS[attrid][1](value) 230 | ), 231 | ) 232 | 233 | if attrid == RTI2_WINDOW_DETECT_ATTR: 234 | self.endpoint.device.Rti2WindowDetection_bus.listener_event( 235 | "set_value", value 236 | ) 237 | elif attrid == RTI2_CHILD_LOCK_ATTR: 238 | self.endpoint.device.ui_bus.listener_event("child_lock_change", value) 239 | self.endpoint.device.thermostat_onoff_bus.listener_event( 240 | "child_lock_change", value 241 | ) 242 | elif attrid == RTI2_MODE_ATTR: 243 | self.endpoint.device.thermostat_bus.listener_event("mode_change", value) 244 | elif attrid == RTI2_VALVE_POSITION_ATTR: 245 | self.endpoint.device.Rti2ValvePosition_bus.listener_event( 246 | "set_value", value / 10 247 | ) 248 | elif attrid == RTI2_VALVE_STATE_ATTR: 249 | self.endpoint.device.thermostat_bus.listener_event("state_change", value) 250 | elif attrid == RTI2_TEMP_CALIBRATION_ATTR: 251 | self.endpoint.device.Rti2TempCalibration_bus.listener_event( 252 | "set_value", value / 10 253 | ) 254 | elif attrid == RTI2_BATTERY_ATTR: 255 | self.endpoint.device.battery_bus.listener_event("battery_change", value) 256 | elif attrid == RTI2_MIN_TEMPERATURE_ATTR: 257 | self.endpoint.device.Rti2MinTemp_bus.listener_event("set_value", value / 10) 258 | elif attrid == RTI2_MAX_TEMPERATURE_ATTR: 259 | self.endpoint.device.Rti2MaxTemp_bus.listener_event("set_value", value / 10) 260 | 261 | 262 | # elif attrid in (RTI_TEMPERATURE_ATTR, RTI_TARGET_TEMP_ATTR): 263 | # self.endpoint.device.thermostat_bus.listener_event( 264 | # "hass_climate_state_change", attrid, value 265 | # ) 266 | 267 | 268 | class Rti2Thermostat(TuyaThermostatCluster): 269 | """Thermostat cluster for thermostatic valves.""" 270 | 271 | class Preset(t.enum8): 272 | """Working modes of the thermostat.""" 273 | 274 | Away = 0x00 275 | Schedule = 0x01 276 | Manual = 0x02 277 | Comfort = 0x03 278 | Eco = 0x04 279 | Boost = 0x05 280 | Complex = 0x06 281 | TempManual = 0x07 282 | 283 | class WorkDays(t.enum8): 284 | """Workday configuration for scheduler operation mode.""" 285 | 286 | MonToFri = 0x00 287 | MonToSat = 0x01 288 | MonToSun = 0x02 289 | 290 | class ForceValveState(t.enum8): 291 | """Force valve state option.""" 292 | 293 | Normal = 0x00 294 | Open = 0x01 295 | Close = 0x02 296 | 297 | _CONSTANT_ATTRIBUTES = { 298 | 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, 299 | 0x001C: Thermostat.SystemMode.Heat, 300 | } 301 | 302 | attributes = TuyaThermostatCluster.attributes.copy() 303 | attributes.update( 304 | { 305 | 0x4002: ("operation_preset", Preset), 306 | 0x4003: ("valve_position", t.uint32_t, True), 307 | } 308 | ) 309 | 310 | DIRECT_MAPPING_ATTRS = { 311 | "min_heat_setpoint_limit": ( 312 | RTI2_MIN_TEMPERATURE_ATTR, 313 | lambda value: round(value / 10), 314 | ), 315 | "max_heat_setpoint_limit": ( 316 | RTI2_MAX_TEMPERATURE_ATTR, 317 | lambda value: round(value / 10), 318 | ), 319 | "local_temperature_calibration": ( 320 | RTI2_TEMP_CALIBRATION_ATTR, 321 | lambda value: value / 10, 322 | ), 323 | "occupied_heating_setpoint": ( 324 | RTI2_TARGET_TEMP_ATTR, 325 | lambda value: value / 10, 326 | ), 327 | "valve_position": ( 328 | RTI2_VALVE_POSITION_ATTR, 329 | lambda value: value / 10, 330 | ), 331 | } 332 | 333 | SCHEDULE_ATTRS = { 334 | "schedule_sunday_4_temperature": 20, 335 | "schedule_sunday_4_minute": 30, 336 | "schedule_sunday_4_hour": 18, 337 | "schedule_sunday_3_temperature": 21, 338 | "schedule_sunday_3_minute": 30, 339 | "schedule_sunday_3_hour": 14, 340 | "schedule_sunday_2_temperature": 20, 341 | "schedule_sunday_2_minute": 30, 342 | "schedule_sunday_2_hour": 12, 343 | "schedule_sunday_1_temperature": 19, 344 | "schedule_sunday_1_minute": 0, 345 | "schedule_sunday_1_hour": 6, 346 | "schedule_saturday_4_temperature": 21, 347 | "schedule_saturday_4_minute": 30, 348 | "schedule_saturday_4_hour": 17, 349 | "schedule_saturday_3_temperature": 22, 350 | "schedule_saturday_3_minute": 30, 351 | "schedule_saturday_3_hour": 14, 352 | "schedule_saturday_2_temperature": 23, 353 | "schedule_saturday_2_minute": 00, 354 | "schedule_saturday_2_hour": 12, 355 | "schedule_saturday_1_temperature": 24, 356 | "schedule_saturday_1_minute": 0, 357 | "schedule_saturday_1_hour": 6, 358 | "schedule_workday_4_temperature": 23, 359 | "schedule_workday_4_minute": 30, 360 | "schedule_workday_4_hour": 17, 361 | "schedule_workday_3_temperature": 22, 362 | "schedule_workday_3_minute": 30, 363 | "schedule_workday_3_hour": 13, 364 | "schedule_workday_2_temperature": 21, 365 | "schedule_workday_2_minute": 30, 366 | "schedule_workday_2_hour": 11, 367 | "schedule_workday_1_temperature": 20, 368 | "schedule_workday_1_minute": 0, 369 | "schedule_workday_1_hour": 6, 370 | } 371 | 372 | def map_attribute(self, attribute, value): 373 | """Map standardized attribute value to dict of manufacturer values.""" 374 | 375 | if attribute in self.DIRECT_MAPPING_ATTRS: 376 | return { 377 | self.DIRECT_MAPPING_ATTRS[attribute][0]: ( 378 | value 379 | if self.DIRECT_MAPPING_ATTRS[attribute][1] is None 380 | else self.DIRECT_MAPPING_ATTRS[attribute][1](value) 381 | ) 382 | } 383 | 384 | if attribute == "operation_preset": 385 | if value == 1: 386 | return {RTI2_MODE_ATTR: 0} 387 | if value == 2: 388 | return {RTI2_MODE_ATTR: 1} 389 | 390 | if attribute in ("programing_oper_mode", "occupancy"): 391 | if attribute == "occupancy": 392 | occupancy = value 393 | oper_mode = self._attr_cache.get( 394 | self.attributes_by_name["programing_oper_mode"].id, 395 | self.ProgrammingOperationMode.Simple, 396 | ) 397 | else: 398 | occupancy = self._attr_cache.get( 399 | self.attributes_by_name["occupancy"].id, self.Occupancy.Occupied 400 | ) 401 | oper_mode = value 402 | if occupancy == self.Occupancy.Occupied: 403 | if oper_mode == self.ProgrammingOperationMode.Schedule_programming_mode: 404 | return {RTI2_MODE_ATTR: 0} 405 | if oper_mode == self.ProgrammingOperationMode.Simple: 406 | return {RTI2_MODE_ATTR: 1} 407 | self.error("Unsupported value for ProgrammingOperationMode") 408 | else: 409 | self.error("Unsupported value for Occupancy") 410 | 411 | if attribute == "system_mode": 412 | if value == self.SystemMode.Off: 413 | return {RTI2_MODE_ATTR: 2} 414 | else: 415 | return {RTI2_MODE_ATTR: 0} 416 | 417 | def hass_climate_state_change(self, attrid, value): 418 | """Update of the HASS Climate gui state according to temp difference.""" 419 | if attrid == RTI2_TEMPERATURE_ATTR: 420 | temp_current = value * 10 421 | temp_set = self._attr_cache.get( 422 | self.attributes_by_name["occupied_heating_setpoint"].id 423 | ) 424 | else: 425 | temp_set = value * 10 426 | temp_current = self._attr_cache.get( 427 | self.attributes_by_name["local_temperature"].id 428 | ) 429 | 430 | state = 0 if (int(temp_current) >= int(temp_set)) else 1 431 | self.endpoint.device.thermostat_bus.listener_event("state_change", state) 432 | 433 | def mode_change(self, value): 434 | """System Mode change.""" 435 | if value in (1, 2, 3): 436 | operation_preset = self.Preset.Manual 437 | prog_mode = self.ProgrammingOperationMode.Simple 438 | occupancy = self.Occupancy.Occupied 439 | else: 440 | operation_preset = self.Preset.Schedule 441 | prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode 442 | occupancy = self.Occupancy.Occupied 443 | 444 | self._update_attribute( 445 | self.attributes_by_name["programing_oper_mode"].id, prog_mode 446 | ) 447 | self._update_attribute(self.attributes_by_name["occupancy"].id, occupancy) 448 | self._update_attribute( 449 | self.attributes_by_name["operation_preset"].id, operation_preset 450 | ) 451 | 452 | if value == 2: 453 | self._update_attribute( 454 | self.attributes_by_name["system_mode"].id, self.SystemMode.Off 455 | ) 456 | else: 457 | self._update_attribute( 458 | self.attributes_by_name["system_mode"].id, self.SystemMode.Heat 459 | ) 460 | 461 | 462 | class Rti2UserInterface(TuyaUserInterfaceCluster): 463 | """HVAC User interface cluster for tuya electric heating thermostats.""" 464 | 465 | _CHILD_LOCK_ATTR = RTI2_CHILD_LOCK_ATTR 466 | 467 | 468 | class Rti2WindowDetection(LocalDataCluster, BinaryInput): 469 | """Binary cluster for the window detection function.""" 470 | 471 | def __init__(self, *args, **kwargs): 472 | """Init.""" 473 | super().__init__(*args, **kwargs) 474 | self.endpoint.device.Rti2WindowDetection_bus.add_listener(self) 475 | 476 | def set_value(self, value): 477 | """Set value.""" 478 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 479 | 480 | 481 | class Rti2ChildLock(CustomTuyaOnOff): 482 | """On/Off cluster for the child lock function of the electric heating thermostats.""" 483 | 484 | def child_lock_change(self, value): 485 | """Child lock change.""" 486 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 487 | 488 | def map_attribute(self, attribute, value): 489 | """Map standardized attribute value to dict of manufacturer values.""" 490 | if attribute == "on_off": 491 | return {RTI2_CHILD_LOCK_ATTR: value} 492 | 493 | 494 | class Rti2ValvePosition(LocalDataCluster, AnalogOutput): 495 | """Analog output for Valve State.""" 496 | 497 | def __init__(self, *args, **kwargs): 498 | """Init.""" 499 | super().__init__(*args, **kwargs) 500 | self.endpoint.device.Rti2ValvePosition_bus.add_listener(self) 501 | self._update_attribute( 502 | self.attributes_by_name["description"].id, "Valve Position" 503 | ) 504 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 100) 505 | self._update_attribute(self.attributes_by_name["min_present_value"].id, 0) 506 | self._update_attribute(self.attributes_by_name["resolution"].id, 1) 507 | self._update_attribute(self.attributes_by_name["application_type"].id, 4 << 16) 508 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 98) 509 | 510 | def set_value(self, value): 511 | """Set value.""" 512 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 513 | 514 | async def write_attributes(self, attributes, manufacturer=None): 515 | """Override the default Cluster write_attributes.""" 516 | for attrid, value in attributes.items(): 517 | if isinstance(attrid, str): 518 | attrid = self.attributes_by_name[attrid].id 519 | if attrid not in self.attributes: 520 | self.error("%d is not a valid attribute id", attrid) 521 | continue 522 | self._update_attribute(attrid, value) 523 | await Rti2ManufClusterSelf[ 524 | self.endpoint.device.ieee 525 | ].endpoint.tuya_manufacturer.write_attributes( 526 | {RTI2_VALVE_POSITION_ATTR: value * 10}, manufacturer=None 527 | ) 528 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 529 | 530 | 531 | class Rti2TempCalibration(LocalDataCluster, AnalogOutput): 532 | """Analog output for Temp Calibration.""" 533 | 534 | def __init__(self, *args, **kwargs): 535 | """Init.""" 536 | super().__init__(*args, **kwargs) 537 | self.endpoint.device.Rti2TempCalibration_bus.add_listener(self) 538 | self._update_attribute( 539 | self.attributes_by_name["description"].id, "Temperature Calibration" 540 | ) 541 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 10) 542 | self._update_attribute(self.attributes_by_name["min_present_value"].id, -10) 543 | self._update_attribute(self.attributes_by_name["resolution"].id, 0.1) 544 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 545 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 546 | 547 | def set_value(self, value): 548 | """Set value.""" 549 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 550 | 551 | def get_value(self): 552 | """Get value.""" 553 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 554 | 555 | async def write_attributes(self, attributes, manufacturer=None): 556 | """Override the default Cluster write_attributes.""" 557 | for attrid, value in attributes.items(): 558 | if isinstance(attrid, str): 559 | attrid = self.attributes_by_name[attrid].id 560 | if attrid not in self.attributes: 561 | self.error("%d is not a valid attribute id", attrid) 562 | continue 563 | self._update_attribute(attrid, value) 564 | 565 | await Rti2ManufClusterSelf[ 566 | self.endpoint.device.ieee 567 | ].endpoint.tuya_manufacturer.write_attributes( 568 | {RTI2_TEMP_CALIBRATION_ATTR: value * 10}, 569 | manufacturer=None, 570 | ) 571 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 572 | 573 | 574 | class Rti2MinTemp(LocalDataCluster, AnalogOutput): 575 | """Analog output for Min Temperature.""" 576 | 577 | def __init__(self, *args, **kwargs): 578 | """Init.""" 579 | super().__init__(*args, **kwargs) 580 | self.endpoint.device.Rti2MinTemp_bus.add_listener(self) 581 | self._update_attribute( 582 | self.attributes_by_name["description"].id, "Min Temperature" 583 | ) 584 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 15) 585 | self._update_attribute(self.attributes_by_name["min_present_value"].id, 5) 586 | self._update_attribute(self.attributes_by_name["resolution"].id, 1) 587 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 588 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 589 | 590 | def set_value(self, value): 591 | """Set value.""" 592 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 593 | 594 | def get_value(self): 595 | """Get value.""" 596 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 597 | 598 | async def write_attributes(self, attributes, manufacturer=None): 599 | """Override the default Cluster write_attributes.""" 600 | for attrid, value in attributes.items(): 601 | if isinstance(attrid, str): 602 | attrid = self.attributes_by_name[attrid].id 603 | if attrid not in self.attributes: 604 | self.error("%d is not a valid attribute id", attrid) 605 | continue 606 | self._update_attribute(attrid, value) 607 | 608 | await Rti2ManufClusterSelf[ 609 | self.endpoint.device.ieee 610 | ].endpoint.tuya_manufacturer.write_attributes( 611 | {RTI2_MIN_TEMPERATURE_ATTR: value * 10}, 612 | manufacturer=None, 613 | ) 614 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 615 | 616 | 617 | class Rti2MaxTemp(LocalDataCluster, AnalogOutput): 618 | """Analog output for Max Temperature.""" 619 | 620 | def __init__(self, *args, **kwargs): 621 | """Init.""" 622 | super().__init__(*args, **kwargs) 623 | self.endpoint.device.Rti2MaxTemp_bus.add_listener(self) 624 | self._update_attribute( 625 | self.attributes_by_name["description"].id, "Max Temperature" 626 | ) 627 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 35) 628 | self._update_attribute(self.attributes_by_name["min_present_value"].id, 20) 629 | self._update_attribute(self.attributes_by_name["resolution"].id, 1) 630 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 631 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 632 | 633 | def set_value(self, value): 634 | """Set value.""" 635 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 636 | 637 | def get_value(self): 638 | """Get value.""" 639 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 640 | 641 | async def write_attributes(self, attributes, manufacturer=None): 642 | """Override the default Cluster write_attributes.""" 643 | for attrid, value in attributes.items(): 644 | if isinstance(attrid, str): 645 | attrid = self.attributes_by_name[attrid].id 646 | if attrid not in self.attributes: 647 | self.error("%d is not a valid attribute id", attrid) 648 | continue 649 | self._update_attribute(attrid, value) 650 | 651 | await Rti2ManufClusterSelf[ 652 | self.endpoint.device.ieee 653 | ].endpoint.tuya_manufacturer.write_attributes( 654 | {RTI2_MAX_TEMPERATURE_ATTR: value * 10}, 655 | manufacturer=None, 656 | ) 657 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 658 | 659 | 660 | class Rti2(TuyaThermostat): 661 | """Rti Thermostatic radiator valve.""" 662 | 663 | def __init__(self, *args, **kwargs): 664 | """Init device.""" 665 | self.thermostat_onoff_bus = Bus() 666 | self.Rti2WindowDetection_bus = Bus() 667 | self.Rti2ValvePosition_bus = Bus() 668 | self.Rti2TempCalibration_bus = Bus() 669 | self.Rti2MinTemp_bus = Bus() 670 | self.Rti2MaxTemp_bus = Bus() 671 | super().__init__(*args, **kwargs) 672 | 673 | signature = { 674 | # endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184] 675 | # output_clusters=[10, 25]> 676 | MODELS_INFO: [ 677 | ("_TZE200_bvrlmajk", "TS0601"), 678 | #MOES TRV 679 | ("_TZE204_9mjy74mp", "TS0601"), 680 | ("_TZE200_9mjy74mp", "TS0601"), 681 | ("_TZE200_rtrmfadk", "TS0601"), 682 | ], 683 | ENDPOINTS: { 684 | 1: { 685 | PROFILE_ID: zha.PROFILE_ID, 686 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 687 | INPUT_CLUSTERS: [ 688 | Basic.cluster_id, 689 | Groups.cluster_id, 690 | Scenes.cluster_id, 691 | TuyaManufClusterAttributes.cluster_id, 692 | ], 693 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 694 | } 695 | }, 696 | } 697 | 698 | replacement = { 699 | ENDPOINTS: { 700 | 1: { 701 | PROFILE_ID: zha.PROFILE_ID, 702 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 703 | INPUT_CLUSTERS: [ 704 | Basic.cluster_id, 705 | Groups.cluster_id, 706 | Scenes.cluster_id, 707 | Rti2ManufCluster, 708 | Rti2Thermostat, 709 | Rti2UserInterface, 710 | Rti2WindowDetection, 711 | TuyaPowerConfigurationCluster, 712 | ], 713 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 714 | }, 715 | 2: { 716 | PROFILE_ID: zha.PROFILE_ID, 717 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 718 | INPUT_CLUSTERS: [Rti2ChildLock], 719 | OUTPUT_CLUSTERS: [], 720 | }, 721 | 3: { 722 | PROFILE_ID: zha.PROFILE_ID, 723 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 724 | INPUT_CLUSTERS: [Rti2ValvePosition], 725 | OUTPUT_CLUSTERS: [], 726 | }, 727 | 4: { 728 | PROFILE_ID: zha.PROFILE_ID, 729 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 730 | INPUT_CLUSTERS: [Rti2TempCalibration], 731 | OUTPUT_CLUSTERS: [], 732 | }, 733 | 5: { 734 | PROFILE_ID: zha.PROFILE_ID, 735 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 736 | INPUT_CLUSTERS: [Rti2MinTemp], 737 | OUTPUT_CLUSTERS: [], 738 | }, 739 | 6: { 740 | PROFILE_ID: zha.PROFILE_ID, 741 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 742 | INPUT_CLUSTERS: [Rti2MaxTemp], 743 | OUTPUT_CLUSTERS: [], 744 | }, 745 | } 746 | } 747 | -------------------------------------------------------------------------------- /ts0601_trv_zonnsmart.py: -------------------------------------------------------------------------------- 1 | """Map from manufacturer to standard clusters for thermostatic valves.""" 2 | 3 | import logging 4 | from typing import Optional, Union 5 | 6 | import zigpy.types as t 7 | from zhaquirks import Bus, LocalDataCluster 8 | from zhaquirks.const import ( 9 | DEVICE_TYPE, 10 | ENDPOINTS, 11 | INPUT_CLUSTERS, 12 | MODELS_INFO, 13 | OUTPUT_CLUSTERS, 14 | PROFILE_ID, 15 | ) 16 | from zhaquirks.tuya import ( 17 | TuyaManufClusterAttributes, 18 | TuyaPowerConfigurationCluster, 19 | TuyaThermostat, 20 | TuyaThermostatCluster, 21 | TuyaUserInterfaceCluster, 22 | ) 23 | from zigpy.profiles import zha 24 | from zigpy.zcl import foundation 25 | from zigpy.zcl.clusters.general import ( 26 | AnalogOutput, 27 | Basic, 28 | Groups, 29 | OnOff, 30 | Ota, 31 | Scenes, 32 | Time, 33 | ) 34 | from zigpy.zcl.clusters.hvac import Thermostat 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | 38 | ZONNSMART_CHILD_LOCK_ATTR = 0x0128 # [0] unlocked [1] child-locked 39 | ZONNSMART_WINDOW_DETECT_ATTR = 0x0108 # [0] inactive [1] active 40 | ZONNSMART_TARGET_TEMP_ATTR = 0x0210 # [0,0,0,210] target room temp (decidegree) 41 | ZONNSMART_TEMPERATURE_ATTR = 0x0218 # [0,0,0,200] current room temp (decidegree) 42 | ZONNSMART_BATTERY_ATTR = 0x0223 # [0,0,0,98] battery charge 43 | ZONNSMART_MODE_ATTR = ( 44 | 0x0402 # [0] Scheduled/auto [1] manual [2] Holiday [3] HolidayReady 45 | ) 46 | ZONNSMART_HEATING_STOPPING = 0x016B # [0] inactive [1] active 47 | ZONNSMART_BOOST_TIME_ATTR = 0x0265 # BOOST mode operating time in (sec) 48 | ZONNSMART_UPTIME_TIME_ATTR = ( 49 | 0x0024 # Seems to be the uptime attribute (sent hourly, increases) [0,200] 50 | ) 51 | ZONNSMART_TEMP_CALIBRATION_ATTR = 0x021B # temperature calibration (decidegree) 52 | ZONNSMART_COMFORT_TEMP_ATTR = 0x0268 # [0, 0, 0, 210] comfort temp in auto (decidegree) 53 | ZONNSMART_ECO_TEMP_ATTR = 0x0269 # [0, 0, 0, 170] eco temp in auto (decidegree) 54 | ZONNSMARTManufClusterSelf = {} 55 | 56 | 57 | class CustomTuyaOnOff(LocalDataCluster, OnOff): 58 | """Custom Tuya OnOff cluster.""" 59 | 60 | def __init__(self, *args, **kwargs): 61 | """Init.""" 62 | super().__init__(*args, **kwargs) 63 | self.endpoint.device.thermostat_onoff_bus.add_listener(self) 64 | 65 | # pylint: disable=R0201 66 | def map_attribute(self, attribute, value): 67 | """Map standardized attribute value to dict of manufacturer values.""" 68 | return {} 69 | 70 | async def write_attributes(self, attributes, manufacturer=None): 71 | """Implement writeable attributes.""" 72 | 73 | records = self._write_attr_records(attributes) 74 | 75 | if not records: 76 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 77 | 78 | manufacturer_attrs = {} 79 | for record in records: 80 | attr_name = self.attributes[record.attrid].name 81 | new_attrs = self.map_attribute(attr_name, record.value.value) 82 | 83 | _LOGGER.debug( 84 | "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) " 85 | "with value %s to custom %s", 86 | self.endpoint.device.nwk, 87 | self.endpoint.endpoint_id, 88 | self.cluster_id, 89 | attr_name, 90 | record.attrid, 91 | repr(record.value.value), 92 | repr(new_attrs), 93 | ) 94 | 95 | manufacturer_attrs.update(new_attrs) 96 | 97 | if not manufacturer_attrs: 98 | return [ 99 | [ 100 | foundation.WriteAttributesStatusRecord( 101 | foundation.Status.FAILURE, r.attrid 102 | ) 103 | for r in records 104 | ] 105 | ] 106 | 107 | await ZONNSMARTManufClusterSelf[ 108 | self.endpoint.device.ieee 109 | ].endpoint.tuya_manufacturer.write_attributes( 110 | manufacturer_attrs, manufacturer=manufacturer 111 | ) 112 | 113 | return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] 114 | 115 | async def command( 116 | self, 117 | command_id: Union[foundation.GeneralCommand, int, t.uint8_t], 118 | *args, 119 | manufacturer: Optional[Union[int, t.uint16_t]] = None, 120 | expect_reply: bool = True, 121 | tsn: Optional[Union[int, t.uint8_t]] = None, 122 | ): 123 | """Override the default Cluster command.""" 124 | 125 | if command_id in (0x0000, 0x0001, 0x0002): 126 | 127 | if command_id == 0x0000: 128 | value = False 129 | elif command_id == 0x0001: 130 | value = True 131 | else: 132 | attrid = self.attributes_by_name["on_off"].id 133 | success, _ = await self.read_attributes( 134 | (attrid,), manufacturer=manufacturer 135 | ) 136 | try: 137 | value = success[attrid] 138 | except KeyError: 139 | return foundation.Status.FAILURE 140 | value = not value 141 | 142 | (res,) = await self.write_attributes( 143 | {"on_off": value}, 144 | manufacturer=manufacturer, 145 | ) 146 | return [command_id, res[0].status] 147 | 148 | return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND] 149 | 150 | 151 | class ZONNSMARTManufCluster(TuyaManufClusterAttributes): 152 | """Manufacturer Specific Cluster of some thermostatic valves.""" 153 | 154 | def __init__(self, *args, **kwargs): 155 | """Init.""" 156 | super().__init__(*args, **kwargs) 157 | self.endpoint.device.ZONNSMARTManufCluster_bus.add_listener(self) 158 | global ZONNSMARTManufClusterSelf 159 | ZONNSMARTManufClusterSelf[self.endpoint.device.ieee] = self 160 | 161 | attributes = TuyaManufClusterAttributes.attributes.copy() 162 | attributes.update( 163 | { 164 | ZONNSMART_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True), 165 | ZONNSMART_WINDOW_DETECT_ATTR: ("window_detection", t.uint8_t, True), 166 | ZONNSMART_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True), 167 | ZONNSMART_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True), 168 | ZONNSMART_BATTERY_ATTR: ("battery", t.uint32_t, True), 169 | ZONNSMART_MODE_ATTR: ("mode", t.uint8_t, True), 170 | ZONNSMART_BOOST_TIME_ATTR: ("boost_duration_seconds", t.uint32_t, True), 171 | ZONNSMART_UPTIME_TIME_ATTR: ("uptime", t.uint32_t, True), 172 | ZONNSMART_HEATING_STOPPING: ("heating_stop", t.uint8_t, True), 173 | ZONNSMART_TEMP_CALIBRATION_ATTR: ( 174 | "temperature_calibration", 175 | t.int32s, 176 | True, 177 | ), 178 | ZONNSMART_COMFORT_TEMP_ATTR: ("comfort_mode_temperature", t.uint32_t, True), 179 | ZONNSMART_ECO_TEMP_ATTR: ("eco_mode_temperature", t.uint32_t, True), 180 | } 181 | ) 182 | 183 | DIRECT_MAPPED_ATTRS = { 184 | ZONNSMART_TEMPERATURE_ATTR: ("local_temperature", lambda value: value * 10), 185 | ZONNSMART_TARGET_TEMP_ATTR: ( 186 | "occupied_heating_setpoint", 187 | lambda value: value * 10, 188 | ), 189 | ZONNSMART_TEMP_CALIBRATION_ATTR: ( 190 | "local_temperature_calibration", 191 | lambda value: value * 10, 192 | ), 193 | } 194 | 195 | def _update_attribute(self, attrid, value): 196 | super()._update_attribute(attrid, value) 197 | if attrid in self.DIRECT_MAPPED_ATTRS: 198 | self.endpoint.device.thermostat_bus.listener_event( 199 | "temperature_change", 200 | self.DIRECT_MAPPED_ATTRS[attrid][0], 201 | value 202 | if self.DIRECT_MAPPED_ATTRS[attrid][1] is None 203 | else self.DIRECT_MAPPED_ATTRS[attrid][1](value), 204 | ) 205 | elif attrid == ZONNSMART_MODE_ATTR: 206 | self.endpoint.device.thermostat_bus.listener_event("mode_change", value) 207 | elif attrid == ZONNSMART_HEATING_STOPPING: 208 | self.endpoint.device.thermostat_bus.listener_event( 209 | "state_change", value == 0 210 | ) 211 | self.endpoint.device.thermostat_bus.listener_event("mode_change2", value) 212 | elif attrid == ZONNSMART_CHILD_LOCK_ATTR: 213 | mode = 1 if value else 0 214 | self.endpoint.device.ui_bus.listener_event("child_lock_change", mode) 215 | self.endpoint.device.thermostat_onoff_bus.listener_event( 216 | "child_lock_change", value 217 | ) 218 | elif attrid == ZONNSMART_BATTERY_ATTR: 219 | self.endpoint.device.battery_bus.listener_event("battery_change", value) 220 | elif attrid == ZONNSMART_TEMP_CALIBRATION_ATTR: 221 | self.endpoint.device.ZonnsmartTempCalibration_bus.listener_event( 222 | "set_value", value / 10 223 | ) 224 | elif attrid == ZONNSMART_COMFORT_TEMP_ATTR: 225 | self.endpoint.device.ZonnsmartComfortTemp_bus.listener_event( 226 | "set_value", value / 10 227 | ) 228 | elif attrid == ZONNSMART_ECO_TEMP_ATTR: 229 | self.endpoint.device.ZonnsmartEcoTemp_bus.listener_event( 230 | "set_value", value / 10 231 | ) 232 | elif attrid == ZONNSMART_BOOST_TIME_ATTR: 233 | self.endpoint.device.thermostat_onoff_bus.listener_event( 234 | "boost_change", 1 if value > 0 else 0 235 | ) 236 | 237 | 238 | class ZONNSMARTThermostat(TuyaThermostatCluster): 239 | """Thermostat cluster for some thermostatic valves.""" 240 | 241 | _CONSTANT_ATTRIBUTES = { 242 | 0x001B: Thermostat.ControlSequenceOfOperation.Heating_Only, 243 | } 244 | 245 | DIRECT_MAPPING_ATTRS = { 246 | "occupied_heating_setpoint": ( 247 | ZONNSMART_TARGET_TEMP_ATTR, 248 | lambda value: round(value / 10), 249 | ), 250 | "operation_preset": (ZONNSMART_MODE_ATTR, None), 251 | "local_temperature_calibration": ( 252 | ZONNSMART_TEMP_CALIBRATION_ATTR, 253 | lambda value: round(value / 10), 254 | ), 255 | } 256 | 257 | def map_attribute(self, attribute, value): 258 | """Map standardized attribute value to dict of manufacturer values.""" 259 | 260 | if attribute in self.DIRECT_MAPPING_ATTRS: 261 | return { 262 | self.DIRECT_MAPPING_ATTRS[attribute][0]: value 263 | if self.DIRECT_MAPPING_ATTRS[attribute][1] is None 264 | else self.DIRECT_MAPPING_ATTRS[attribute][1](value) 265 | } 266 | if attribute in ("system_mode", "programing_oper_mode"): 267 | if attribute == "system_mode": 268 | system_mode = value 269 | oper_mode = self._attr_cache.get( 270 | self.attributes_by_name["programing_oper_mode"].id, 271 | self.ProgrammingOperationMode.Simple, 272 | ) 273 | else: 274 | system_mode = self._attr_cache.get( 275 | self.attributes_by_name["system_mode"].id, self.SystemMode.Heat 276 | ) 277 | oper_mode = value 278 | if system_mode == self.SystemMode.Off: 279 | return {ZONNSMART_HEATING_STOPPING: 1} 280 | if system_mode == self.SystemMode.Heat: 281 | if oper_mode == self.ProgrammingOperationMode.Schedule_programming_mode: 282 | return {ZONNSMART_MODE_ATTR: 0} 283 | if oper_mode == self.ProgrammingOperationMode.Simple: 284 | return {ZONNSMART_MODE_ATTR: 1} 285 | self.error("Unsupported value for ProgrammingOperationMode") 286 | else: 287 | self.error("Unsupported value for SystemMode") 288 | 289 | def mode_change(self, value): 290 | """System Mode change.""" 291 | if value == 0: 292 | prog_mode = self.ProgrammingOperationMode.Schedule_programming_mode 293 | elif value == 1: 294 | prog_mode = self.ProgrammingOperationMode.Simple 295 | else: 296 | prog_mode = self.ProgrammingOperationMode.Simple 297 | 298 | self._update_attribute( 299 | self.attributes_by_name["system_mode"].id, self.SystemMode.Heat 300 | ) 301 | self._update_attribute( 302 | self.attributes_by_name["programing_oper_mode"].id, prog_mode 303 | ) 304 | 305 | def mode_change2(self, value): 306 | if value == 1: 307 | self._update_attribute( 308 | self.attributes_by_name["system_mode"].id, self.SystemMode.Off 309 | ) 310 | else: 311 | self._update_attribute( 312 | self.attributes_by_name["system_mode"].id, self.SystemMode.Heat 313 | ) 314 | 315 | 316 | class ZONNSMARTUserInterface(TuyaUserInterfaceCluster): 317 | """HVAC User interface cluster for tuya electric heating thermostats.""" 318 | 319 | _CHILD_LOCK_ATTR = ZONNSMART_CHILD_LOCK_ATTR 320 | 321 | 322 | class ZONNSMARTChildLock(CustomTuyaOnOff): 323 | """On/Off cluster for the child lock function.""" 324 | 325 | def child_lock_change(self, value): 326 | """Child lock change.""" 327 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 328 | 329 | def map_attribute(self, attribute, value): 330 | """Map standardized attribute value to dict of manufacturer values.""" 331 | if attribute == "on_off": 332 | return {ZONNSMART_CHILD_LOCK_ATTR: value} 333 | 334 | 335 | class ZonnsmartBoost(CustomTuyaOnOff): 336 | """On/Off cluster for the boost function.""" 337 | 338 | def boost_change(self, value): 339 | """Boost change.""" 340 | self._update_attribute(self.attributes_by_name["on_off"].id, value) 341 | 342 | def map_attribute(self, attribute, value): 343 | """Map standardized attribute value to dict of manufacturer values.""" 344 | if attribute == "on_off": 345 | return {ZONNSMART_BOOST_TIME_ATTR: 299 if value else 0} 346 | 347 | 348 | class ZonnsmartTempCalibration(LocalDataCluster, AnalogOutput): 349 | """Analog output for Temperature calibration.""" 350 | 351 | def __init__(self, *args, **kwargs): 352 | """Init.""" 353 | super().__init__(*args, **kwargs) 354 | self.endpoint.device.ZonnsmartTempCalibration_bus.add_listener(self) 355 | self._update_attribute( 356 | self.attributes_by_name["description"].id, "Temperature calibration" 357 | ) 358 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 5.5) 359 | self._update_attribute(self.attributes_by_name["min_present_value"].id, -5.5) 360 | self._update_attribute(self.attributes_by_name["resolution"].id, 0.1) 361 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 362 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 363 | 364 | def set_value(self, value): 365 | """Set value.""" 366 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 367 | 368 | def get_value(self): 369 | """Get value.""" 370 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 371 | 372 | async def write_attributes(self, attributes, manufacturer=None): 373 | """Override the default Cluster write_attributes.""" 374 | for attrid, value in attributes.items(): 375 | if isinstance(attrid, str): 376 | attrid = self.attributes_by_name[attrid].id 377 | if attrid not in self.attributes: 378 | self.error("%d is not a valid attribute id", attrid) 379 | continue 380 | self._update_attribute(attrid, value) 381 | await ZONNSMARTManufClusterSelf[ 382 | self.endpoint.device.ieee 383 | ].endpoint.tuya_manufacturer.write_attributes( 384 | {ZONNSMART_TEMP_CALIBRATION_ATTR: value * 10}, manufacturer=None 385 | ) 386 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 387 | 388 | 389 | class ZonnsmartComfortTemp(LocalDataCluster, AnalogOutput): 390 | """Analog output for Comfort Temperature.""" 391 | 392 | def __init__(self, *args, **kwargs): 393 | """Init.""" 394 | super().__init__(*args, **kwargs) 395 | self.endpoint.device.ZonnsmartComfortTemp_bus.add_listener(self) 396 | self._update_attribute( 397 | self.attributes_by_name["description"].id, "Comfort Temperature" 398 | ) 399 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 30) 400 | self._update_attribute(self.attributes_by_name["min_present_value"].id, 5) 401 | self._update_attribute(self.attributes_by_name["resolution"].id, 0.5) 402 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 403 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 404 | 405 | def set_value(self, value): 406 | """Set value.""" 407 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 408 | 409 | def get_value(self): 410 | """Get value.""" 411 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 412 | 413 | async def write_attributes(self, attributes, manufacturer=None): 414 | """Override the default Cluster write_attributes.""" 415 | for attrid, value in attributes.items(): 416 | if isinstance(attrid, str): 417 | attrid = self.attributes_by_name[attrid].id 418 | if attrid not in self.attributes: 419 | self.error("%d is not a valid attribute id", attrid) 420 | continue 421 | self._update_attribute(attrid, value) 422 | await ZONNSMARTManufClusterSelf[ 423 | self.endpoint.device.ieee 424 | ].endpoint.tuya_manufacturer.write_attributes( 425 | {ZONNSMART_COMFORT_TEMP_ATTR: value * 10}, manufacturer=None 426 | ) 427 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 428 | 429 | 430 | class ZonnsmartEcoTemp(LocalDataCluster, AnalogOutput): 431 | """Analog output for Eco Temperature.""" 432 | 433 | def __init__(self, *args, **kwargs): 434 | """Init.""" 435 | super().__init__(*args, **kwargs) 436 | self.endpoint.device.ZonnsmartEcoTemp_bus.add_listener(self) 437 | self._update_attribute( 438 | self.attributes_by_name["description"].id, "Eco Temperature" 439 | ) 440 | self._update_attribute(self.attributes_by_name["max_present_value"].id, 30) 441 | self._update_attribute(self.attributes_by_name["min_present_value"].id, 5) 442 | self._update_attribute(self.attributes_by_name["resolution"].id, 0.5) 443 | self._update_attribute(self.attributes_by_name["application_type"].id, 13 << 16) 444 | self._update_attribute(self.attributes_by_name["engineering_units"].id, 62) 445 | 446 | def set_value(self, value): 447 | """Set value.""" 448 | self._update_attribute(self.attributes_by_name["present_value"].id, value) 449 | 450 | def get_value(self): 451 | """Get value.""" 452 | return self._attr_cache.get(self.attributes_by_name["present_value"].id) 453 | 454 | async def write_attributes(self, attributes, manufacturer=None): 455 | """Override the default Cluster write_attributes.""" 456 | for attrid, value in attributes.items(): 457 | if isinstance(attrid, str): 458 | attrid = self.attributes_by_name[attrid].id 459 | if attrid not in self.attributes: 460 | self.error("%d is not a valid attribute id", attrid) 461 | continue 462 | self._update_attribute(attrid, value) 463 | await ZONNSMARTManufClusterSelf[ 464 | self.endpoint.device.ieee 465 | ].endpoint.tuya_manufacturer.write_attributes( 466 | {ZONNSMART_ECO_TEMP_ATTR: value * 10}, manufacturer=None 467 | ) 468 | return ([foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)],) 469 | 470 | 471 | class ZonnsmartTV01_ZG(TuyaThermostat): 472 | """ZONNSMART TV01-ZG Thermostatic radiator valve.""" 473 | 474 | def __init__(self, *args, **kwargs): 475 | """Init device.""" 476 | self.ZONNSMARTManufCluster_bus = Bus() 477 | self.thermostat_onoff_bus = Bus() 478 | self.ZonnsmartTempCalibration_bus = Bus() 479 | self.ZonnsmartComfortTemp_bus = Bus() 480 | self.ZonnsmartEcoTemp_bus = Bus() 481 | super().__init__(*args, **kwargs) 482 | 483 | signature = { 484 | # endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184] 485 | # output_clusters=[10, 25]> 486 | MODELS_INFO: [ 487 | ("_TZE200_7yoranx2", "TS0601"), # MOES TV01 ZTRV-ZX-TV01-MS 488 | ("_TZE200_e9ba97vf", "TS0601"), # Zonnsmart TV01-ZG 489 | ("_TZE200_hue3yfsn", "TS0601"), # Zonnsmart TV02-ZG 490 | ("_TZE200_husqqvux", "TS0601"), # Tesla Smart TSL-TRV-TV01ZG 491 | ("_TZE200_kly8gjlz", "TS0601"), # EARU TV05-ZG 492 | ("_TZE200_lnbfnyxd", "TS0601"), # Tesla Smart TSL-TRV-TV01ZG 493 | ("_TZE200_mudxchsu", "TS0601"), # Foluu TV05 494 | ("_TZE200_kds0pmmv", "TS0601"), # MOES TV02 495 | ("_TZE200_sur6q7ko", "TS0601"), # LSC Smart Connect 3012732 496 | ("_TZE200_lllliz3p", "TS0601"), # tuya TV02-Zigbee 497 | ], 498 | ENDPOINTS: { 499 | 1: { 500 | PROFILE_ID: zha.PROFILE_ID, 501 | DEVICE_TYPE: zha.DeviceType.SMART_PLUG, 502 | INPUT_CLUSTERS: [ 503 | Basic.cluster_id, 504 | Groups.cluster_id, 505 | Scenes.cluster_id, 506 | TuyaManufClusterAttributes.cluster_id, 507 | ], 508 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 509 | } 510 | }, 511 | } 512 | 513 | replacement = { 514 | ENDPOINTS: { 515 | 1: { 516 | PROFILE_ID: zha.PROFILE_ID, 517 | DEVICE_TYPE: zha.DeviceType.THERMOSTAT, 518 | INPUT_CLUSTERS: [ 519 | Basic.cluster_id, 520 | Groups.cluster_id, 521 | Scenes.cluster_id, 522 | ZONNSMARTManufCluster, 523 | ZONNSMARTThermostat, 524 | ZONNSMARTUserInterface, 525 | TuyaPowerConfigurationCluster, 526 | ], 527 | OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], 528 | }, 529 | 2: { 530 | PROFILE_ID: zha.PROFILE_ID, 531 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 532 | INPUT_CLUSTERS: [ZONNSMARTChildLock], 533 | OUTPUT_CLUSTERS: [], 534 | }, 535 | 3: { 536 | PROFILE_ID: zha.PROFILE_ID, 537 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 538 | INPUT_CLUSTERS: [ZonnsmartTempCalibration], 539 | OUTPUT_CLUSTERS: [], 540 | }, 541 | 4: { 542 | PROFILE_ID: zha.PROFILE_ID, 543 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 544 | INPUT_CLUSTERS: [ZonnsmartComfortTemp], 545 | OUTPUT_CLUSTERS: [], 546 | }, 547 | 5: { 548 | PROFILE_ID: zha.PROFILE_ID, 549 | DEVICE_TYPE: zha.DeviceType.CONSUMPTION_AWARENESS_DEVICE, 550 | INPUT_CLUSTERS: [ZonnsmartEcoTemp], 551 | OUTPUT_CLUSTERS: [], 552 | }, 553 | 6: { 554 | PROFILE_ID: zha.PROFILE_ID, 555 | DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, 556 | INPUT_CLUSTERS: [ZonnsmartBoost], 557 | OUTPUT_CLUSTERS: [], 558 | }, 559 | } 560 | } 561 | --------------------------------------------------------------------------------