/** * 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 * 2026-06-04 Codex Add Trane Home diagnostics support for newer thermostats * 2026-06-05 Codex Categorize app under Integrations * 2026-09-04 Claude Scrape pages as text - Hubitat can no longer parse * Trane's HTML into a navigable document * 2026-09-04 Claude serverUrl is the bare origin, not .../login * 2026-09-04 Claude Detect rejected logins via the Location header * 2026-09-04 Claude Defer child initialize() so it does not re-enter * the app before state is persisted (401) * 2026-09-04 Claude Remove the unused diagnostics-thermostat subsystem * 2026-09-04 Claude Re-authenticate when the session expires - Hubitat * follows the redirect, so a stale session arrives as * 200 + login HTML, not as a 302 * 2026-09-04 Claude Apply the same session check to zone/thermostat * writes, which silently no-opped when logged out * */ static String version() { return '1.2.0' } definition( name: "Nexia Thermostat Manager", namespace: "trentfoley", author: "Trent Foley", description: "Connect your Nexia thermostat to Hubitat.", category: "Convenience", menu: "Integrations", importUrl:"https://raw.githubusercontent.com/waterboysh/hubitat/main/trane-home-thermostat/nexiaThermostatManager.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" } // Base origin only. Every request supplies its own path. The old value ended in // "/login", which made the pathless dashboard fetch re-read the login page - so // the /houses/{id}/climate link was never there to find. def getServerUrl() { if(useAmerStand) return "https://asairhome.com" else return "https://www.tranehome.com" } def installed() { if(debugEnabled) log.debug("installed()") initialize() } def updated() { if(debugEnabled) log.debug("updated()") unsubscribe() initialize() } def initialize() { // Unconditional so there is never any doubt about WHICH build is running. log.debug("Nexia Thermostat Manager v${version()} initialize()") if(debugEnabled) log.debug("initialize()") // Ensure authenticated and know where this account's thermostats live reauthenticate() // Get list of thermostats and ensure child devices requestThermostats { thermostatsResp -> def devices = [] if(thermostatsResp.data && thermostatsResp.data.size() > 0) { 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) } return device } } else { log.warn("No thermostats returned from ${state.thermostatsPath}") } log.info("Discovered ${devices.size()} thermostat(s)") } // Do NOT call device.initialize() inline. The child's initialize() polls, // which re-enters this app through parent.pollChild() in a separate // execution context that reloads state from the database. Mid-initialize() // nothing has been persisted yet, so the child sees a null thermostatsPath // and no session cookies, and the request comes back 401. runIn(5, "initializeChildDevices") } // Log in and re-derive this account's house paths. Called from initialize() and // again whenever a request comes back looking unauthenticated. private boolean reauthenticate() { refreshAuthToken() return discoverHousePaths() } // Find the house id on the dashboard. Scraped as text - see httpGetText(). private boolean discoverHousePaths() { def homeBody = httpGetText("/") if(!homeBody) { log.error("Could not fetch the Trane Home dashboard") return false } if(debugEnabled) log.debug("Dashboard body length=${homeBody.length()}") def houseId = matchFirst(homeBody, /\/houses\/(\d+)\/climate/) if(!houseId) houseId = matchFirst(homeBody, /\/houses\/(\d+)/) if(!houseId) { log.error("No /houses/{id} link found on the dashboard - are we actually logged in?") return false } state.thermostatsPath = "/houses/${houseId}/xxl_thermostats" state.zonesPath = "/houses/${houseId}/xxl_zones" if(debugEnabled) log.debug("Found house ${houseId}") return true } // Runs after initialize() has ended and state has been persisted. def initializeChildDevices() { def children = getChildDevices() if(debugEnabled) log.debug("initializeChildDevices() for ${children.size()} device(s)") children.each { device -> try { device.initialize() } catch(e) { log.error("Failed to initialize ${device.displayName}: ${e}") } } } private def addMultipleDevices(dni, statname) { def device = getChildDevice(dni) if(!device) { try { device = addChildDevice(childNamespace, childName, dni, [ label: "${childName} (${statname})" ]) if(device) { log.info("Created child device ${device.displayName}") if(debugEnabled) log.debug("Created ${device.displayName} with device network id: ${dni}") } else { log.error("Child device creation returned no device for ${statname}") } } catch(e) { log.error("Failed to create child device ${statname}: ${e}") } } else { log.info("Child device already exists: ${device.displayName}") if(debugEnabled) log.debug("Found already existing ${device.displayName} with device network id: ${dni}") } return device } // --------------------------------------------------------------------------- // Text scraping. Hubitat cannot parse Trane's HTML into a navigable document - // resp.data comes back as an object whose children()/size()/getAt() all fail - // so every page is fetched with textParser:true and read with regexes. // --------------------------------------------------------------------------- // Read a response body without naming Reader/InputStream (the sandbox rejects // those class references). Groovy puts getText() on both, so try it first. private String readBodyText(o) { if(o == null) return null if(o instanceof String) return o try { return o.getText() } catch(e) { /* not a Reader/InputStream, fall through */ } try { return o.toString() } catch(e) { return null } } // First capture group of the first match, or null. private String matchFirst(String body, String regex) { if(!body) return null def m = (body =~ regex) if(m.find()) return m.group(1) return null } // GET a page and return its body as text. private String httpGetText(String path) { def params = [ uri: serverUrl, headers: getDefaultHeaders(), textParser: true ] if(path) params.path = path def body = null try { httpGet(params) { resp -> if(resp.status == 200) { updateCookies(resp) body = readBodyText(resp?.data) } else { log.error("Unexpected status ${resp.status} fetching ${path}") } } } catch(e) { log.error("Caught exception fetching ${path}: ${e}") } return body } private String getDeviceNetworkId(def statId) { return [ app.id, statId ].join('.') } private updateCookies(response) { if(state.cookies == null) state.cookies = [:] response.getHeaders('Set-Cookie').each { def cookieValue = it.value.split(';')[0] def cookieName = cookieValue.split('=')[0] state.cookies[(cookieName)] = cookieValue if(debugEnabled) log.debug("Cookie updated: ${cookieName}") } } 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()") if(debugEnabled) log.info("Attempting Trane Home login") // Initialize / clear any existing cookies state.cookies = [:] // Scrape the login form as text. The DOM walk this used to do cannot work: // Hubitat fails to parse this page, so resp.data is not navigable. def loginBody = httpGetText("/login") if(!loginBody) { log.error("Could not fetch the login page") return } if(debugEnabled) log.debug("Login page body length=${loginBody.length()}") state.AuthToken = matchFirst(loginBody, /name="authenticity_token"[^>]*value="([^"]+)"/) state.csrfToken = matchFirst(loginBody, /name="csrf-token"[^>]*content="([^"]+)"/) if(!state.AuthToken) { log.error("Could not find authenticity_token on the login page") return } if(debugEnabled) log.debug("Authenticity token found; csrfToken present=${state.csrfToken != null}") def sessionParams = [ uri: serverUrl, path: '/session', requestContentType: 'application/x-www-form-urlencoded', headers: getDefaultHeaders(), body: [ 'utf8': '✓', 'authenticity_token': state.AuthToken, 'login': settings.username, 'password': settings.password ] ] try { httpPost(sessionParams) { sessionResp -> updateCookies(sessionResp) // A successful login is a 302 to the site root. A 302 back to // /login means the credentials were rejected. def location = null try { sessionResp?.getHeaders()?.each { h -> if(h.name?.toLowerCase() == "location") location = h.value } } catch(e) { /* header read is best effort */ } if(sessionResp.status == 302 && location != null && location.contains("/login")) { log.error("Trane Home rejected the login - check username/password") } else if(sessionResp.status == 302 || sessionResp.status == 200) { if(debugEnabled) log.debug("Trane Home login successful") } else { log.error("Unexpected login status ${sessionResp.status}") } } } catch(e) { log.error("Caught exception posting login ${e}") } } private requestThermostats(Closure closure) { requestThermostatsWithRetry(closure, true) } // Session expiry does NOT arrive as a 302. Hubitat follows redirects, so an // expired session yields 200 with the login page's HTML. That object is not // navigable, but it does answer find(Closure) with null - which is why a stale // session used to surface as "No data found ... after polling" rather than as // an auth error. The content type is the reliable tell: the real endpoint // answers application/json, the login page answers text/html. private requestThermostatsWithRetry(Closure closure, boolean allowRetry) { if(debugEnabled) log.debug("requestThermostats(${state.thermostatsPath})") // A child poll runs in its own execution context; if it never picked up the // path, re-derive it rather than GETting the site root and finding nothing. if(!state.thermostatsPath) { if(!allowRetry) { log.error("No thermostatsPath available after re-authenticating") return } if(debugEnabled) log.warn("No thermostatsPath in state; re-authenticating") reauthenticate() requestThermostatsWithRetry(closure, false) return } def thermostatsParams = [ uri: serverUrl, path: state.thermostatsPath, headers: getDefaultHeaders() ] try { httpGet(thermostatsParams) { resp -> def isJson = resp.contentType?.toString()?.contains("json") if(resp.status == 200 && isJson) { closure(resp) } else if(allowRetry) { if(debugEnabled) log.warn("Thermostat request looks unauthenticated (status ${resp.status}, contentType ${resp.contentType}); re-authenticating") reauthenticate() requestThermostatsWithRetry(closure, false) } else { log.error("Still unauthenticated after re-login (status ${resp.status}, contentType ${resp.contentType})") } } } catch(e) { // An expired session can also come back as a thrown 401. if(allowRetry) { if(debugEnabled) log.warn("Exception requesting thermostats (${e}); re-authenticating") reauthenticate() requestThermostatsWithRetry(closure, false) } else { 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 } // The writes below face the same trap as the reads: an expired session is not // a 302, it is a 200 carrying the login page, which would otherwise be logged // as "update suceeded". Only an HTML body is treated as unauthenticated - a // successful API write never returns HTML, while treating "not JSON" as failure // would misfire if these endpoints answer with an empty or unusual body. private boolean looksUnauthenticated(resp) { return resp?.contentType?.toString()?.contains("html") } // updateType can be: "setpoints", "zone_mode" private updateZone(zone, updateType) { updateZoneWithRetry(zone, updateType, true) } private updateZoneWithRetry(zone, updateType, boolean allowRetry) { 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 ] try { httpPutJson(requestParams) { resp -> if (resp.status == 200 && !looksUnauthenticated(resp)) { if(debugEnabled) log.debug("Zone update suceeded") } else if (allowRetry) { if(debugEnabled) log.warn("Zone update looks unauthenticated (status ${resp.status}, contentType ${resp.contentType}); re-authenticating") reauthenticate() updateZoneWithRetry(zone, updateType, false) } else { log.error("Unexpected status while attempting to update zone: ${resp.status} (contentType ${resp.contentType})") /* 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)}" } */ } } } catch(e) { // Without this the exception escapes into requestThermostats' handler, // which would re-run the whole enclosing closure and write twice. if(allowRetry) { log.warn("Exception updating zone (${e}); re-authenticating") reauthenticate() updateZoneWithRetry(zone, updateType, false) } else { log.error("Caught exception updating zone ${e}") } } } // updateType can be: "fan_mode" private updateThermostat(stat, updateType) { updateThermostatWithRetry(stat, updateType, true) } private updateThermostatWithRetry(stat, updateType, boolean allowRetry) { if(debugEnabled) log.debug("updateThermostat(${stat.id}, ${updateType})") def requestParams = [ uri: serverUrl, path: "${state.thermostatsPath}/${stat.id}/${updateType}", headers: getDefaultHeaders(), body: stat ] try { httpPutJson(requestParams) { resp -> if (resp.status == 200 && !looksUnauthenticated(resp)) { if(debugEnabled) log.debug("Thermostat update suceeded") } else if (allowRetry) { if(debugEnabled) log.warn("Thermostat update looks unauthenticated (status ${resp.status}, contentType ${resp.contentType}); re-authenticating") reauthenticate() updateThermostatWithRetry(stat, updateType, false) } else { log.error("Unexpected status while attempting to update thermostat: ${resp.status} (contentType ${resp.contentType})") } } } catch(e) { if(allowRetry) { log.warn("Exception updating thermostat (${e}); re-authenticating") reauthenticate() updateThermostatWithRetry(stat, updateType, false) } else { log.error("Caught exception updating thermostat ${e}") } } } 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(){ app.updateSetting("debugEnabled",[value:"false",type:"bool"]) }