diff --git a/README.md b/README.md index daced5a..340d169 100755 --- a/README.md +++ b/README.md @@ -42,6 +42,11 @@ If you already have it installed and want the update before it's in the store. ## Release Notes +- 2.0.13: JimBo + - Reorganize hold functions for changing setpoints, climate type, ... + - Fix Illegal node names + - More trapping of bad return data from Ecobee servers + - More debugging info to find issues - 2.0.12: JimBo - Fix for polling not working - Many changes to how hold's are handled, should be more reliable diff --git a/ecobee-poly.py b/ecobee-poly.py index c9478f8..0a75708 100755 --- a/ecobee-poly.py +++ b/ecobee-poly.py @@ -19,6 +19,7 @@ from copy import deepcopy from node_types import Thermostat, Sensor, Weather +from node_funcs import get_valid_node_name LOGGER = polyinterface.LOGGER @@ -42,7 +43,7 @@ def start(self): LOGGER.info('Started Ecobee v2 NodeServer') #LOGGER.debug(self.polyConfig['customData']) self.serverdata = self.get_server_data(LOGGER) - LOGGER.info('Ecobee NodeServer Version {}a'.format(self.serverdata['version'])) + LOGGER.info('Ecobee NodeServer Version {}'.format(self.serverdata['version'])) self.removeNoticesAll() self.check_profile() if 'tokenData' in self.polyConfig['customData']: @@ -255,7 +256,9 @@ def discover(self, *args, **kwargs): if fullData is not False: tstat = fullData['thermostatList'][0] useCelsius = True if tstat['settings']['useCelsius'] else False - self.addNode(Thermostat(self, address, address, thermostatId, 'Ecobee - {}'.format(thermostat['name']), thermostat, fullData, useCelsius)) + self.addNode(Thermostat(self, address, address, thermostatId, + 'Ecobee - {}'.format(get_valid_node_name(thermostat['name'])), + thermostat, fullData, useCelsius)) self.discover_st = True self.in_discover = False return True @@ -342,6 +345,9 @@ def getThermostats(self): auth_conn.close() return False res = auth_conn.getresponse() + if res is None: + LOGGER.error("Bad response {} from thermostatSummary".format(res)) + return False data = json.loads(res.read().decode('utf-8')) auth_conn.close() thermostats = {} diff --git a/node_funcs.py b/node_funcs.py new file mode 100755 index 0000000..8965720 --- /dev/null +++ b/node_funcs.py @@ -0,0 +1,10 @@ + +import re + +# Removes invalid charaters for ISY Node description +def get_valid_node_name(name): + # Only allow utf-8 characters + # https://stackoverflow.com/questions/26541968/delete-every-non-utf-8-symbols-froms-string + name = bytes(name, 'utf-8').decode('utf-8','ignore') + # Remove <>`~!@#$%^&*(){}[]?/\;:"'` characters from name + return re.sub(r"[<>`~!@#$%^&*(){}[\]?/\\;:\"']+", "", name) diff --git a/node_types.py b/node_types.py index 767e379..261d4ce 100755 --- a/node_types.py +++ b/node_types.py @@ -7,6 +7,7 @@ from copy import deepcopy # For debugging only import json +from node_funcs import get_valid_node_name LOGGER = polyinterface.LOGGER @@ -150,6 +151,13 @@ def getMapName(map,val): if int(map[name]) == val: return name +def is_int(s): + try: + int(s) + return True + except ValueError: + return False + """ Address scheme: Devices: n_t e.g. n003_t511892759243 @@ -203,17 +211,19 @@ def start(self): self.controller.addNotice({fnode['address']: "Sensor created with new name, please delete old sensor with address '{}' in the Polyglot UI.".format(fnode['address'])}) if sensorAddress is not None and not sensorAddress in self.controller.nodes: sensorName = 'Ecobee - {}'.format(sensor['name']) - self.controller.addNode(Sensor(self.controller, self.address, sensorAddress, sensorName, self.useCelsius)) + self.controller.addNode(Sensor(self.controller, self.address, sensorAddress, + get_valid_node_name(sensorName), self.useCelsius)) if 'weather' in self.tstat: weatherAddress = 'w{}'.format(self.thermostatId) - weatherName = 'Ecobee - Weather' + weatherName = get_valid_node_name('Ecobee - Weather') self.controller.addNode(Weather(self.controller, self.address, weatherAddress, weatherName, self.useCelsius, False)) forecastAddress = 'f{}'.format(self.thermostatId) - forecastName = 'Ecobee - Forecast' + forecastName = get_valid_node_name('Ecobee - Forecast') self.controller.addNode(Weather(self.controller, self.address, forecastAddress, forecastName, self.useCelsius, True)) self.update(self.revData, self.fullData) def update(self, revData, fullData): + LOGGER.debug("{}:update: ".format(self.address)) #LOGGER.debug("fullData={}".format(json.dumps(fullData, sort_keys=True, indent=2))) #LOGGER.debug("revData={}".format(json.dumps(revData, sort_keys=True, indent=2))) if not 'thermostatList' in fullData: @@ -243,7 +253,7 @@ def _update(self): self.clismd = 0 # Is there an active event? if len(self.events) > 0 and self.events[0]['type'] == 'hold' and self.events[0]['running']: - LOGGER.debug("Checking: events={}".format(json.dumps(self.events, sort_keys=True, indent=2))) + #LOGGER.debug("Checking: events={}".format(json.dumps(self.events, sort_keys=True, indent=2))) # This seems to mean an indefinite hold # "endDate": "2035-01-01", "endTime": "00:00:00", if self.events[0]['endTime'] == '00:00:00': @@ -358,122 +368,84 @@ def getHoldType(self,val=None): def ecobeePost(self,command): return self.controller.ecobeePost(self.thermostatId, command) - def pushHold(self,forceCancel=False): - # - # Push the current hold info to the thermostat - # https://www.ecobee.com/home/developer/api/examples/ex5.shtml - # If there is nothing to hold, then cancel it and resume? - # - push = False - climateChange = False - clismd = int(self.getDriver('CLISMD')) - # This is what the stat is currently set to - coolTempS = self.tempToD(self.runtime['desiredCool'],True) - heatTempS = self.tempToD(self.runtime['desiredHeat'],True) - # This is what the current climate type says it should be - cdict = self.getCurrentClimateDict() - coolTempC = self.tempToD(cdict['coolTemp'],True) - heatTempC = self.tempToD(cdict['heatTemp'],True) - # This is what the schedule says should be enabled. - climateTypeSName = self.program['currentClimateRef'] - if climateTypeSName in climateMap: - climateTypeSIndex = climateMap[climateTypeSName] - else: - LOGGER.error("Unknown climate name {}".format(climateTypeSName)) - climateTypeSIndex = None - if not forceCancel: - # - # See if we need to hold - # - params = dict() - params = { - 'holdType': self.getHoldType(), + def pushResume(self): + LOGGER.debug('{}:setResume: Cancelling hold'.format(self.address)) + func = { + 'type': 'resumeProgram', + 'params': { + 'resumeAll': False } - # If not desired running mode then always push - if clismd != 0: - push = True - # Check for climate type GV3 - # This is what is desired - climateTypeRIndex = self.getDriver('GV3') - climateTypeRName = getMapName(climateMap,climateTypeRIndex) - LOGGER.debug("{}:pushHold: climateTypeSet={}={} climateTypeReq={}={}".format(self.address,climateTypeSIndex,climateTypeSName,climateTypeRIndex,climateTypeRName)) - if climateTypeRName == None: - LOGGER.debug("{}:pushHold: Unknwon climateTypeSName index {}".format(self.address,climateTypeRIndex)) - elif climateTypeRName != climateTypeSName: - LOGGER.debug("{}:pushHold: Off scheudle climateTypeSName {}={}".format(self.address,climateTypeRIndex,climateTypeSName)) - params['holdClimateRef'] = climateTypeRName - climateChange = True - # Check for Temp set point changes - coolTemp = self.tempToD(self.getDriver('CLISPC')) - LOGGER.debug("{}:pushHold: Cool: Schedule {} Set {}".format(self.address,coolTempS,coolTemp)) - heatTemp = self.tempToD(self.getDriver('CLISPH')) - LOGGER.debug("{}:pushHold: Heat: Schedule {} Set {} ()".format(self.address,heatTempS,heatTemp,self.runtime['desiredHeat'])) - # If pushing and no climateChange, then must push current set temps - # and if we push one temp you have to push both. - pushTemp = push and not climateChange - LOGGER.debug('{}:pushHold: force pushTemp={}'.format(self.address,pushTemp)) - if pushTemp or (coolTemp != coolTempS or heatTemp != heatTempS): - params['coolHoldTemp'] = self.tempToE(coolTemp) - LOGGER.debug("{}:pushHold: Push Cool: {}".format(self.address,params['coolHoldTemp'])) - params['heatHoldTemp'] = self.tempToE(heatTemp) - LOGGER.debug("{}:pushHold: Push Heat: {}".format(self.address,params['heatHoldTemp'])) - push = True - # - # Anything to Push? - # - if push: - # We have a change to push, must move to hold if not in one - if clismd == 0: - # Default is temp hold - clismd = 1 - params['holdType'] = self.getHoldType(clismd) - # The command to change - command = { - 'functions': [{ - 'type': 'setHold', - 'params': params, - }] + } + if self.ecobeePost( {'functions': [func]}): + # All cancelled, restore settings to program + self.setScheduleMode(0) + # This is what the current climate type says it should be + self.setClimateSettings() + self.events = list() + return True + LOGGER.error('{}:setResume: Post failed?'.format(self.address)) + return False + + def setClimateSettings(self,climateName=None): + if climateName is None: + climateName = self.program['currentClimateRef'] + # Set to what the current schedule says + self.setClimateType(climateName) + cdict = self.getClimateDict(climateName) + self.setCool(self.tempToD(cdict['coolTemp'],True)) + self.setHeat(self.tempToD(cdict['heatTemp'],True)) + + def pushScheduleMode(self,clismd=None,coolTemp=None,heatTemp=None): + LOGGER.debug("pushScheduleMode: clismd={} coolTemp={} heatTemp={}".format(clismd,coolTemp,heatTemp)) + if clismd is None: + clismd = int(self.getDriver('CLISMD')) + elif int(clismd) == 0: + return self.pushResume() + # Get the new schedule mode, current if in a hold, or hold next + clismd_name = self.getHoldType(clismd) + if heatTemp is None: + heatTemp = self.getDriver('CLISPH') + if coolTemp is None: + coolTemp = self.getDriver('CLISPC') + func = { + 'type': 'setHold', + 'params': { + 'holdType': clismd_name, + 'heatHoldTemp': self.tempToE(heatTemp), + 'coolHoldTemp': self.tempToE(coolTemp) } } - if self.ecobeePost(command): - self.setScheduleMode(clismd) - self.setCool(coolTemp) - self.setHeat(heatTemp) - return True - else: - LOGGER.debug('{}:pushHold: Nothing to push for hold'.format(self.address)) - clismd = self.getDriver('CLISMD') - if self.clismd == 0: - LOGGER.debug('{}:pushHold: And not in a hold'.format(self.address)) - else: - LOGGER.debug('{}:pushHold: Cancelling hold'.format(self.address)) - func = { - 'type': 'resumeProgram', - 'params': { - 'resumeAll': False - } - } - if self.ecobeePost( {'functions': [func]}): - # All cancelled, restore settings to program - self.setScheduleMode(0) - self.setClimateType(climateTypeSIndex) - self.setCool(coolTempC) - self.setHeat(heatTempC) - return True - # IF we got here the push failed, so restore settings - self.setScheduleMode(self.clismd) - self.setCool(coolTempS) - self.setHeat(heatTempS) - if climateChange: - self.setSchedleMode(climateTypeSIndex) + if self.ecobeePost({'functions': [func]}): + self.setScheduleMode(clismd_name) + self.setCool(coolTemp) + self.setHeat(heatTemp) # - # Set Methods for drivers so they are set the same and if necessary - # to track current settings so that can be restored when necessary + # Set Methods for drivers so they are set the same way + # def setScheduleMode(self,val): + LOGGER.debug('{}:setScheduleMode: {}'.format(self.address,val)) + if not is_int(val): + if val in transitionMap: + val = transitionMap[val] + else: + logger.ERROR("{}:setScheduleMode: Unknown transitionMap name {}".format(self.address,val)) + return False self.setDriver('CLISMD',int(val)) self.clismd = int(val) + # Set current climateType + # True = use current + # string = looking name + # int = just do it def setClimateType(self,val): + if val is True: + val = self.program['currentClimateRef'] + if not is_int(val): + if val in climateMap: + val = climateMap[val] + else: + LOGGER.error("Unknown climate name {}".format(val)) + return False self.setDriver('GV3',int(val)) def setCool(self,val): @@ -499,12 +471,25 @@ def cmdSetDriverF(self, cmd): self.setDriver(cmd['cmd'], float(cmd['value'])) self.pushHold() - def cmdSetScheduleMode(self,cmd): - val = int(cmd['value']) - LOGGER.debug("cmdSetScheduleMode: {} to {}".format(cmd['cmd'],val)) - self.setDriver(cmd['cmd'], val) - # Send force cancel if schedule mode = 0 - self.pushHold(val == 0) + def cmdSetPoint(self, cmd): + # Set a hold: https://www.ecobee.com/home/developer/api/examples/ex5.shtml + # TODO: Need to check that mode is auto, + #LOGGER.debug("self.events={}".format(json.dumps(self.events, sort_keys=True, indent=2))) + #LOGGER.debug("program={}".format(json.dumps(self.program, sort_keys=True, indent=2))) + driver = cmd['cmd'] + if driver == 'CLISPH': + return self.pushScheduleMode(heatTemp=cmd['value']) + else: + return self.pushScheduleMode(coolTemp=cmd['value']) + + def cmdSetScheduleMode(self, cmd): + ''' + Set the Schedule Mode, like running, or a hold + ''' + if int(self.getDriver(cmd['cmd'])) == int(cmd['value']): + LOGGER.debug("cmdSetScheduleMode: {}={} already set to {}".format(cmd['cmd'],self.getDriver(cmd['cmd']),cmd['value'])) + else: + self.pushScheduleMode(cmd['value']) def cmdSetMode(self, cmd): if int(self.getDriver(cmd['cmd'])) == int(cmd['value']): @@ -515,6 +500,26 @@ def cmdSetMode(self, cmd): if self.ecobeePost( {'thermostat': {'settings': {'hvacMode': name}}}): self.setDriver(cmd['cmd'], cmd['value']) + def cmdSetClimateType(self, cmd): + LOGGER.debug('{}:cmdSetClimateType: {}={}'.format(self.address,cmd['cmd'],cmd['value'])) + # We don't check if this is already current since they may just want setpoints returned. + climateName = getMapName(climateMap,int(cmd['value'])) + command = { + 'functions': [{ + 'type': 'setHold', + 'params': { + 'holdType': self.getHoldType(), + 'holdClimateRef': climateName + } + }] + } + if self.ecobeePost(command): + self.setDriver(cmd['cmd'], cmd['value']) + self.setDriver('CLISMD',transitionMap[self.getHoldType()]) + # If we went back to current climate name that will reset temps, so reset isy + #if self.program['currentClimateRef'] == climateName: + self.setClimateSettings(climateName) + def cmdSetFanOnTime(self, cmd): if int(self.getDriver(cmd['cmd'])) == int(cmd['value']): LOGGER.debug("cmdSetFanOnTime: {} already set to {}".format(cmd['cmd'],int(cmd['value']))) @@ -560,8 +565,8 @@ def cmdFollowMe(self, cmd): # TODO: This should set the drivers and call pushHold... def setPoint(self, cmd): LOGGER.debug(cmd) - coolTemp = float(self.getDriver('CLISPC')) - heatTemp = float(self.getDriver('CLISPH')) + coolTemp = self.tempToD(self.getDriver('CLISPC')) + heatTemp = self.tempToD(self.getDriver('CLISPH')) if 'value' in cmd: value = float(cmd['value']) else: @@ -599,11 +604,11 @@ def setPoint(self, cmd): commands = { 'QUERY': query, - 'CLISPH': cmdSetDriverF, - 'CLISPC': cmdSetDriverF, + 'CLISPH': cmdSetPoint, + 'CLISPC': cmdSetPoint, 'CLIMD': cmdSetMode, 'CLISMD': cmdSetScheduleMode, - 'GV3': cmdSetDriverI, + 'GV3': cmdSetClimateType, 'GV4': cmdSetFanOnTime, 'GV6': cmdSmartHome, 'GV7': cmdFollowMe, diff --git a/server.json b/server.json index bfaf2fb..95a2eaf 100755 --- a/server.json +++ b/server.json @@ -14,7 +14,7 @@ { "title": "ecobee: Polyglot NodeServer for Ecobee", "author": "James Milne (Einstein.42)", - "version": "2.0.12", + "version": "2.0.13", "date": "July 25, 2018", "source": "https://github.com/Einstein42/udi-ecobee-poly", "license": "https://github.com/Einstein42/udi-ecobee-poly/master/LICENSE"