/** * Copyright 2015 SmartThings * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * * Nexia Thermostat Service Manager * * Author: Trent Foley * Date: 2016-01-19 * * ** Modifications ** * Date Who Description * 2022-09-15 thebearmay Port to Hubitat * 2022-09-16 thebearmay Fix thermostatOperatingMode * 2022-10-04 thebearmay Add permanent hold and return to schedule * 2022-10-07 thebearmay Option to use American Standard Login * */ static String version() { return '1.0.3' } definition( name: "Nexia Thermostat Manager", namespace: "trentfoley", author: "Trent Foley", description: "Connect your Nexia thermostat to Hubitat.", category: "Convenience", importUrl:"https://raw.githubusercontent.com/thebearmay/hubitat/main/STPorts/nexiaThermMgr.groovy", iconUrl: "http://lh4.ggpht.com/oMx3-nlICwLmUxpDhTXWsZ6Ocuzu9P2yfz9jpXBx1rhrW_Vcj94kPl2M9ooApckK6TM1=w60", iconX2Url: "https://www.trane.com/content/dam/Trane/residential/products/nexia/medium/TR_Nexia%20-%20Medium.jpg", iconX3Url: "https://www.trane.com/content/dam/Trane/residential/products/nexia/medium/TR_Nexia%20-%20Medium.jpg", singleInstance: true ) { } preferences { section("

Nexia Authentication
v${version()}

") { input "username", "text", title: "Username" input "password", "password", title: "Password" input "debugEnabled", "bool", title: "Enable debug logging?", width:4 input "useAmerStand", "bool", title: "Use American Standard login", width:4, defaultValue:false if(debugEnabled) runIn(1800, "logsOff") } } def getChildNamespace() { "trentfoley" } def getChildName() { "Nexia Thermostat" } def getServerUrl() { if(useAmerStand) return "https://asairhome.com/login" else return "https://www.tranehome.com/login" } def installed() { if(debugEnabled) log.debug("installed()") initialize() } def updated() { if(debugEnabled) log.debug("updated()") unsubscribe() initialize() } def initialize() { if(debugEnabled) log.debug("initialize()") // Ensure authenticated refreshAuthToken() // Get list of thermostats and ensure child devices def homeParams = [ //method: 'GET', uri: serverUrl, headers: getDefaultHeaders() ] try { httpGet(homeParams) { homeResp -> def respData = homeResp.data[0] // html / body / div id=footer-wrapper / div id=content / div id=content_sidebar / nav / ul / li / a id=climate_link // Recursive search for climate/index link. Should be more robust to Nexia DOM changes respData.children().each{ searchForClimate(it) } } } catch(e) { log.error("Caught exception determining thermostats path $e") } // Get list of thermostats and ensure child devices requestThermostats { thermostatsResp -> def devices = thermostatsResp.data.collect { stat -> if(debugEnabled) log.debug("Found thermostat with ID: ${stat.id}") //Check for Multiple Zones def dni = getDeviceNetworkId(stat.id) def device = null; if(stat.zones.size > 1) { stat.zones.each { dni = getDeviceNetworkId(stat.id + "_" + it.id) device = addMultipleDevices(dni, it.name) } } else { dni = getDeviceNetworkId(stat.id) device = addMultipleDevices(dni, stat.name) } device.initialize() return device } if(debugEnabled) log.debug("Discovered ${devices.size()} thermostats") } } private searchForClimate(httpNode) { if(httpNode != null && !(httpNode instanceof String)) { def href = httpNode.attributes()["href"] if(href != null) { if(debugEnabled) log.debug "$href" if(href.matches("/houses/(?i).*climate")) { if(debugEnabled) log.debug "Found climate" state.thermostatsPath = href.replace("/climate", "/xxl_thermostats") state.zonesPath = href.replace("/climate", "/xxl_zones") if(debugEnabled) log.debug("state.thermostatsPath = ${state.thermostatsPath}; state.zonesPath = ${state.zonesPath}") } } if(httpNode.children() != null) { httpNode.children().each { if(it!=null) searchForClimate(it) } } } } private def addMultipleDevices(dni, statname) { def device = getChildDevice(dni) if(!device) { device = addChildDevice(childNamespace, childName, dni, null, [ label: "${childName} (${statname})" ]) if(debugEnabled) log.debug("Created ${device.displayName} with device network id: ${dni}") } else { if(debugEnabled) log.debug("Found already existing ${device.displayName} with device network id: ${dni}") } return device } private refreshCsrfToken(resp) { def respData = resp.data[0] // Get CSRF token from response // head / respData.children[0].children().each { if (it.attributes()["name"] == "csrf-token") { state.csrfToken = it.attributes()["content"] if(debugEnabled) log.debug("state.csrfToken = ${state.csrfToken}") } } } private searchForAuthToken(httpNode) { if(httpNode != null && !(httpNode instanceof String)) { if (httpNode.attributes()["name"] != null) { if(httpNode.attributes()["name"]=="authenticity_token") { state.AuthToken = httpNode.attributes()["value"] if(debugEnabled) log.debug("state.AuthToken = ${state.AuthToken}") } } if (httpNode.children() != null) { httpNode.children().each { if(it != null) searchForAuthToken(it) } } } } private String getDeviceNetworkId(def statId) { return [ app.id, statId ].join('.') } private updateCookies(response) { response.getHeaders('Set-Cookie').each { def cookieValue = it.value.split(';')[0] def cookieName = cookieValue.split('=')[0] state.cookies[(cookieName)] = cookieValue if(debugEnabled) log.debug("state.cookies[${cookieName}] = ${cookieValue}") } } def getDefaultHeaders() { def headers = [ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Encoding': 'gzip, deflate', 'Accept-Language': 'en-US,en,q=0.8', 'Cache-Control': 'max-age=0', 'Connection': 'keep-alive', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36', 'X-CSRF-Token': state.csrfToken, 'X-Requested-With': 'XMLHttpRequest' ] def cookieString = state.cookies?.collect { entry -> entry.value }?.join('; '); if (cookieString) { headers.Cookie = cookieString } return headers } private refreshAuthToken() { if(debugEnabled) log.debug("refreshAuthToken()") // Initialize / clear any existing cookies state.cookies = [:] def loginParams = [ //method: 'GET', uri: serverUrl, path: "/login", headers: getDefaultHeaders() ] try { httpGet(loginParams) { loginResp -> updateCookies(loginResp) // html / body / div id=content / div id=external-wrapper / div id=external-content / div id=login-form / form / div / input name=authenticity_token // OLD def authenticityToken = loginResp.data[0].children[1].children[1].children[0].children[0].children[1].children[2].children[0].children[1].attributes()["value"] //def authenticityToken = loginResp.data[0].children[1].children[1].children[0].children[0].children[0].children[0].children[2].children[0].children[1].attributes()["value"] // Recursive search for authenticity token. Should be more robust to Nexia DOM changes searchForAuthToken(loginResp.data[0]) def authenticityToken = state.AuthToken refreshCsrfToken(loginResp); def sessionParams = [ //method: 'POST', uri: serverUrl, path: '/session', requestContentType: 'application/x-www-form-urlencoded', headers: getDefaultHeaders(), body: [ 'utf8': '✓', 'authenticity_token': authenticityToken, 'login': settings.username, 'password': settings.password ] ] httpPost(sessionParams) { sessionResp -> if (sessionResp.status != 302) { log.error("Did not receive expected response status code. Expected 302, actual ${sessionResp.status}") } updateCookies(sessionResp) refreshCsrfToken(sessionResp); } } } catch(e) { log.error("Caught exception refreshing auth token $e") } } private requestThermostats(Closure closure) { if(debugEnabled) log.debug("requestThermostats(${state.thermostatsPath})") def thermostatsParams = [ uri: serverUrl, path: state.thermostatsPath, headers: getDefaultHeaders() ] try { httpGet(thermostatsParams) { resp -> if (resp.status == 200) { closure(resp) } else if (resp.status == 302) { // Redirect to login page due to session expiration refreshAuthToken() requestThermostats(closure) } else { log.error("Unexpected status while requesting thermostats: ${resp.status}") } } } catch(e) { log.error("Caught exception requesting thermostats $e") } } private requestThermostat(deviceNetworkId, Closure closure) { if(debugEnabled) log.debug("requestThermostat(${deviceNetworkId})") requestThermostats { resp -> def stat = resp.data.find { it -> getDeviceNetworkId(it.id) == deviceNetworkId } if (!stat) { log.error("Device connection removed? No data found for ${deviceNetworkId} after polling") } else { closure(stat) } } } // Poll Child is invoked from the Child Device itself as part of the Poll Capability def pollChild(child) { //if zoned, take off zone id... performs a repetitive update due to zoning, fix later def deviceNetworkId = ((child.device.deviceNetworkId).split('_'))[0] def zonedBool = ((child.device.deviceNetworkId).split('_')).size() if(debugEnabled) log.debug("ZoneBool ${zonedBool} pollChild(${deviceNetworkId})") def statData = [:] requestThermostat(deviceNetworkId) { stat -> def zone = stat.zones[0] if(zonedBool > 1) { def zoneNetworkId = ((child.device.deviceNetworkId).split('_'))[1] zone = stat.zones.find {it.id == zoneNetworkId.toInteger()} } def systemStatusToOperatingStateMapping = [ "System Idle": "idle", "Waiting...": "pending ${zone.zone_mode.toLowerCase()}", "Heating": "heating", "Cooling": "cooling", "Fan Running": "fan only" ] if(debugEnabled) log.debug "Zone: $zone" statData = [ temperature: zone.temperature.toInteger(), heatingSetpoint: zone.heating_setpoint.toInteger(), coolingSetpoint: zone.cooling_setpoint.toInteger(), thermostatSetpoint: ((zone.zone_mode == "COOL") ? zone.cooling_setpoint : zone.heating_setpoint).toInteger(), // TODO: handle case for "emergency heat" thermostatMode: zone.requested_zone_mode.toLowerCase(), // "auto" "emergency heat" "heat" "off" "cool" thermostatFanMode: stat.fan_mode, // "auto" "on" "circulate" thermostatOperatingState: systemStatusToOperatingStateMapping[stat.system_status], // "heating" "idle" "pending cool" "vent economizer" "cooling" "pending heat" "fan only" systemStatus: stat.system_status, activeMode: zone.zone_mode.toLowerCase(), emergencyHeatSupported: stat.emergency_heat_supported, humidity: (stat.current_relative_humidity * 100).toInteger(), outdoorTemperature: stat.raw_outdoor_temperature.toInteger(), setpointStatus: zone.setpoint_status ] } return statData } // updateType can be: "setpoints", "zone_mode" private updateZone(zone, updateType) { if(debugEnabled) log.debug("updateZone(${zone.id}, ${updateType})") zone.hold_time = zone.hold_time.toBigInteger() def requestParams = [ uri: serverUrl, path: "${state.zonesPath}/${zone.id}/${updateType}", headers: getDefaultHeaders(), body: zone ] httpPutJson(requestParams) { resp -> if (resp.status == 200) { if(debugEnabled) log.debug("Zone update suceeded") } else { log.error("Unexpected status while attempting to update zone: ${resp.status}") /* def zoneJson = new org.json.JSONObject(zone).toString() def interations = Math.ceil(zoneJson.length() / 1200.0) for(int i = 0; i <= interations; i++) { def end = i * 1200 + 1200 if (zoneJson.length() < end) { end = zoneJson.length() } if(debugEnabled) log.debug "${i}: ${zoneJson.substring(i * 1200, end)}" } */ } } } // updateType can be: "fan_mode" private updateThermostat(stat, updateType) { if(debugEnabled) log.debug("updateThermostat(${stat.id}, ${updateType})") def requestParams = [ uri: serverUrl, path: "${state.thermostatsPath}/${stat.id}/${updateType}", headers: getDefaultHeaders(), body: stat ] httpPutJson(requestParams) { resp -> if (resp.status == 200) { if(debugEnabled) log.debug("Thermostat update suceeded") } else { log.error("Unexpected status while attempting to update thermostat: ${resp.status} ${stat}") } } } def setHeatingSetpoint(child, degreesF) { def deviceNetworkId = ((child.device.deviceNetworkId).split('_'))[0] def zonedBool = ((child.device.deviceNetworkId).split('_')).size() if(debugEnabled) log.debug("setHeatingSetpoint(${deviceNetworkId}, ${degreesF})") requestThermostat(deviceNetworkId) { stat -> def zone = stat.zones[0] if(zonedBool > 1) { def zoneNetworkId = ((child.device.deviceNetworkId).split('_'))[1] zone = stat.zones.find {it.id == zoneNetworkId.toInteger()} } zone.heating_setpoint = degreesF zone.heating_integer = "${degreesF.toInteger()}" zone.heating_decimal = "" zone.cooling_setpoint = zone.cooling_setpoint zone.cooling_integer = "${zone.cooling_setpoint}" zone.cooling_decimal = "" updateZone(zone, "setpoints") } } def setCoolingSetpoint(child, degreesF) { def deviceNetworkId = ((child.device.deviceNetworkId).split('_'))[0] def zonedBool = ((child.device.deviceNetworkId).split('_')).size() if(debugEnabled) log.debug("setCoolingSetpoint(${deviceNetworkId}, ${degreesF})") requestThermostat(deviceNetworkId) { stat -> def zone = stat.zones[0] if(zonedBool > 1) { def zoneNetworkId = ((child.device.deviceNetworkId).split('_'))[1] zone = stat.zones.find {it.id == zoneNetworkId.toInteger()} } zone.heating_setpoint = zone.heating_setpoint zone.heating_integer = "${zone.heating_setpoint.toInteger()}" zone.heating_decimal = "" zone.cooling_setpoint = degreesF zone.cooling_integer = "${degreesF.toInteger()}" zone.cooling_decimal = "" updateZone(zone, "setpoints") } } def setThermostatMode(child, value) { def deviceNetworkId = ((child.device.deviceNetworkId).split('_'))[0] def zonedBool = ((child.device.deviceNetworkId).split('_')).size() if(debugEnabled) log.debug("setThermostatMode(${deviceNetworkId}, ${value})") requestThermostat(deviceNetworkId) { stat -> def zone = stat.zones[0] if(zonedBool > 1) { def zoneNetworkId = ((child.device.deviceNetworkId).split('_'))[1] zone = stat.zones.find {it.id == zoneNetworkId.toInteger()} } zone.requested_zone_mode = value.toUpperCase() zone.last_requested_zone_mode = value.toUpperCase() updateZone(zone, "zone_mode") } } def setHoldMode(child, value) {//"permanent_hold" or "return_to_schedule" def deviceNetworkId = ((child.device.deviceNetworkId).split('_'))[0] def zonedBool = ((child.device.deviceNetworkId).split('_')).size() if(debugEnabled) log.debug("setThermostatMode(${deviceNetworkId}, ${value})") requestThermostat(deviceNetworkId) { stat -> def zone = stat.zones[0] if(zonedBool > 1) { def zoneNetworkId = ((child.device.deviceNetworkId).split('_'))[1] zone = stat.zones.find {it.id == zoneNetworkId.toInteger()} } updateZone(zone, value) } } def setThermostatFanMode(child, value) { def deviceNetworkId = ((child.device.deviceNetworkId).split('_'))[0] def zonedBool = ((child.device.deviceNetworkId).split('_')).size() if(debugEnabled) log.debug("setThermostatFanMode(${deviceNetworkId}, ${value})") requestThermostat(deviceNetworkId) { stat -> stat.fan_mode = value updateThermostat(stat, "fan_mode") } } void logsOff(){ device.updateSetting("debugEnabled",[value:"false",type:"bool"]) }