# Heatzy python plugin for Domoticz # # Author: fjumelle # #pylint: disable=line-too-long,broad-exception-caught,possibly-used-before-assignment """

Heatzy Pilote


Implementation of Heatzy Pilote as a Domoticz Plugin.
""" import math import time from datetime import datetime from enum import IntEnum import requests import DomoticzEx as Domoticz #pylint: disable=import-error #pyright:ignore if None is not None: #Fake statement to remove warning on global Domoticz variables #NOSONAR Parameters = Parameters # type: ignore #NOSONAR #pylint: disable=undefined-variable,self-assigning-variable Images = Images # type: ignore #NOSONAR #pylint: disable=undefined-variable,self-assigning-variable Devices = Devices # type: ignore #NOSONAR #pylint: disable=undefined-variable,self-assigning-variable HEATZY_MODE = { '停止': 'OFF', '解冻': 'FROSTFREE', '经济': 'ECONOMY', '舒适': 'NORMAL', 'stop': 'OFF', 'fro': 'FROSTFREE', 'eco': 'ECONOMY', 'cft': 'NORMAL', } HEATZY_MODE_NAME = { 'OFF': 'Off', 'FROSTFREE': 'Hors Gel', 'ECONOMY': 'Eco', 'NORMAL': 'Confort' } HEATZY_MODE_VALUE = { 'OFF': 0, 'FROSTFREE': 10, 'ECONOMY': 20, 'NORMAL': 30 } HEATZY_MODE_VALUE_INV = {v: k for k, v in HEATZY_MODE_VALUE.items()} DEFAULT_POOLING = 60 class HeatzyUnit(IntEnum): """2 units: Control and Selector""" CONTROL = 1 SELECTOR = 2 class BasePlugin: """Class for plugin""" _HTTP_TIMEOUT = 5 _MAX_RETRY_PER_DEVICE = 3 debug = False token = "" token_expire_at = 0 did = {} nextupdate = datetime.now() bug = False pooling = 30 pooling_steps = 1 pooling_current_step = 1 retry = 0 def __init__(self): return def on_start(self): """At statup""" # setup the appropriate logging level debuglevel = int(Parameters["Mode6"]) if debuglevel != 0: self.debug = True Domoticz.Debugging(debuglevel) dump_config_to_log() else: self.debug = False Domoticz.Debugging(0) # Polling interval = X sec try: pooling = int(Parameters["Mode5"]) except Exception: pooling = DEFAULT_POOLING self.pooling_steps = math.ceil(pooling/30) self.pooling = pooling // self.pooling_steps Domoticz.Heartbeat(self.pooling) # Get Token self.token, self.token_expire_at = self.get_token(Parameters["Username"], Parameters["Password"]) # Get Devide Id self.did = self.get_heatzy_devices() # max retry per device self.retry = self._MAX_RETRY_PER_DEVICE * len(self.did) # Create the child devices if these do not exist yet for deviceid in self.did: if deviceid not in Devices: alias = self.did[deviceid]["alias"] #Control Domoticz.Unit(Name=f"Heatzy {alias} - Control", DeviceID=deviceid, Unit=HeatzyUnit.CONTROL, TypeName="Switch", Image=9, Used=1).Create() #Selector switch options = {"LevelActions": "||", "LevelNames": HEATZY_MODE_NAME['OFF'] + "|" + HEATZY_MODE_NAME['FROSTFREE'] + "|" + HEATZY_MODE_NAME['ECONOMY'] + "|" + HEATZY_MODE_NAME['NORMAL'], "LevelOffHidden": "false", #Bug with off mode... #"LevelOffHidden": "true",t "SelectorStyle": "0"} Domoticz.Unit(Name=f"Heatzy {alias} - Mode", DeviceID=deviceid, Unit=HeatzyUnit.SELECTOR, TypeName="Selector Switch", Switchtype=18, Image=15, Options=options, Used=1).Create() # Bug Off = Normal? if str(Parameters["Mode4"]) != "0": Domoticz.Log("Heatzy plugin configured to support the bug Off=Confort. Then when switching to Off, Heatzy will switch to Frost Free.") self.bug = True # Get mode self.get_mode() def on_command(self, DeviceID, Unit, Command, Level, Color): #pylint: disable=unused-argument,invalid-name """Send a command""" if Unit == HeatzyUnit.CONTROL: self.on_off(DeviceID, Command) elif Unit == HeatzyUnit.SELECTOR: self.set_mode(DeviceID, Level) def on_heartbeat(self): """Time to heartbeat :)""" if self.pooling_current_step >= self.pooling_steps: Domoticz.Debug(f"Retry counter: {self.retry}") if self.retry < 0: Domoticz.Status("No connection to Heatzy API ==> Device disabled for 15 minutes") self.pooling_current_step = - 15 * 60 // self.pooling + self.pooling_steps self.reset_retry() #Force refresh token/did Domoticz.Status("Force refresh token and device id.") self.token = "" self.did = {} return self.get_mode() self.pooling_current_step = 1 else: self.pooling_current_step = self.pooling_current_step + 1 def get_token(self, user, password): """Get token using the Heatzy API""" need_to_get_token = False if self.token == "" or self.token_expire_at == "": need_to_get_token = True Domoticz.Status("Heatzy Token unknown, need to call Heatzy API.") elif (float(self.token_expire_at)-time.time()) < 24*60*60: #Token will expire in less than 1 day need_to_get_token = True Domoticz.Status("Heatzy Token expired, need to call Heatzy API.") if need_to_get_token and self.retry>=0: headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Gizwits-Application-Id': 'c70a66ff039d41b4a220e198b0fcc8b3', } data = '{ "username": "'+user+'", "password": "'+password+'", "lang": "en" }' try: time.sleep(0.5) url = 'https://euapi.gizwits.com/app/login' response = requests.post(url, headers=headers, data=data, timeout=self._HTTP_TIMEOUT).json() except Exception as exc: Domoticz.Error("Cannot open connection to Heatzy API to get the token: " + str(exc)) #Domoticz.Error("URL: " + str(url)) #Domoticz.Error("Headers: " + str(headers)) Domoticz.Error("Data: " + str(data)) #Decrease retry self.retry = self.retry - 1 return "", "" Domoticz.Debug("Get Token Response: " + str(response)) if 'token' in response: self.token = response['token'] self.token_expire_at = response['expire_at'] Domoticz.Status("Token from Heatzy API: " + self.token) #Reset retry counter self.reset_retry() else: error_code = "Unknown" if 'error_code' not in response else response['error_code'] error_message = "Unknown" if 'error_message' not in response else response['error_message'] Domoticz.Error(f"Cannot get Heatzy Token: {error_message} ({error_code})\n{response}") self.token = "" self.token_expire_at = 0 self.did = {} #Decrease retry self.retry = self.retry - 1 return self.token, self.token_expire_at def get_heatzy_devices(self): """Get the device id from the token, using the Heatzy API""" if self.token == "" or self.retry<0: return self.did if len(self.did) == 0: Domoticz.Status("Heatzy Devide Id unknown, need to call Heatzy API.") headers = { 'Accept': 'application/json', 'X-Gizwits-User-token': self.token, 'X-Gizwits-Application-Id': 'c70a66ff039d41b4a220e198b0fcc8b3', } params = (('limit', '20'), ('skip', '0'),) url = 'https://euapi.gizwits.com/app/bindings' try: response = requests.get(url, headers=headers, params=params, timeout=self._HTTP_TIMEOUT).json() except Exception as exc: Domoticz.Error("Cannot open connection to Heatzy API to get the device id: " + str(exc)) #Domoticz.Error("URL: " + str(url)) #Domoticz.Error("Headers: " + str(headers)) #Domoticz.Error("Params: " + str(params)) return self.did Domoticz.Debug("Get Device Id Response:" + str(response)) if 'devices' in response: devices = response['devices'] unit = 1 for device in devices: if "dev_alias" in device and "did" in device and "product_key" in device: product_key = device['product_key'] alias = device['dev_alias'] did = device['did'] self.did[product_key] = {"did":did, "alias":alias, "updated_at":0, "command_at":0} Domoticz.Status(f"Devide Id from Heatzy API: {alias} - {did}") unit = unit + 1 return self.did def get_mode(self): "Get the device mode using the Heatzy API" mode = "" response = "" self.token, self.token_expire_at = self.get_token(Parameters["Username"], Parameters["Password"]) #self.did = self.get_heatzy_devices() #Not really needed if self.retry<0: return "" headers = { 'Accept': 'application/json', 'X-Gizwits-User-token': self.token, 'X-Gizwits-Application-Id': 'c70a66ff039d41b4a220e198b0fcc8b3', } Domoticz.Debug(f"Get Mode for {[self.did[d]['alias'] for d in self.did]}...") for deviceid in self.did: device = self.did[deviceid] did = device["did"] alias = device["alias"] url = f"https://euapi.gizwits.com/app/devdata/{did}/latest" try: response = requests.get(url, headers=headers, timeout=self._HTTP_TIMEOUT).json() except Exception as exc: message = f"Cannot open connection to Heatzy API to get the mode for {alias} (retry={self.retry}): {exc}" if self.retry < 0: Domoticz.Error(message) else: Domoticz.Status(message + f" (retry left: {self.retry})") #Decrease retry self.retry = self.retry - 1 continue Domoticz.Debug(f"Get Mode Response for {alias}: {response}") #Last Update if 'updated_at' in response: if response["updated_at"] == device["updated_at"]: #No update since last heartbeat if device["command_at"] - 60 > device["updated_at"] and Devices[deviceid].TimedOut == 0: #No update while a command has been sent obsolete_min = int((time.time() - response["updated_at"])//60) Domoticz.Error(f"Last update from '{alias}' was {obsolete_min} min earlier.") Devices[deviceid].TimedOut = 1 continue device["updated_at"] = response["updated_at"] if Devices[deviceid].TimedOut == 1: Domoticz.Status(f"'{alias}' is now back.") Devices[deviceid].TimedOut = 0 if 'attr' in response and 'mode' in response['attr']: mode = HEATZY_MODE[response['attr']['mode']] Domoticz.Debug(f"Current Heatzy Mode: {HEATZY_MODE_NAME[mode]} ({alias})") #Reset retry counter self.reset_retry() if Devices[deviceid].Units[HeatzyUnit.SELECTOR].nValue != HEATZY_MODE_VALUE[mode]: Domoticz.Status(f"New Heatzy Mode: {HEATZY_MODE_NAME[mode]} ({alias})") Devices[deviceid].Units[HeatzyUnit.SELECTOR].nValue = HEATZY_MODE_VALUE[mode] Devices[deviceid].Units[HeatzyUnit.SELECTOR].sValue = str(HEATZY_MODE_VALUE[mode]) Devices[deviceid].Units[HeatzyUnit.SELECTOR].Update() if not self.bug: if mode == 'OFF' and Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue != 0: Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue = 0 Devices[deviceid].Units[HeatzyUnit.CONTROL].sValue = "Off" elif mode != 'OFF' and Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue == 0: Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue = 1 Devices[deviceid].Units[HeatzyUnit.CONTROL].sValue = "On" else: if mode in ('OFF', 'FROSTFREE') and Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue != 0: Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue = 0 Devices[deviceid].Units[HeatzyUnit.CONTROL].sValue = "Off" elif mode not in ('OFF', 'FROSTFREE') and Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue == 0: Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue = 1 Devices[deviceid].Units[HeatzyUnit.CONTROL].sValue = "On" Devices[deviceid].Units[HeatzyUnit.CONTROL].Update() else: #Decrease retry self.retry = self.retry - 1 error_code = "Unknown" if 'error_code' not in response else response['error_code'] error_message = "Unknown" if 'error_message' not in response else response['error_message'] Domoticz.Error(f"Cannot get Heatzy Mode: {error_message} ({error_code})\n{response}\nToken: {self.token}\nDeviceId: {did}") if error_code == 9004: #Invalid token self.token = "" self.did = {} elif 'attr' in response and len(response["attr"]) == 0: #attr is empty... Domoticz.Status("We force a setMode to try to get the correct mode at the next try...") self.set_mode(deviceid, HEATZY_MODE_VALUE['FROSTFREE']) continue # If mode = OFF and bug, then mode = FROSTFREE if self.bug and mode == 'OFF': Domoticz.Log(f"Switch to FROSTFREE because of the OFF bug...({device})") self.set_mode(deviceid, HEATZY_MODE_VALUE['FROSTFREE']) def set_mode(self, deviceid, mode): "Set the device mode using the Heatzy API" if Devices[deviceid].Units[HeatzyUnit.SELECTOR].nValue != mode: mode_str = { HEATZY_MODE_VALUE['NORMAL']: 0, #'[1,1,0]', HEATZY_MODE_VALUE['ECONOMY']: 1, #'[1,1,1]', HEATZY_MODE_VALUE['FROSTFREE']: 2, #'[1,1,2]', HEATZY_MODE_VALUE['OFF']: 3, #'[1,1,3]', } headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Gizwits-User-token': self.token, 'X-Gizwits-Application-Id': 'c70a66ff039d41b4a220e198b0fcc8b3', } #data = '{"raw": '+mode_str[mode]+'}' data = '{"attrs": {"mode":' + str(mode_str[mode]) + '}}' if deviceid not in self.did: #Should not occur... but it occurs sometimes self.did = self.get_heatzy_devices() did = self.did[deviceid]["did"] self.did[deviceid]["command_at"] = time.time() url = f"https://euapi.gizwits.com/app/control/{did}" try: response = requests.post(url, headers=headers, data=data, timeout=self._HTTP_TIMEOUT).json() except Exception as exc: Domoticz.Error("Cannot open connection to Heatzy API to set the mode: " + str(exc)) #Domoticz.Error("URL: " + str(url)) #Domoticz.Error("Headers: " + str(headers)) Domoticz.Error("Data: " + str(data)) return Domoticz.Debug("Set Mode Response:" + str(response)) if response is not None: mode_str = HEATZY_MODE_VALUE_INV[mode] Devices[deviceid].Units[HeatzyUnit.SELECTOR].nValue = int(mode) Devices[deviceid].Units[HeatzyUnit.SELECTOR].sValue = str(mode) Devices[deviceid].Units[HeatzyUnit.SELECTOR].Update() alias = self.did[deviceid]["alias"] Domoticz.Status(f"New Heatzy Mode: {HEATZY_MODE_NAME[mode_str]} ({alias})") if not self.bug: if mode_str == 'OFF' and Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue != 0: Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue = 0 Devices[deviceid].Units[HeatzyUnit.CONTROL].sValue = "Off" elif mode_str != 'OFF' and Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue == 0: Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue = 1 Devices[deviceid].Units[HeatzyUnit.CONTROL].sValue = "On" else: if mode_str in ('OFF', 'FROSTFREE') and Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue != 0: Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue = 0 Devices[deviceid].Units[HeatzyUnit.CONTROL].sValue = "Off" elif mode_str not in ('OFF', 'FROSTFREE') and Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue == 0: Devices[deviceid].Units[HeatzyUnit.CONTROL].nValue = 1 Devices[deviceid].Units[HeatzyUnit.CONTROL].sValue = "On" Devices[deviceid].Units[HeatzyUnit.CONTROL].Update() else: error_code = "Unknown" if 'error_code' not in response else response['error_code'] error_message = "Unknown" if 'error_message' not in response else response['error_message'] Domoticz.Error(f"Cannot set Heatzy Mode: {error_message} ({error_code})\n{response}\nToken: {self.token}\nDeviceId: {did}") if error_code == 9004: #Invalid token self.token = "" self.did = {} return #Device is correctly running ==> we reset the retry counter self.reset_retry() def on_off(self, deviceid, command): """Toggle device on/off""" if Devices[deviceid].Units[HeatzyUnit.CONTROL].sValue != command: if command == "On": self.set_mode(deviceid, HEATZY_MODE_VALUE['NORMAL']) else: if not self.bug: self.set_mode(deviceid, HEATZY_MODE_VALUE['OFF']) else: #Because of issue with the equipment (Off do not work...) self.set_mode(deviceid, HEATZY_MODE_VALUE['FROSTFREE']) def reset_retry(self): """Reset the retry counter""" Domoticz.Status("Reset retry counter") self.retry = self._MAX_RETRY_PER_DEVICE * len(self.did) _plugin = BasePlugin() def onStart(): #NOSONAR #pylint: disable=invalid-name """OnStart""" _plugin.on_start() def onCommand(DeviceID, Unit, Command, Level, Color): #NOSONAR #pylint: disable=invalid-name """OnCommand""" _plugin.on_command(DeviceID, Unit, Command, Level, Color) def onHeartbeat(): #NOSONAR #pylint: disable=invalid-name """onHeartbeat""" _plugin.on_heartbeat() # Generic helper functions def dump_config_to_log(): """Dump the config to the Domoticz Log""" for x in Parameters: if Parameters[x] != "": Domoticz.Debug( "'" + x + "':'" + str(Parameters[x]) + "'") Domoticz.Debug("Device count: " + str(len(Devices))) for device_name in Devices: device = Devices[device_name] Domoticz.Debug("Device ID: '" + str(device.DeviceID) + "'") Domoticz.Debug("--->Unit Count: '" + str(len(device.Units)) + "'") for unit_no in device.Units: unit = device.Units[unit_no] Domoticz.Debug("--->Unit: " + str(unit_no)) Domoticz.Debug("--->Unit Name: '" + unit.Name + "'") Domoticz.Debug("--->Unit nValue: " + str(unit.nValue)) Domoticz.Debug("--->Unit sValue: '" + unit.sValue + "'") Domoticz.Debug("--->Unit LastLevel: " + str(unit.LastLevel)) return