/**
* 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-09-05 Codex Improve login, thermostat discovery, and session recovery for older thermostats
*
*/
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
}
}
def getChildNamespace() { "trentfoley" }
def getChildName() { "Nexia Thermostat" }
def getServerUrl() {
if(useAmerStand)
return "https://asairhome.com"
else
return "https://www.tranehome.com"
}
def installed() {
initialize()
}
def updated() {
unsubscribe()
initialize()
}
def initialize() {
unschedule("logsOff")
if(debugEnabled) runIn(1800, "logsOff")
state.initializationReady = false
debugStage("initialize", "version=${version()}; service=${useAmerStand ? 'American Standard' : 'Trane Home'}; debug auto-off=30 minutes")
// Keep the previous routes available to existing children if setup fails.
def previousRoutes = [
thermostatsPath: state.thermostatsPath, zonesPath: state.zonesPath,
diagnosticThermostats: state.diagnosticThermostats
]
def candidates = []
def stage = "login"
try {
state.thermostatsPath = null
state.zonesPath = null
state.diagnosticThermostats = [:]
if(!authenticateAndDiscoverRoutes()) validationFailure("Login or dashboard discovery did not complete")
stage = "thermostat discovery"
if(state.thermostatsPath) {
boolean received = requestThermostats { resp ->
resp.data.each { stat ->
validateThermostat(stat, false)
if(stat.zones.size() > 1) {
stat.zones.each { zone ->
candidates << [dni: getDeviceNetworkId("${stat.id}_${zone.id}"), label: zone.name ?: stat.name ?: "Thermostat"]
}
} else {
candidates << [dni: getDeviceNetworkId(stat.id), label: stat.name ?: "Thermostat"]
}
}
}
if(!received) validationFailure("Legacy request failed; not an empty result")
}
if(!candidates) {
debugStage(stage, "legacy data empty or route absent; checking diagnostics")
candidates = diagnosticDeviceCandidates()
}
if(!candidates) {
log.error("No usable thermostat data returned. No child devices will be created.")
validationFailure("No usable thermostat data")
}
if(candidates.collect { it.dni }.unique().size() != candidates.size()) {
validationFailure("Duplicate thermostat/zone identifiers")
}
// All discovery responses have been validated before any child is created.
state.initializationReady = true
} catch(e) {
logStageException(stage, e)
}
if(!state.initializationReady) {
state.thermostatsPath = previousRoutes.thermostatsPath
state.zonesPath = previousRoutes.zonesPath
state.diagnosticThermostats = previousRoutes.diagnosticThermostats ?: [:]
log.error("Setup stopped at ${stage}. No child devices were created; existing devices were left in place. Enable debug logging and save the app to capture another attempt.")
return
}
int ready = 0
candidates.each { candidate ->
def device = addMultipleDevices(candidate.dni, candidate.label)
if(device) {
ready++
}
}
// Child initialize() immediately polls the parent. Deferring it allows the
// newly authenticated cookies and routes to be persisted first.
runIn(5, "initializeChildDevices")
log.info("Validated ${candidates.size()} thermostat zone(s); ${ready} child device(s) available.")
}
def initializeChildDevices() {
def children = getChildDevices() ?: []
children.each { device ->
try { device.initialize() }
catch(e) { logStageException("child initialize", e) }
}
}
private debugStage(String stage, String message) {
if(debugEnabled) log.debug("[${stage}] ${message}")
}
private String dataShape(value) {
if(value == null) return "null"
if(value instanceof Map) return "map (${value.size()} entries)"
if(value instanceof List) return "list (${value.size()} entries)"
if(value instanceof CharSequence) return "text (${value.length()} characters)"
if(value instanceof Number) return "number"
if(value instanceof Boolean) return "boolean"
// Hubitat forbids both java.lang.Class expressions and getClass() calls.
return "unsupported object"
}
// Do not log bodies, request headers, credentials, cookies, tokens or full exception messages.
private responseData(String stage, resp) {
return resp.data
}
private debugResponseDetails(String stage, resp, data) {
debugStage(stage, "HTTP ${resp.status}; content type=${responseContentType(resp) ?: 'unknown'}; body shape=${dataShape(data)}")
}
private requireStatus(resp, List allowed) {
if(!(resp.status in allowed)) validationFailure("Unexpected HTTP status ${resp.status}")
}
private validationFailure(String reason) {
// Only developer-defined descriptions are logged, never server response text.
log.error("[validation] ${reason}")
throw new IllegalStateException(reason)
}
private Integer exceptionStatus(e) {
try {
def status = e.response?.status
return status instanceof Number ? status.intValue() : null
} catch(ignored) {
return null
}
}
private logStageException(String stage, e) {
def status = exceptionStatus(e)
if(status != null) log.error("[${stage}] HTTP ${status}")
if(e instanceof groovy.lang.MissingMethodException) {
debugStage(stage, "Missing method=${e.method}; argument values omitted")
}
def source = e.stackTrace?.find { it.fileName?.endsWith(".groovy") }
def line = source ? "; Groovy line=${source.lineNumber}" : ""
log.error("[${stage}] Request or data-processing exception${line}. Response details are available with debug logging enabled.")
}
private String readBodyText(data) {
if(data == null) return null
if(data instanceof CharSequence) return data.toString()
try { return data.getText() }
catch(ignored) { /* Not a text stream; try a safe conversion below. */ }
try { return data.toString() }
catch(ignored) { return null }
}
private String responseContentType(resp) {
try {
def direct = resp.contentType?.toString()
if(direct) return direct.toLowerCase()
} catch(ignored) { }
try {
def header = resp.getHeaders('Content-Type')?.find { true }?.value?.toString()
return header?.toLowerCase()
} catch(ignored) {
return null
}
}
private boolean isJsonResponse(resp) {
return responseContentType(resp)?.contains("json")
}
private boolean looksUnauthenticated(resp) {
return responseContentType(resp)?.contains("html")
}
private Map htmlAttributes(String tag) {
def attributes = [:]
def matcher = tag =~ /(?is)([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(["'])(.*?)\2/
while(matcher.find()) attributes[matcher.group(1).toLowerCase()] = matcher.group(3)
return attributes
}
private String htmlNamedAttribute(String body, String elementName, String attributeName) {
if(!body) return null
def tags = body =~ /(?is)<(?:input|meta)\b[^>]*>/
while(tags.find()) {
def attributes = htmlAttributes(tags.group())
if(attributes.name == elementName) return attributes[attributeName.toLowerCase()]?.toString()
}
return null
}
private String httpGetText(String path, String stage) {
String body = null
try {
def params = [uri: serverUrl, headers: getDefaultHeaders(), textParser: true]
if(path != null) params.path = path
httpGet(params) { resp ->
requireStatus(resp, [200])
updateCookies(resp)
def data = resp.data
body = readBodyText(data)
if(!body) {
debugResponseDetails(stage, resp, data)
validationFailure("Empty text response during ${stage}")
}
}
} catch(e) {
logStageException(stage, e)
}
return body
}
private boolean discoverHouseRoutes() {
def previousRoutes = [thermostatsPath: state.thermostatsPath, zonesPath: state.zonesPath,
diagnosticThermostats: state.diagnosticThermostats]
state.thermostatsPath = null
state.zonesPath = null
state.diagnosticThermostats = [:]
def body = httpGetText("/", "authenticated dashboard GET")
if(!body) {
restoreRoutes(previousRoutes)
return false
}
discoverRoutesFromText(body)
boolean discovered = !!state.thermostatsPath || !!state.diagnosticThermostats
debugStage("authenticated dashboard GET", "legacy route present=${!!state.thermostatsPath}; diagnostics links=${state.diagnosticThermostats?.size() ?: 0}")
if(!discovered) {
restoreRoutes(previousRoutes)
log.error("No supported thermostat links found on the dashboard. Check Trane Home web access.")
}
return discovered
}
private restoreRoutes(Map routes) {
state.thermostatsPath = routes.thermostatsPath
state.zonesPath = routes.zonesPath
state.diagnosticThermostats = routes.diagnosticThermostats ?: [:]
}
private discoverRoutesFromText(String body) {
if(!body) return
if(state.diagnosticThermostats == null) state.diagnosticThermostats = [:]
def climate = body =~ /(?i)\/houses\/(\d+)\/climate/
if(climate.find()) {
def houseId = climate.group(1)
state.thermostatsPath = "/houses/${houseId}/xxl_thermostats"
state.zonesPath = "/houses/${houseId}/xxl_zones"
}
def diagnostic = body =~ /(?i)\/houses\/(\d+)\/diagnostics\/thermostats\/([^\/?\."'<>\s]+)/
while(diagnostic.find()) {
registerDiagnosticThermostat(diagnostic.group(1), diagnostic.group(2))
}
}
private validateThermostat(stat, boolean diagnostic) {
if(!(stat instanceof Map) || !(stat.zones instanceof List) || stat.zones.isEmpty()) {
validationFailure("Expected thermostat object with zones")
}
if(!diagnostic && !validIdentifier(stat.id)) validationFailure("Missing thermostat ID")
def zoneIds = []
stat.zones.each { zone ->
if(!(zone instanceof Map)) validationFailure("Expected zone object")
def zoneId = diagnostic ? zone.zone_id : zone.id
if(!validIdentifier(zoneId)) validationFailure("Missing zone ID")
zoneIds << zoneId.toString()
// Missing readings are not sufficient evidence of a usable device.
def readings = diagnostic ? [zone.temperature, zone.heat_setpoint, zone.cool_setpoint] :
[zone.temperature, zone.heating_setpoint, zone.cooling_setpoint]
if(!readings.every { it != null && it.toString().isNumber() }) {
validationFailure("Missing or invalid temperature/setpoint readings")
}
}
if(zoneIds.unique().size() != stat.zones.size()) validationFailure("Duplicate zone IDs")
}
private boolean validIdentifier(value) {
return (value instanceof Number || value instanceof CharSequence) && value.toString().trim().length() > 0
}
private registerDiagnosticThermostat(houseId, thermostatId) {
def thermId = thermostatId.toString()
state.diagnosticThermostats[thermId] = [
id: thermId,
houseId: houseId.toString(),
path: "/houses/${houseId}/diagnostics/thermostats/${thermId}.json",
updatePath: "/houses/${houseId}/diagnostics/thermostats/${thermId}"
]
}
private discoverDiagnosticThermostats() {
if(!state.thermostatsPath) {
return
}
// Re-read the climate page after login so we can inspect its DOM attributes
// and collect diagnostics thermostat URLs for newer Trane Home devices.
def climatePath = state.thermostatsPath.replace("/xxl_thermostats", "/climate")
def climateBody = httpGetText(climatePath, "climate GET / diagnostics discovery")
if(climateBody) discoverRoutesFromText(climateBody)
}
private diagnosticDeviceCandidates() {
def candidates = []
state.diagnosticThermostats?.each { thermId, therm ->
boolean received = requestDiagnosticThermostat(thermId) { stat ->
validateThermostat(stat, true)
stat.zones.each { zone ->
candidates << [dni: getDeviceNetworkId("${thermId}_${zone.zone_id}"),
label: stat.name ?: zone.name ?: "Thermostat"]
}
}
if(!received) validationFailure("Diagnostics request failed")
}
return candidates
}
private def addMultipleDevices(dni, statname) {
if(!state.initializationReady) {
log.error("Child creation blocked: setup has not validated login and thermostat data.")
return null
}
def device = getChildDevice(dni)
if(!device) {
try {
device = addChildDevice(childNamespace, childName, dni, [ label: "${childName} (${statname})" ])
if(device) {
log.info("Created child device ${device.displayName}")
} else {
log.error("Child device creation returned no device for ${statname}")
}
}
catch(e) {
logStageException("child device creation (verify Nexia Thermostat driver is installed)", e)
}
} else {
}
return device
}
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
}
}
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 boolean authenticateAndDiscoverRoutes() {
if(!refreshAuthToken()) return false
if(!discoverHouseRoutes()) return false
// The climate page may contain diagnostics URLs not present on the dashboard.
discoverDiagnosticThermostats()
return !!state.thermostatsPath || !!state.diagnosticThermostats
}
private String responseLocation(resp) {
try {
def location = resp.getHeaders('Location')?.find { true }?.value?.toString()
if(location) return location
} catch(ignored) { }
try {
return resp.getHeaders()?.find { it.name?.toString()?.equalsIgnoreCase('Location') }?.value?.toString()
} catch(ignored) {
return null
}
}
private boolean refreshAuthToken() {
def stage = "login GET"
boolean submitted = false
state.cookies = [:]
state.AuthToken = null
state.csrfToken = null
log.info("Attempting ${useAmerStand ? 'American Standard' : 'Trane Home'} login")
try {
if(!settings.username || !settings.password) {
log.error("Login requires a username and password.")
return false
}
def loginBody = httpGetText("/login", stage)
if(!loginBody) return false
stage = "login HTML parsing"
state.AuthToken = htmlNamedAttribute(loginBody, "authenticity_token", "value")
state.csrfToken = htmlNamedAttribute(loginBody, "csrf-token", "content")
debugStage(stage, "Login form tokens found")
if(!state.AuthToken) validationFailure("Missing login authenticity token")
stage = "session POST"
httpPost([
uri: serverUrl, path: "/session",
requestContentType: "application/x-www-form-urlencoded",
headers: getDefaultHeaders(),
body: [utf8: '✓', authenticity_token: state.AuthToken,
login: settings.username, password: settings.password]
]) { sessionResp ->
requireStatus(sessionResp, [200, 302])
updateCookies(sessionResp)
def location = responseLocation(sessionResp)
if(location?.toLowerCase()?.contains("/login")) {
log.error("Trane Home rejected the login. Check the username, password, and web-account prompts.")
submitted = false
} else {
submitted = true
}
}
if(submitted) debugStage("login", "Credentials accepted; verifying dashboard access")
} catch(e) {
logStageException(stage, e)
}
return submitted
}
private boolean requestThermostats(Closure closure, boolean retried = false) {
if(!state.thermostatsPath) {
log.error("Thermostat request skipped: no discovered thermostat path.")
return false
}
boolean received = false
try {
httpGet([uri: serverUrl, path: state.thermostatsPath, headers: getDefaultHeaders()]) { resp ->
def data = responseData("legacy thermostats GET", resp)
def unauthenticated = resp.status in [302, 401] || looksUnauthenticated(resp)
if(resp.status == 200 && !unauthenticated) {
if(!(data instanceof List)) {
debugResponseDetails("legacy thermostats GET", resp, data)
validationFailure("Expected thermostat list")
}
if(!state.initializationReady) debugStage("thermostat discovery", "legacy records=${data.size()}")
// Supply the already-read data to avoid accessing the parser result twice.
closure([status: 200, data: data])
received = true
} else if(unauthenticated && !retried) {
debugStage("legacy thermostats GET", "Session appears expired; attempting one login and route-discovery retry")
if(authenticateAndDiscoverRoutes()) received = requestThermostats(closure, true)
} else {
debugResponseDetails("legacy thermostats GET", resp, data)
log.error("Unexpected response while requesting thermostats: status ${resp.status}; content type ${responseContentType(resp) ?: 'unknown'}")
}
}
} catch(e) {
logStageException("legacy thermostats GET / data processing", e)
// Hubitat may throw for 401 instead of invoking the response callback.
if(exceptionStatus(e) == 401 && !retried && authenticateAndDiscoverRoutes()) {
received = requestThermostats(closure, true)
}
}
return received
}
private requestThermostat(deviceNetworkId, Closure closure) {
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)
}
}
}
private boolean requestDiagnosticThermostat(thermId, Closure closure, boolean retried = false) {
def thermostat = state.diagnosticThermostats?.get(thermId)
if(!thermostat) {
log.error("No diagnostics route configured for the requested thermostat")
return false
}
boolean received = false
try {
httpGet([uri: serverUrl, path: thermostat.path, headers: getDefaultHeaders()]) { resp ->
def data = responseData("diagnostics thermostat GET", resp)
def unauthenticated = resp.status in [302, 401] || looksUnauthenticated(resp)
if(resp.status == 200 && !unauthenticated) {
if(!(data instanceof Map) || !(data.zones instanceof List)) {
debugResponseDetails("diagnostics thermostat GET", resp, data)
validationFailure("Expected diagnostics object with zones")
}
if(!state.initializationReady) debugStage("thermostat discovery", "diagnostics zones=${data.zones.size()}")
closure(data)
received = true
} else if(unauthenticated && !retried) {
debugStage("diagnostics thermostat GET", "Session appears expired; attempting one login and route-discovery retry")
if(authenticateAndDiscoverRoutes()) received = requestDiagnosticThermostat(thermId, closure, true)
} else {
debugResponseDetails("diagnostics thermostat GET", resp, data)
log.error("Unexpected response while requesting diagnostics thermostat: status ${resp.status}; content type ${responseContentType(resp) ?: 'unknown'}")
}
}
} catch(e) {
logStageException("diagnostics thermostat GET / data processing", e)
if(exceptionStatus(e) == 401 && !retried && authenticateAndDiscoverRoutes()) {
received = requestDiagnosticThermostat(thermId, closure, true)
}
}
return received
}
private String getRawDeviceId(String deviceNetworkId) {
// Hubitat child DNI is "{app.id}.{thermostatSerial}_{zoneId}".
// Trane endpoints only want "{thermostatSerial}", so strip the app prefix
// before deciding whether this is a diagnostics thermostat.
def prefix = "${app.id}."
if(deviceNetworkId?.startsWith(prefix)) {
return deviceNetworkId.substring(prefix.length())
}
def dotIndex = deviceNetworkId?.indexOf(".")
if(dotIndex != null && dotIndex > 0 && deviceNetworkId.substring(0, dotIndex).isInteger()) {
return deviceNetworkId.substring(dotIndex + 1)
}
return deviceNetworkId
}
private boolean isDiagnosticChild(child) {
def rawDeviceId = getRawDeviceId(child.device.deviceNetworkId)
def thermId = rawDeviceId.split('_')[0].toString()
return state.diagnosticThermostats?.get(thermId) != null
}
private Map getDiagnosticChildParts(child) {
// After removing the Hubitat app prefix, the remaining ID is
// "{thermostatSerial}_{zoneId}".
def rawDeviceId = getRawDeviceId(child.device.deviceNetworkId)
def parts = rawDeviceId.split('_')
return [
thermostatId: parts[0],
zoneId: parts.size() > 1 ? parts[1] : "1"
]
}
private warnDiagnosticsWriteUnsupported(child, action) {
log.warn("${action} is not supported yet for Trane Home diagnostics thermostat ${child.device.displayName}. Need the newer Trane Home write endpoint.")
}
private updateDiagnosticThermostat(child, settingName, value, boolean retried = false) {
// The Trane Home climate page writes all supported controls through the
// same endpoint using form fields like faceplate_thermostat[heat_setpoint].
def parts = getDiagnosticChildParts(child)
def thermostat = state.diagnosticThermostats?.get(parts.thermostatId)
if(!thermostat) {
log.error("No diagnostics thermostat configured for ${parts.thermostatId}")
return
}
def requestParams = [
uri: serverUrl,
path: thermostat.updatePath,
requestContentType: "application/x-www-form-urlencoded",
headers: getDefaultHeaders(),
body: [
"faceplate_thermostat[${settingName}]": value,
"zone_id": parts.zoneId
]
]
try {
httpPut(requestParams) { resp ->
def unauthenticated = resp.status in [302, 401] || looksUnauthenticated(resp)
if(resp.status in [200, 204] && !unauthenticated) {
if(debugEnabled) log.debug("Diagnostics thermostat ${settingName} update succeeded")
} else if(unauthenticated && !retried) {
if(authenticateAndDiscoverRoutes()) updateDiagnosticThermostat(child, settingName, value, true)
} else {
log.error("Unexpected response while updating diagnostics thermostat ${settingName}: status ${resp.status}; content type ${responseContentType(resp) ?: 'unknown'}")
}
}
}
catch(e) {
logStageException("diagnostics thermostat update", e)
if(exceptionStatus(e) == 401 && !retried && authenticateAndDiscoverRoutes()) {
updateDiagnosticThermostat(child, settingName, value, true)
}
}
}
private String normalizeThermostatValue(value) {
return value?.toString()?.toLowerCase()
}
private Integer toIntegerValue(value) {
if(value == null || value == "") {
return null
}
return value.toString().toBigDecimal().toInteger()
}
private String mapDiagnosticsOperatingState(stat, zone) {
def stateValue = stat.operating_state ?: zone.status
def operatingStateMapping = [
"System Idle": "idle",
"Idle": "idle",
"Heating": "heating",
"Cooling": "cooling",
"Fan Running": "fan only",
"Fan On": "fan only",
"Fan Off": "idle"
]
return operatingStateMapping[stateValue] ?: normalizeThermostatValue(stateValue)
}
private pollDiagnosticChild(child) {
def rawDeviceId = getRawDeviceId(child.device.deviceNetworkId)
def thermId = rawDeviceId.split('_')[0]
def zoneId = rawDeviceId.split('_').size() > 1 ? rawDeviceId.split('_')[1] : null
def statData = [:]
requestDiagnosticThermostat(thermId) { stat ->
def zone = zoneId ? stat.zones.find { it.zone_id.toString() == zoneId.toString() } : stat.zones[0]
if(!zone) {
log.error("No diagnostics zone ${zoneId} found for thermostat ${thermId}")
return
}
// Convert the diagnostics JSON shape into the same attribute names the
// existing child driver already expects from parent.pollChild(this).
def thermostatMode = normalizeThermostatValue(zone.mode)
def heatingSetpoint = toIntegerValue(zone.heat_setpoint)
def coolingSetpoint = toIntegerValue(zone.cool_setpoint)
statData = [
temperature: toIntegerValue(zone.temperature),
heatingSetpoint: heatingSetpoint,
coolingSetpoint: coolingSetpoint,
minHeatingSetpoint: toIntegerValue(stat.min_heat_setpoint),
maxHeatingSetpoint: toIntegerValue(stat.max_heat_setpoint),
minCoolingSetpoint: toIntegerValue(stat.min_cool_setpoint),
maxCoolingSetpoint: toIntegerValue(stat.max_cool_setpoint),
thermostatSetpoint: (thermostatMode == "cool") ? coolingSetpoint : heatingSetpoint,
thermostatMode: thermostatMode,
thermostatFanMode: normalizeThermostatValue(stat.fan_mode),
thermostatOperatingState: mapDiagnosticsOperatingState(stat, zone),
systemStatus: stat.operating_state,
activeMode: thermostatMode,
emergencyHeatSupported: false,
compressorSpeed: toIntegerValue(stat.compressor_speed),
humidity: toIntegerValue(zone.humidity),
outdoorTemperature: toIntegerValue(stat.outdoor_temperature),
setpointStatus: zone.run_mode
]
}
return statData
}
// Poll Child is invoked from the Child Device itself as part of the Poll Capability
def pollChild(child) {
// Keep the original SmartThings/Nexia path for older devices, but use the
// diagnostics JSON endpoint when this child was created from a diagnostics URL.
def rawDeviceId = getRawDeviceId(child.device.deviceNetworkId)
def diagnosticThermId = rawDeviceId.split('_')[0]
if(state.diagnosticThermostats?.containsKey(diagnosticThermId)) {
return pollDiagnosticChild(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()
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"
]
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, boolean retried = false) {
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 ->
def unauthenticated = resp.status in [302, 401] || looksUnauthenticated(resp)
if(resp.status == 200 && !unauthenticated) {
if(debugEnabled) log.debug("Zone update succeeded")
} else if(unauthenticated && !retried) {
if(authenticateAndDiscoverRoutes()) updateZone(zone, updateType, true)
} else {
log.error("Unexpected response while attempting to update zone: status ${resp.status}; content type ${responseContentType(resp) ?: 'unknown'}")
/*
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) {
logStageException("zone update", e)
if(exceptionStatus(e) == 401 && !retried && authenticateAndDiscoverRoutes()) {
updateZone(zone, updateType, true)
}
}
}
// updateType can be: "fan_mode"
private updateThermostat(stat, updateType, boolean retried = false) {
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 ->
def unauthenticated = resp.status in [302, 401] || looksUnauthenticated(resp)
if(resp.status == 200 && !unauthenticated) {
if(debugEnabled) log.debug("Thermostat update succeeded")
} else if(unauthenticated && !retried) {
if(authenticateAndDiscoverRoutes()) updateThermostat(stat, updateType, true)
} else {
log.error("Unexpected response while attempting to update thermostat: status ${resp.status}; content type ${responseContentType(resp) ?: 'unknown'}")
}
}
} catch(e) {
logStageException("thermostat update", e)
if(exceptionStatus(e) == 401 && !retried && authenticateAndDiscoverRoutes()) {
updateThermostat(stat, updateType, true)
}
}
}
def setHeatingSetpoint(child, degreesF) {
if(isDiagnosticChild(child)) {
updateDiagnosticThermostat(child, "heat_setpoint", degreesF)
return
}
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) {
if(isDiagnosticChild(child)) {
updateDiagnosticThermostat(child, "cool_setpoint", degreesF)
return
}
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) {
if(isDiagnosticChild(child)) {
updateDiagnosticThermostat(child, "mode", value.toLowerCase())
return
}
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"
if(isDiagnosticChild(child)) {
def runMode = (value == "return_to_schedule") ? "schedule" : "hold"
updateDiagnosticThermostat(child, "run_mode", runMode)
return
}
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) {
if(isDiagnosticChild(child)) {
updateDiagnosticThermostat(child, "fan_mode", value.toLowerCase())
return
}
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"])
}