/**
* Based on original code Copyright 2015 SmartThings
* Additional changes Copyright 2016 Sean Kendall Schneyer
* Additional changes Copyright 2017 Barry A. Burke
*
* 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.
*
* Ecobee Service Manager
*
* Author: scott
* Date: 2013-08-07
*
* Last Modification:
* JLH - 01-23-2014 - Update for Correct SmartApp URL Format
* JLH - 02-15-2014 - Fuller use of ecobee API
* 10-28-2015 DVCSMP-604 - accessory sensor, DVCSMP-1174, DVCSMP-1111 - not respond to routines
* StrykerSKS - 12-11-2015 - Make it work (better) with the Ecobee 3
* BAB - 01-20-2017 - Massive overhaul for efficiency, performance, bug fixes and UI enhancements
*
* Updates by Barry A. Burke (storageanarchy@gmail.com)
*
* See Changelog for change history
* 1.0.0 - Final preparation for general release
* 1.0.1 - Added "Offline" status when ecobee Cloud loses connection with thermostat (power out, network down, etc.)
* 1.0.2 - Chasing another uninitialized variable issue
* 1.0.3 - Updates to thermostat->Ecobee Cloud connection handling
* 1.0.4 - Added Health Check support for Thermostat device
* 1.0.5 - Beginning of support for thermostats in different timeZones than SmartThings hub
* 1.0.6 - Fixed zipCode handling when thermostat does not have a postalCode
* 1.0.7 - Rework Climates handling for Hold: (Auto)
* 1.0.8 - Cleaned up handling of program Hold: events & commands
* 1.0.9 - Fixed tstat name reporting with debugLevel 5, typo in hourForcedUpdate state
* 1.0.10- Fixed resumeProgram resetting HVAC mode incorrectly
* 1.0.11- Create vacation template automatically if one doesn't exist (ecobee bug workaround for hold events)
* 1.0.12- Added new Smart Vents Helper SmartApp
* 1.0.13- Improved Health Check reliability
* 1.0.14- Logging optimizations
* 1.0.15- Added new Smart Switch/Dimmer/Vent Helper, updated Open Contacts Helper to also support Switches
* 1.1.1 - Preparations for Ecobee Alerts support and Ask Alexa integrations
*
*
*/
import groovy.json.JsonOutput
def getVersionNum() { return "1.1.1" }
private def getVersionLabel() { return "Ecobee (Connect) version ${getVersionNum()}" }
private def getHelperSmartApps() {
return [
[name: "ecobeeContactsChild", appName: "ecobee Open Contacts",
namespace: "smartthings", multiple: true,
title: "New Contacts & Switches Handler..."],
[name: "ecobeeRoutinesChild", appName: "ecobee Routines",
namespace: "smartthings", multiple: true,
title: "New Mode/Routine/Program Handler..."],
[name: "ecobeeCirculationChild", appName: "ecobee Smart Circulation",
namespace: "smartthings", multiple: true,
title: "New Smart Circulation Handler..."],
[name: "ecobeeRoomChild", appName: "ecobee Smart Room",
namespace: "smartthings", multiple: true,
title: "New Smart Room Handler..."],
[name: "ecobeeSwitchesChild", appName: "ecobee Smart Switches",
namespace: "smartthings", multiple: true,
title: "New Smart Switch/Dimmer/Vent Handler..."],
[name: "ecobeeVentsChild", appName: "ecobee Smart Vents",
namespace: "smartthings", multiple: true,
title: "New Smart Vents Handler..."],
[name: "ecobeeZonesChild", appName: "ecobee Smart Zones",
namespace: "smartthings", multiple: true,
title: "New Smart Zone Handler..."]
]
}
definition(
name: "Ecobee (Connect)",
namespace: "smartthings",
author: "Sean Kendall Schneyer",
description: "Connect your Ecobee thermostat to SmartThings.",
category: "My Apps",
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png",
iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png",
singleInstance: true
) {
appSetting "clientId"
}
preferences {
page(name: "mainPage")
page(name: "removePage")
page(name: "authPage")
page(name: "thermsPage")
page(name: "sensorsPage")
page(name: "preferencesPage")
page(name: "askAlexaPage")
page(name: "addWatchdogDevicesPage")
page(name: "helperSmartAppsPage")
// Part of debug Dashboard
page(name: "debugDashboardPage")
page(name: "pollChildrenPage")
page(name: "updatedPage")
page(name: "refreshAuthTokenPage")
}
mappings {
path("/oauth/initialize") {action: [GET: "oauthInitUrl"]}
path("/oauth/callback") {action: [GET: "callback"]}
}
// Begin Preference Pages
def mainPage() {
def deviceHandlersInstalled
def readyToInstall
// Request the Ask Alexa Message Queue list as early as possible (isn't a problem if Ask Alexa isn't installed)
subscribe(location, "askAlexaMQ", askAlexaMQHandler)
sendLocationEvent(name: "askAlexaMQRefresh", value: "refresh")
// Only create the dummy devices if we aren't initialized yet
if (!atomicState.initialized) {
deviceHandlersInstalled = testForDeviceHandlers()
readyToInstall = deviceHandlersInstalled
}
if (atomicState.initialized) { readyToInstall = true }
dynamicPage(name: "mainPage", title: "Welcome to ecobee (Connect)", install: readyToInstall, uninstall: false, submitOnChange: true) {
def ecoAuthDesc = (atomicState.authToken != null) ? "[Connected]\n" :"[Not Connected]\n"
// If not device Handlers we cannot proceed
if(!atomicState.initialized && !deviceHandlersInstalled) {
section() {
paragraph "ERROR!\n\nYou MUST add the ${getChildThermostatName()} and ${getChildSensorName()} Device Handlers to the IDE BEFORE running the setup."
}
} else {
readyToInstall = true
}
if(atomicState.initialized && !atomicState.authToken) {
section() {
paragraph "WARNING!\n\nYou are no longer connected to the ecobee API. Please re-Authorize below."
}
}
if(atomicState.authToken != null && atomicState.initialized != true) {
section() {
paragraph "Please click 'Done' to save your credentials. Then re-open the SmartApp to continue the setup."
}
}
// Need to save the initial login to setup the device without timeouts
if(atomicState.authToken != null && atomicState.initialized) {
if (settings.thermostats?.size() > 0 && atomicState.initialized) {
section("Helper SmartApps") {
href ("helperSmartAppsPage", title: "Helper SmartApps", description: "Tap to manage Helper SmartApps")
}
}
section("Devices") {
def howManyThermsSel = settings.thermostats?.size() ?: 0
def howManyTherms = atomicState.numAvailTherms ?: "?"
def howManySensors = atomicState.numAvailSensors ?: "?"
// Thermostats
atomicState.settingsCurrentTherms = settings.thermostats ?: []
href ("thermsPage", title: "Thermostats", description: "Tap to select Thermostats [${howManyThermsSel}/${howManyTherms}]")
// Sensors
if (settings.thermostats?.size() > 0) {
atomicState.settingsCurrentSensors = settings.ecobeesensors ?: []
def howManySensorsSel = settings.ecobeesensors?.size() ?: 0
if (howManySensorsSel > howManySensors) { howManySensorsSel = howManySensors } // This is due to the fact that you can remove alread selected hiden items
href ("sensorsPage", title: "Sensors", description: "Tap to select Sensors [${howManySensorsSel}/${howManySensors}]")
}
}
section("Preferences") {
href ("preferencesPage", title: "Preferences", description: "Tap to review SmartApp settings.")
LOG("In Preferences page section after preferences line", 5, null, "trace")
href ("askAlexaPage", title: "Ask Alexa", description: "Tap to review Ask Alexa integration.")
}
if( settings.useWatchdogDevices == true ) {
section("Extra Poll and Watchdog Devices") {
href ("addWatchdogDevicesPage", title: "Watchdog Devices", description: "Tap to select Poll and Watchdog Devices.")
}
}
} // End if(atomicState.authToken)
// Setup our API Tokens
section("Ecobee Authentication") {
href ("authPage", title: "ecobee Authorization", description: "${ecoAuthDesc}Tap for ecobee Credentials")
}
if ( debugLevel(5) ) {
section ("Debug Dashboard") {
href ("debugDashboardPage", description: "Tap to enter the Debug Dashboard", title: "Debug Dashboard")
}
}
section("Remove ecobee (Connect)") {
href ("removePage", description: "Tap to remove ecobee (Connect) ", title: "Remove ecobee (Connect)")
}
section ("Name this instance of ecobee (Connect)") {
label name: "name", title: "Assign a name", required: false, defaultValue: app.name, description: app.name
}
section (getVersionLabel())
}
}
def removePage() {
dynamicPage(name: "removePage", title: "Remove ecobee (Connect) and All Devices", install: false, uninstall: true) {
section ("WARNING!\n\nRemoving ecobee (Connect) also removes all Devices\n") {
}
}
}
// Setup OAuth between SmartThings and Ecobee clouds
def authPage() {
LOG("=====> authPage() Entered", 5)
if(!atomicState.accessToken) { //this is an access token for the 3rd party to make a call to the connect app
atomicState.accessToken = createAccessToken()
}
def description = "Click to enter ecobee Credentials"
def uninstallAllowed = false
def oauthTokenProvided = false
if(atomicState.authToken) {
description = "You are connected. Tap Done/Next above."
uninstallAllowed = true
oauthTokenProvided = true
apiRestored()
} else {
description = "Tap to enter ecobee Credentials"
}
def redirectUrl = buildRedirectUrl //"${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}"
LOG("authPage() --> RedirectUrl = ${redirectUrl}")
// get rid of next button until the user is actually auth'd
if (!oauthTokenProvided) {
LOG("authPage() --> in !oauthTokenProvided")
return dynamicPage(name: "authPage", title: "ecobee Setup", nextPage: "", uninstall: uninstallAllowed) {
section() {
paragraph "Tap below to log in to the ecobee service and authorize SmartThings access. Be sure to press the 'Allow' button on the 2nd page."
href url:redirectUrl, style:"embedded", required:true, title: "ecobee Account Authorization", description:description
}
}
} else {
LOG("authPage() --> in else for oauthTokenProvided - ${atomicState.authToken}.")
return dynamicPage(name: "authPage", title: "ecobee Setup", nextPage: "mainPage", uninstall: uninstallAllowed) {
section() {
paragraph "Return to main menu."
href url:redirectUrl, style: "embedded", state: "complete", title: "ecobee Account Authorization", description: description
}
}
}
}
// Select which Thermostats are to be used
def thermsPage(params) {
LOG("=====> thermsPage() entered", 5)
def stats = getEcobeeThermostats()
LOG("thermsPage() -> thermostat list: ${stats}")
LOG("thermsPage() starting settings: ${settings}")
LOG("thermsPage() params passed? ${params}", 4, null, "trace")
dynamicPage(name: "thermsPage", title: "Select Thermostats", params: params, nextPage: "", content: "thermsPage", uninstall: false) {
section("Units") {
paragraph "NOTE: The units type (F or C) is determined by your Hub Location settings automatically. Please update your Hub settings (under My Locations) to change the units used. Current value is ${getTemperatureScale()}."
}
section("Select Thermostats") {
LOG("thermsPage(): atomicState.settingsCurrentTherms=${atomicState.settingsCurrentTherms} settings.thermostats=${settings.thermostats}", 4, null, "trace")
if (atomicState.settingsCurrentTherms != settings.thermostats) {
LOG("atomicState.settingsCurrentTherms != settings.thermostats determined!!!", 4, null, "trace")
} else { LOG("atomicState.settingsCurrentTherms == settings.thermostats: No changes detected!", 4, null, "trace") }
paragraph "Tap below to see the list of ecobee thermostats available in your ecobee account and select the ones you want to connect to SmartThings."
input(name: "thermostats", title:"Select Thermostats", type: "enum", required:false, multiple:true, description: "Tap to choose", params: params, metadata:[values:stats], submitOnChange: true)
}
}
}
def sensorsPage() {
// Only show sensors that are part of the chosen thermostat(s)
// Refactor to show the sensors under their corresponding Thermostats. Use Thermostat name as section header?
LOG("=====> sensorsPage() entered. settings: ${settings}", 5)
atomicState.sensorsPageVisited = true
def options = getEcobeeSensors() ?: []
def numFound = options.size() ?: 0
LOG("options = getEcobeeSensors == ${options}")
dynamicPage(name: "sensorsPage", title: "Select Sensors", nextPage: "") {
if (numFound > 0) {
section("Select Sensors"){
LOG("sensorsPage(): atomicState.settingsCurrentSensors=${atomicState.settingsCurrentSensors} settings.ecobeesensors=${settings.ecobeesensors}", 4, null, "trace")
if (atomicState.settingsCurrentSensors != settings.ecobeesensors) {
LOG("atomicState.settingsCurrentSensors != settings.ecobeesensors determined!!!", 4, null, "trace")
} else { LOG("atomicState.settingsCurrentSensors == settings.ecobeesensors: No changes detected!", 4, null, "trace") }
paragraph "Tap below to see the list of ecobee sensors available for the selected thermostat(s) and select the ones you want to connect to SmartThings."
if (settings.showThermsAsSensor) { paragraph "NOTE: Also showing Thermostats as an available sensor to allow for actual temperature values to be used." }
input(name: "ecobeesensors", title:"Select Ecobee Sensors (${numFound} found)", type: "enum", required:false, description: "Tap to choose", multiple:true, metadata:[values:options])
}
} else {
// No sensors associated with this set of Thermostats was found
LOG("sensorsPage(): No sensors found.", 4)
section(""){
paragraph "No associated sensors were found. Click Done above."
}
}
}
}
def askAlexaPage() {
dynamicPage(name: "askAlexaPage", title: "Ask Alexa Integration", nextPage: "") {
section('') {
if (atomicState.askAlexaMQ == null) {
paragraph(image: 'https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/smartapps/michaelstruck/ask-alexa.src/AskAlexa@2x.png',
title: 'Ask Alexa not Detected!',
'Ask Alexa either isn\'t installed, or no Message Queues are defined (sending to the deprecated Primary Message Queue is not supported).\n\n' +
'Please verify your Ask Alexa settings and then return here to complete the integration.')
} else {
paragraph(image: 'https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/smartapps/michaelstruck/ask-alexa.src/AskAlexa@2x.png',
title: 'Ask Alexa Integration', '')
input(name: 'askAlexa', type: 'bool', title: 'Send Ecobee Alerts to Ask Alexa?', required: true, submitOnChange: true, defaultValue: false)
if (settings.askAlexa) {
if (!settings.listOfMQs || (settings.listOfMQs?.size() == 0)) {
paragraph('Please select one or more Ask Alexa Message Queues below (sending to the deprecated Primary Message Queue is not supported):')
}
input(name: 'listOfMQs', type: 'enum', title: 'Send Alerts to these Ask Alexa Message Queues', options: atomicState.askAlexaMQ, submitOnChange: true,
multiple: true, required: true)
input(name: 'expire', type: 'number', title: 'Expire Alerts after how many hours (optional)?', submitOnChange: true, required: false, range: "1..*")
input(name: 'collapseAAA', type: 'bool', title: 'Collapse identical Alerts from multiple thermostats?', defaultValue: false, submitOnChange: true)
}
}
}
}
}
def preferencesPage() {
LOG("=====> preferencesPage() entered. settings: ${settings}", 5)
dynamicPage(name: "preferencesPage", title: "Update SmartApp Preferences", nextPage: "") {
section("SmartApp Preferences") {
input(name: 'recipients', title: 'Send push notifications to', type: 'contact', required: false, multiple: true)
input(name: "holdType", title:"Select Hold Type", type: "enum", required:false, multiple:false, defaultValue: "Until I Change", description: "Until I Change", metadata:[values:["Until I Change", "Until Next Program"]])
paragraph "The 'Smart Auto Temperature Adjust' feature determines if you want to allow the thermostat setpoint to be changed using the arrow buttons in the Tile when the thermostat is in 'auto' mode."
input(name: "smartAuto", title:"Use Smart Auto Temperature Adjust?", type: "bool", required:false, defaultValue: false, description: "")
input(name: "pollingInterval", title:"Polling Interval (in Minutes)", type: "enum", required:false, multiple:false, defaultValue:5, description: "5", options:["1", "2", "3", "5", "10", "15", "30"])
input(name: "debugLevel", title:"Debugging Level (higher # for more information)", type: "enum", required:false, multiple:false, defaultValue:3, description: "3", options:["5", "4", "3", "2", "1", "0"])
paragraph "Showing a Thermostat as a separate Sensor is useful if you need to access the actual temperature in the room where the Thermostat is located and not just the (average) temperature displayed on the Thermostat"
input(name: "showThermsAsSensor", title:"Include Thermostats as a separate Ecobee Sensor?", type: "bool", required:false, defaultValue: false, description: "")
paragraph "Monitoring external devices can be used to drive polling and the watchdog events. Be warned, however, not to select too many devices or devices that will send too many events as this can cause issues with the connection."
input(name: "useWatchdogDevices", title:"Monitor external devices to drive additional polling and watchdog events?", type: "bool", required:false, description: "", defaultValue:false)
paragraph "Set the pause between pressing the setpoint arrows and initiating the API calls. The pause needs to be long enough to allow you to click the arrow again for changing by more than one degree."
input(name: "arrowPause", title:"Delay timer value after pressing setpoint arrows", type: "enum", required:false, multiple:false, description: "4", defaultValue:5, options:["1", "2", "3", "4", "5"])
paragraph "Set the desired number of decimal places to display for all temperatures (recommended 1 for C, 0 for F)."
String digits = wantMetric() ? "1" : "0"
input(name: "tempDecimals", title:"Decimal places to display", type: "enum", required:true, multiple:false, defaultValue:digits, description: digits, options:["0", "1", "2"], submitOnChange: true)
}
}
}
def addWatchdogDevicesPage() {
LOG("Displaying the Watchdog Device Selection page next...", 5, null, "trace")
dynamicPage(name: "addWatchdogDevicesPage", title: "Select Watchdog Devices", nextPage: "") {
section("Polling and Watchdog Devices") {
paragraph ("Select device(s) that you wish to subscribe to in order to create additional polling events and trigger the watchdog timers. " +
"Do NOT select too many devices or devices that will cause excess polling. " +
"Ecobee only updates their data every 3 minutes so any polling interval greater than that is unnecessary.")
input(name: "watchdogMotion", type:"capability.motionSensor", title: "Select Motion Sensor(s)", required:false, multiple:true)
input(name: "watchdogTemp", type:"capability.temperatureMeasurement", title: "Select Temperature Measurement Device(s)", required:false, multiple:true)
input(name: "watchdogSwitch", type:"capability.switch", title: "Select Switch(es)", required:false, multiple:true)
input(name: "watchdogBattery", type:"capability.battery", title: "Select Battery(ies)", required:false, multiple:true)
input(name: "watchdogHumidity", type:"capability.relativeHumidityMeasurement", title: "Select Humidity Sensor(s)", required:false, multiple:true)
input(name: "watchdogLuminance", type:"capability.illuminanceMeasurement", title: "Select Illuminance Sensor(s)", required:false, multiple:true)
}
}
}
def debugDashboardPage() {
LOG("=====> debugDashboardPage() entered.", 5)
dynamicPage(name: "debugDashboardPage", title: "") {
section (getVersionLabel())
section("Commands") {
href(name: "pollChildrenPage", title: "", required: false, page: "pollChildrenPage", description: "Tap to execute: pollChildren()")
href(name: "refreshAuthTokenPage", title: "", required: false, page: "refreshAuthTokenPage", description: "Tap to execute: refreshAuthToken()")
href(name: "updatedPage", title: "", required: false, page: "updatedPage", description: "Tap to execute: updated()")
}
section("Settings Information") {
paragraph "debugLevel: ${getDebugLevel()}"
paragraph "holdType: ${getHoldType()}"
paragraph "pollingInterval: ${getPollingInterval()}"
paragraph "showThermsAsSensor: ${settings.showThermsAsSensor} (default=false if null)"
paragraph "smartAuto: ${settings.smartAuto} (default=false if null)"
paragraph "Selected Thermostats: ${settings.thermostats}"
paragraph "Decimal Precision: ${getTempDecimals()}"
}
section("Dump of Debug Variables") {
def debugParamList = getDebugDump()
LOG("debugParamList: ${debugParamList}", 4, null, "debug")
//if ( debugParamList?.size() > 0 ) {
if ( debugParamList != null ) {
debugParamList.each { key, value ->
LOG("Adding paragraph: key:${key} value:${value}", 5, null, "trace")
paragraph "${key}: ${value}"
}
}
}
section("Commands") {
href(name: "pollChildrenPage", title: "", required: false, page: "pollChildrenPage", description: "Tap to execute command: pollChildren()")
href ("removePage", description: "Tap to remove ecobee (Connect) ", title: "")
}
}
}
// pages that are part of Debug Dashboard
def pollChildrenPage() {
LOG("=====> pollChildrenPage() entered.", 5)
atomicState.forcePoll = true // Reset to force the poll to happen
pollChildren()
dynamicPage(name: "pollChildrenPage", title: "") {
section() {
paragraph "pollChildren() was called"
}
}
}
// pages that are part of Debug Dashboard
def updatedPage() {
LOG("=====> updatedPage() entered.", 5)
updated()
dynamicPage(name: "updatedPage", title: "") {
section() {
paragraph "updated() was called"
}
}
}
def refreshAuthTokenPage() {
LOG("=====> refreshAuthTokenPage() entered.", 5)
refreshAuthToken()
dynamicPage(name: "refreshAuthTokenPage", title: "") {
section() {
paragraph "refreshAuthTokenPage() was called"
}
}
}
def helperSmartAppsPage() {
LOG("helperSmartAppsPage() entered", 5)
LOG("SmartApps available are ${getHelperSmartApps()}", 5, null, "info")
//getHelperSmartApps() {
dynamicPage(name: "helperSmartAppsPage", title: "Helper Smart Apps", install: true, uninstall: false, submitOnChange: true) {
section("Available Helper Smart Apps") {
getHelperSmartApps().each { oneApp ->
LOG("Processing the app: ${oneApp}", 4, null, "trace")
def allowMultiple = oneApp.multiple.value
//section ("${oneApp.appName.value}") {
app(name:"${oneApp.name.value}", appName:"${oneApp.appName.value}", namespace:"${oneApp.namespace.value}", title:"${oneApp.title.value}", multiple: allowMultiple)
//}
}
}
}
}
// End Preference Pages
// Preference Pages Helpers
private def Boolean testForDeviceHandlers() {
if (atomicState.runTestOnce != null) { return atomicState.runTestOnce }
def DNIAdder = now().toString()
def d1
def d2
def success = true
try {
d1 = addChildDevice(app.namespace, getChildThermostatName(), "dummyThermDNI-${DNIAdder}", null, ["label":"Ecobee Thermostat:TestingForInstall", completedSetup:true])
d2 = addChildDevice(app.namespace, getChildSensorName(), "dummySensorDNI-${DNIAdder}", null, ["label":"Ecobee Sensor:TestingForInstall", completedSetup:true])
} catch (physicalgraph.app.exception.UnknownDeviceTypeException e) {
LOG("You MUST add the ${getChildThermostatName()} and ${getChildSensorName()} Device Handlers to the IDE BEFORE running the setup.", 1, null, "error")
success = false
}
atomicState.runTestOnce = success
if (d1) deleteChildDevice("dummyThermDNI-${DNIAdder}")
if (d2) deleteChildDevice("dummySensorDNI-${DNIAdder}")
return success
}
// End Preference Pages Helpers
// Ask Alexa Helpers
private String getMQListNames() {
if (!settings.listOfMQs || (settings.listOfMQs?.size() == 0)) return ''
def temp = []
settings.listOfMQs?.each {
temp << atomicState.askAlexaMQ?.getAt(it).value
}
return ((temp.size() > 1) ? ('(' + temp.join(", ") + ')') : (temp.toString()))?.replaceAll("\\[",'').replaceAll("\\]",'')
}
def askAlexaMQHandler(evt) {
LOG("askAlexaMQHandler ${evt?.name} ${evt?.value}",4,null,'trace')
if (!evt) return
switch (evt.value) {
case "refresh":
atomicState.askAlexaMQ = evt.jsonData && evt.jsonData?.queues ? evt.jsonData.queues : []
break
}
}
// End of Ask Alexa Helpers
// OAuth Init URL
def oauthInitUrl() {
LOG("oauthInitUrl with callback: ${callbackUrl}", 5)
atomicState.oauthInitState = UUID.randomUUID().toString()
def oauthParams = [
response_type: "code",
client_id: smartThingsClientId,
scope: "smartRead,smartWrite",
redirect_uri: callbackUrl, //"https://graph.api.smartthings.com/oauth/callback"
state: atomicState.oauthInitState
]
LOG("oauthInitUrl - Before redirect: location: ${apiEndpoint}/authorize?${toQueryString(oauthParams)}", 4)
redirect(location: "${apiEndpoint}/authorize?${toQueryString(oauthParams)}")
}
// OAuth Callback URL and helpers
def callback() {
LOG("callback()>> params: $params, params.code ${params.code}, params.state ${params.state}, atomicState.oauthInitState ${atomicState.oauthInitState}", 4)
def code = params.code
def oauthState = params.state
//verify oauthState == atomicState.oauthInitState, so the callback corresponds to the authentication request
if (oauthState == atomicState.oauthInitState){
LOG("callback() --> States matched!", 4)
def tokenParams = [
grant_type: "authorization_code",
code : code,
client_id : smartThingsClientId,
state : oauthState,
redirect_uri: callbackUrl //"https://graph.api.smartthings.com/oauth/callback"
]
def tokenUrl = "${apiEndpoint}/token?${toQueryString(tokenParams)}"
LOG("callback()-->tokenURL: ${tokenUrl}", 2)
httpPost(uri: tokenUrl) { resp ->
atomicState.refreshToken = resp.data.refresh_token
atomicState.authToken = resp.data.access_token
LOG("Expires in ${resp?.data?.expires_in} seconds")
atomicState.authTokenExpires = now() + (resp.data.expires_in * 1000)
LOG("swapped token: $resp.data; atomicState.refreshToken: ${atomicState.refreshToken}; atomicState.authToken: ${atomicState.authToken}", 2)
}
if (atomicState.authToken) { success() } else { fail() }
} else {
LOG("callback() failed oauthState != atomicState.oauthInitState", 1, null, "warn")
}
}
def success() {
def message = """
Your ecobee Account is now connected to SmartThings!
Click 'Done' to finish setup.
"""
connectionStatus(message)
}
def fail() {
def message = """
The connection could not be established!
Click 'Done' to return to the menu.
"""
connectionStatus(message)
}
def connectionStatus(message, redirectUrl = null) {
def redirectHtml = ""
if (redirectUrl) {
redirectHtml = """
"""
}
def html = """
Ecobee & SmartThings connection
"""
render contentType: 'text/html', data: html
}
// End OAuth Callback URL and helpers
// Get the list of Ecobee Thermostats for use in the settings pages
def getEcobeeThermostats() {
LOG("====> getEcobeeThermostats() entered", 5)
def requestBody = '{"selection":{"selectionType":"registered","selectionMatch":"","includeRuntime":true,"includeSensors":true,"includeLocation":true,"includeProgram":true}}'
def deviceListParams = [
uri: apiEndpoint,
path: "/1/thermostat",
headers: ["Content-Type": "application/json", "Authorization": "Bearer ${state.authToken}"],
query: [format: 'json', body: requestBody]
]
def stats = [:]
def statLocation = [:]
try {
httpGet(deviceListParams) { resp ->
LOG("getEcobeeThermostats() - httpGet() response: ${resp.data}", 4)
// Initialize the Thermostat Data. Will reuse for the Sensor List intialization
atomicState.thermostatData = resp.data
if (resp.status == 200) {
LOG("getEcobeeThermostats() - httpGet() in 200 Response", 3)
atomicState.numAvailTherms = resp.data.thermostatList?.size() ?: 0
resp.data.thermostatList.each { stat ->
def dni = [app.id, stat.identifier].join('.')
stats[dni] = getThermostatDisplayName(stat)
statLocation[stat.identifier] = stat.location
}
} else {
LOG("getEcobeeThermostats() - httpGet() in else: http status: ${resp.status}", 1)
//refresh the auth token
if (resp.status == 500 && resp.data.status.code == 14) {
LOG("getEcobeeThermostats() - Storing the failed action to try later", 1)
atomicState.action = "getEcobeeThermostats"
LOG("getEcobeeThermostats() - Refreshing your auth_token!", 1)
refreshAuthToken()
} else {
LOG("getEcobeeThermostats() - Other error. Status: ${resp.status} Response data: ${resp.data} ", 1)
}
}
}
} catch(Exception e) {
LOG("___exception getEcobeeThermostats(): ${e}", 1, null, "error")
refreshAuthToken()
}
atomicState.thermostatsWithNames = stats
atomicState.statLocation = statLocation
LOG("getEcobeeThermostats() - thermostatsWithNames: ${stats}, locations: ${statLocation}", 4, null, 'trace')
return stats
}
// Get the list of Ecobee Sensors for use in the settings pages (Only include the sensors that are tied to a thermostat that was selected)
// NOTE: getEcobeeThermostats() should be called prior to getEcobeeSensors to refresh the full data of all thermostats
Map getEcobeeSensors() {
LOG("====> getEcobeeSensors() entered. thermostats: ${thermostats}", 5)
def sensorMap = [:]
def foundThermo = null
// TODO: Is this needed?
atomicState.remoteSensors = [:]
// Now that we routinely only collect the data that has changed in atomicState.thermostatData, we need to ALWAYS refresh that data
// here so that we are sure we have everything we need here.
getEcobeeThermostats()
atomicState.thermostatData.thermostatList.each { singleStat ->
def tid = singleStat.identifier
LOG("thermostat loop: singleStat.identifier == ${tid} -- singleStat.remoteSensors == ${singleStat.remoteSensors} ", 4)
def tempSensors = atomicState.remoteSensors
if (!settings.thermostats.findAll{ it.contains(tid) } ) {
// We can skip this thermostat as it was not selected by the user
LOG("getEcobeeSensors() --> Skipping this thermostat: ${tid}", 5)
} else {
LOG("getEcobeeSensors() --> Entering the else... we found a match. singleStat == ${singleStat.name}", 4)
// atomicState.remoteSensors[tid] = atomicState.remoteSensors[tid] ? (atomicState.remoteSensors[tid] + singleStat.remoteSensors) : singleStat.remoteSensors
tempSensors[tid] = tempSensors[tid] ? tempSensors[tid] + singleStat.remoteSensors : singleStat.remoteSensors
LOG("After atomicState.remoteSensors setup...", 5)
LOG("getEcobeeSensors() - singleStat.remoteSensors: ${singleStat.remoteSensors}", 4)
LOG("getEcobeeSensors() - atomicState.remoteSensors: ${atomicState.remoteSensors}", 4)
}
atomicState.remoteSensors = tempSensors
// WORKAROUND: Iterate over remoteSensors list and add in the thermostat DNI
// This is needed to work around the dynamic enum "bug" which prevents proper deletion
LOG("remoteSensors all before each loop: ${atomicState.remoteSensors}", 5, null, "trace")
atomicState.remoteSensors[tid].each {
LOG("Looping through each remoteSensor. Current remoteSensor: ${it}", 5, null, "trace")
if (it.type == "ecobee3_remote_sensor") {
LOG("Adding an ecobee3_remote_sensor: ${it}", 4, null, "trace")
def value = "${it?.name}"
def key = "ecobee_sensor-"+ it?.id + "-" + it?.code
sensorMap["${key}"] = value
} else if ( (it.type == "thermostat") && (settings.showThermsAsSensor == true) ) {
LOG("Adding a Thermostat as a Sensor: ${it}", 4, null, "trace")
def value = "${it?.name}"
def key = "ecobee_sensor_thermostat-"+ it?.id + "-" + it?.name
LOG("Adding a Thermostat as a Sensor: ${it}, key: ${key} value: ${value}", 4, null, "trace")
sensorMap["${key}"] = value + " (Thermostat)"
} else if ( it.type == "control_sensor" && it.capability[0]?.type == "temperature") {
// We can add this one as it supports temperature
LOG("Adding a control_sensor: ${it}", 4, null, "trace")
def value = "${it?.name}"
def key = "control_sensor-"+ it?.id
sensorMap["${key}"] = value
} else {
LOG("Did NOT add: ${it}. settings.showThermsAsSensor=${settings.showThermsAsSensor}", 4, null, "trace")
}
}
} // end thermostats.each loop
LOG("getEcobeeSensors() - remote sensor list: ${sensorMap}", 4)
atomicState.eligibleSensors = sensorMap
atomicState.numAvailSensors = sensorMap.size() ?: 0
return sensorMap
}
def getThermostatDisplayName(stat) {
if(stat?.name)
return stat.name.toString()
return (getThermostatTypeName(stat) + " (${stat.identifier})").toString()
}
def getThermostatTypeName(stat) {
return stat.modelNumber == "siSmart" ? "Smart Si" : "Smart"
}
def installed() {
LOG("Installed with settings: ${settings}", 4)
initialize()
}
def updated() {
LOG("Updated with settings: ${settings}", 4)
// atomicState.Events = [:]
// atomicState.Eventss = [:]
// atomicState.aTestMap = [:]
if (!atomicState.atomicMigrate) {
LOG("updated() - Migrating state to atomicState...", 2, null, "warn")
try {
state.collect {
LOG("traversing state: ${it} name: ${it.key} value: ${it.value}")
atomicState."${it.key}" = it.value
}
atomicState.atomicMigrate = true
} catch (Exception e) {
LOG("updated() - Migration of state t- atomicState failed with exception (${e})", 1, null, "error")
}
try {
LOG("atomicState after migration", 4)
atomicState.collect {
LOG("Traversing atomicState: ${it} name: ${it.key} value: ${it.value}")
}
} catch (Exception e) {
LOG("Unable to traverse atomicState", 2, null, "warn")
}
atomicState.atomicMigrate = true
}
initialize()
}
def initialize() {
LOG("=====> initialize()", 4)
atomicState.connected = "full"
atomicState.reAttempt = 0
atomicState.reAttemptPoll = 0
try {
unsubscribe()
unschedule() // reset all the schedules
} catch (Exception e) {
LOG("updated() - Exception encountered trying to unschedule(). Exception: ${e}", 2, null, "error")
}
subscribe(app, appHandler)
subscribe(location, "askAlexaMQ", askAlexaMQHandler)
// if (!settings.askAlexa) atomicState.askAlexaAlerts = null
def nowTime = now()
def nowDate = getTimestamp()
// Initialize several variables
atomicState.lastScheduledPoll = nowTime
atomicState.lastScheduledPollDate = nowDate
atomicState.lastScheduledWatchdog = nowTime
atomicState.lastScheduledWatchdogDate = nowDate
atomicState.lastPoll = nowTime
atomicState.lastPollDate = nowDate
atomicState.lastWatchdog = nowTime
atomicState.lastWatchdogDate = nowDate
atomicState.lastUserDefinedEvent = now()
atomicState.lastUserDefinedEventDate = getTimestamp()
atomicState.lastRevisions = [:]
atomicState.latestRevisions = [:]
atomicState.skipCount = 0
atomicState.getWeather = true
atomicState.runtimeUpdated = true
atomicState.thermostatUpdated = true
atomicState.sendJsonRetry = false
atomicState.forcePoll = true // make sure we get ALL the data after initialization
atomicState.hourlyForcedUpdate = 0
atomicState.needExtendedRuntime = true // we'll stop getting it once we decide we don't need it
getTimeZone() // these will set/refresh atomicState.timeZone
getZipCode() // and atomicState.zipCode (because atomicState.forcePoll is true)
// get sunrise/sunset for the location of the thermostats (prefers thermostat.location.postalCode)
def sunriseAndSunset = (atomicState.zipCode != null) ? getSunriseAndSunset(zipCode: atomicState.zipCode) : getSunRiseAndSunset()
LOG("sunriseAndSunset == ${sunriseAndSunset}")
if(atomicState.timeZone) {
atomicState.sunriseTime = sunriseAndSunset.sunrise.format("HHmm", TimeZone.getTimeZone(atomicState.timeZone)).toInteger()
atomicState.sunsetTime = sunriseAndSunset.sunset.format("HHmm", TimeZone.getTimeZone(atomicState.timeZone)).toInteger()
} else if( (sunriseAndSunset != [:]) && (location != null) ) {
atomicState.sunriseTime = sunriseAndSunset.sunrise.format("HHmm").toInteger()
atomicState.sunsetTime = sunriseAndSunset.sunset.format("HHmm").toInteger()
} else {
atomicState.sunriseTime = "0500".toInteger()
atomicState.sunsetTime = "1800".toInteger()
}
// Must do this AFTER setting up sunrise/sunset
atomicState.timeOfDay = getTimeOfDay()
// Setup initial polling and determine polling intervals
atomicState.pollingInterval = getPollingInterval()
atomicState.watchdogInterval = 15 // In minutes: 14/28/42/56<- scheduleWatchdog should refresh tokens with 4 minutes to spare
atomicState.reAttemptInterval = 15 // In seconds
if (state.initialized) {
// refresh Thermostats and Sensor full lists
getEcobeeThermostats()
getEcobeeSensors()
}
// Children
def aOK = true
if (settings.thermostats?.size() > 0) { aOK = aOK && createChildrenThermostats() }
if (settings.ecobeesensors?.size() > 0) { aOK = aOK && createChildrenSensors() }
deleteUnusedChildren()
// Initial poll()
if (settings.thermostats?.size() > 0) { pollInit() }
// Add subscriptions as little "daemons" that will check on our health
subscribe(location, scheduleWatchdog)
subscribe(location, "routineExecuted", scheduleWatchdog)
subscribe(location, "sunset", sunsetEvent)
subscribe(location, "sunrise", sunriseEvent)
subscribe(location, "position", scheduleWatchdog)
if ( settings.useWatchdogDevices == true ) {
if ( settings.watchdogBattery?.size() > 0) { subscribe(settings.watchdogBattery, "battery", userDefinedEvent) }
if ( settings.watchdogHumidity?.size() > 0) { subscribe(settings.watchdogHumidity, "humidity", userDefinedEvent) }
if ( settings.watchdogLuminance?.size() > 0) { subscribe(settings.watchdogLuminance, "illuminance", userDefinedEvent) }
if ( settings.watchdogMotion?.size() > 0) { subscribe(settings.watchdogMotion, "motion", userDefinedEvent) }
if ( settings.watchdogSwitch?.size() > 0) { subscribe(settings.watchdogSwitch, "switch", userDefinedEvent) }
if ( settings.watchdogTemp?.size() > 0) { subscribe(settings.watchdogTemp, "temperature", userDefinedEvent) }
}
// Schedule the various handlers
LOG("Spawning scheduled events from initialize()", 5, null, "trace")
if (settings.thermostats?.size() > 0) {
LOG("Spawning the poll scheduled event. (settings.thermostats?.size() - ${settings.thermostats?.size()})", 4)
spawnDaemon("poll", false)
}
spawnDaemon("watchdog", false)
//send activity feeds to tell that device is connected
def notificationMessage = aOK ? "is connected to SmartThings" : "had an error during setup of devices"
sendActivityFeeds(notificationMessage)
atomicState.timeSendPush = null
if (!atomicState.initialized) {
atomicState.initialized = true
// These two below are for debugging and statistics purposes
atomicState.initializedEpic = nowTime
atomicState.initializedDate = nowDate
}
log.debug getVersionLabel()
return aOK
}
private def createChildrenThermostats() {
LOG("createChildrenThermostats() entered: thermostats=${settings.thermostats}", 5)
// Create the child Thermostat Devices
def devices = settings.thermostats.collect { dni ->
def d = getChildDevice(dni)
if(!d) {
try {
d = addChildDevice(app.namespace, getChildThermostatName(), dni, null, ["label":"EcoTherm: ${atomicState.thermostatsWithNames[dni]}", completedSetup:true])
} catch (physicalgraph.app.exception.UnknownDeviceTypeException e) {
LOG("You MUST add the ${getChildSensorName()} Device Handler to the IDE BEFORE running the setup.", 1, null, "error")
return false
}
LOG("created ${d.displayName} with id ${dni}", 4)
} else {
LOG("found ${d.displayName} with id ${dni} already exists", 4)
}
return d
}
LOG("Created/Updated ${devices.size()} thermostats")
return true
}
private def createChildrenSensors() {
LOG("createChildrenSensors() entered: ecobeesensors=${settings.ecobeesensors}", 5)
// Create the child Ecobee Sensor Devices
def sensors = settings.ecobeesensors.collect { dni ->
def d = getChildDevice(dni)
if(!d) {
try {
d = addChildDevice(app.namespace, getChildSensorName(), dni, null, ["label":"EcoSensor: ${atomicState.eligibleSensors[dni]}", completedSetup:true])
} catch (physicalgraph.app.exception.UnknownDeviceTypeException e) {
LOG("You MUST add the ${getChildSensorName()} Device Handler to the IDE BEFORE running the setup.", 1, null, "error")
return false
}
LOG("created ${d.displayName} with id $dni", 4)
} else {
LOG("found ${d.displayName} with id $dni already exists", 4)
}
return d
}
LOG("Created/Updated ${sensors.size()} sensors.")
return true
}
// somebody pushed my button - do a force poll
def appHandler(evt) {
if (evt.value == 'touch') {
LOG('appHandler(touch) event, forced poll',2,null,'info')
atomicState.forcePoll = true
pollChildren()
}
}
// NOTE: For this to work correctly getEcobeeThermostats() and getEcobeeSensors() should be called prior
private def deleteUnusedChildren() {
LOG("deleteUnusedChildren() entered", 5)
if (settings.thermostats?.size() == 0) {
// No thermostats, need to delete all children
LOG("Deleting All My Children!", 2, null, "warn")
getAllChildDevices().each { deleteChildDevice(it.deviceNetworkId) }
} else {
// Only delete those that are no longer in the list
// This should be a combination of any removed thermostats and any removed sensors
def allMyChildren = getAllChildDevices()
LOG("These are currently all of my childred: ${allMyChildren}", 5, null, "debug")
// Update list of "eligibleSensors"
def childrenToKeep = (thermostats ?: []) + (atomicState.eligibleSensors?.keySet() ?: [])
LOG("These are the children to keep around: ${childrenToKeep}", 4, null, "trace")
def childrenToDelete = allMyChildren.findAll { !childrenToKeep.contains(it.deviceNetworkId) }
LOG("Ready to delete these devices. ${childrenToDelete}", 4, null, "trace")
if (childrenToDelete.size() > 0) childrenToDelete?.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management)
}
}
def sunriseEvent(evt) {
LOG("sunriseEvent() - with evt (${evt?.name}:${evt?.value})", 4, null, "info")
atomicState.timeOfDay = "day"
atomicState.lastSunriseEvent = now()
atomicState.lastSunriseEventDate = getTimestamp()
def sunriseAndSunset = atomicState.zipCode ? getSunriseAndSunset(zipCode: atomicState.zipCode) : getSunRiseAndSunset()
if(atomicState.timeZone) {
atomicState.sunriseTime = sunriseAndSunset.sunrise.format("HHmm", TimeZone.getTimeZone(atomicState.timeZone)).toInteger()
} else if( (sunriseAndSunset != [:]) && (location != null) ) {
atomicState.sunriseTime = sunriseAndSunset.sunrise.format("HHmm").toInteger()
} else {
atomicState.sunriseTime = evt.value.toInteger()
}
// if(atomicState.timeZone) {
// atomicState.sunriseTime = new Date().format("HHmm", atomicState.timeZone).toInteger()
// } else {
// atomicState.sunriseTime = new Date().format("HHmm").toInteger()
// }
atomicState.getWeather = true // force updating of the weather icon in the thermostat
atomicState.forcePoll = true
scheduleWatchdog(evt, true)
}
def sunsetEvent(evt) {
LOG("sunsetEvent() - with evt (${evt?.name}:${evt?.value})", 4, null, "info")
atomicState.timeOfDay = "night"
atomicState.lastSunsetEvent = now()
atomicState.lastSunsetEventDate = getTimestamp()
// get sunrise/sunset for the location of the thermostats (prefers thermostat.location.postalCode)
def sunriseAndSunset = atomicState.zipCode ? getSunriseAndSunset(zipCode: atomicState.zipCode) : getSunRiseAndSunset()
if(atomicState.timeZone) {
atomicState.sunsetTime = sunriseAndSunset.sunset.format("HHmm", TimeZone.getTimeZone(atomicState.timeZone)).toInteger()
} else if( (sunriseAndSunset != [:]) && (location != null) ) {
atomicState.sunsetTime = sunriseAndSunset.sunset.format("HHmm").toInteger()
} else {
atomicState.sunsetTime = evt.value.toInteger()
}
// if(atomicState.timeZone) {
// atomicState.sunsetTime = new Date().format("HHmm", atomicState.timeZone).toInteger()
// } else {
// atomicState.sunsetTime = new Date().format("HHmm").toInteger()
// }
atomicState.getWeather = true // force updating of the weather icon in the thermostat
atomicState.forcePoll = true // force a complete poll...
scheduleWatchdog(evt, true)
}
// Event on a monitored device
def userDefinedEvent(evt) {
if ( ((now() - atomicState.lastUserDefinedEvent) / 60000.0) < 0.5 ) {
if (debugLevel(4)) LOG("userDefinedEvent() - time since last event is less than 30 seconds, ignoring.", 4)
return
}
if (debugLevel(4)) LOG("userDefinedEvent() - with evt (Device:${evt?.displayName} ${evt?.name}:${evt?.value})", 4, null, "info")
poll()
atomicState.lastUserDefinedEvent = now()
atomicState.lastUserDefinedEventDate = getTimestamp()
atomicState.lastUserDefinedEventInfo = "Event Info: (Device:${evt?.displayName} ${evt?.name}:${evt?.value})"
def lastUserWatchdog = atomicState.lastUserWatchdogEvent
if (lastUserWatchdog) {
if ( ((now() - lastUserWatchdog) / 60000) < 3 ) {
// Don't bother running the watchdog on EVERY userDefinedEvent - that's really overkill.
if (debugLevel(4)) LOG('userDefinedEvent() - polled, but time since last watchdog is less than 3 minutes, exiting without performing additional actions', 4)
return
}
}
atomicState.lastUserWatchdogEvent = now()
scheduleWatchdog(evt, true)
}
def scheduleWatchdog(evt=null, local=false) {
def results = true
if (debugLevel(4)) {
def evtStr = evt ? "${evt.name}:${evt.value}" : 'null'
if (debugLevel(4)) LOG("scheduleWatchdog() called with evt (${evtStr}) & local (${local})", 4, null, "trace")
}
// Only update the Scheduled timestamp if it is not a local action or from a subscription
if ( (evt == null) && (local==false) ) {
atomicState.lastScheduledWatchdog = now()
atomicState.lastScheduledWatchdogDate = getTimestamp()
atomicState.getWeather = true // next pollEcobeeApi for runtime changes should also get the weather object
// do a forced update once an hour, just because (e.g., forces Hold Status to update completion date string)
def counter = atomicState.hourlyForcedUpdate
counter = (!counter) ? 1 : counter + 1
if (counter == 4) {
counter = 0
atomicState.forcePoll = true
}
atomicState.hourlyForcedUpdate = counter
}
// Check to see if we have called too soon
def timeSinceLastWatchdog = (now() - atomicState.lastWatchdog) / 60000
if ( timeSinceLastWatchdog < 1 ) {
if (debugLevel(4)) LOG("It has only been ${timeSinceLastWatchdog} since last scheduleWatchdog was called. Please come back later.", 4, null, "trace")
return true
}
atomicState.lastWatchdog = now()
atomicState.lastWatchdogDate = getTimestamp()
def pollAlive = isDaemonAlive("poll")
def watchdogAlive = isDaemonAlive("watchdog")
if (debugLevel(4)) LOG("After watchdog tagging",4,null,'trace')
if (apiConnected() == 'lost') {
// Possibly a false alarm? Check if we can update the token with one last fleeting try...
if( refreshAuthToken() ) {
// We are back in business!
LOG("scheduleWatchdog() - Was able to recover the lost connection. Please ignore any notifications received.", 1, null, "warn")
} else {
LOG("scheduleWatchdog() - Unable to schedule handlers do to loss of API Connection. Please ensure you are authorized.", 1, null, "error")
return false
}
}
LOG("scheduleWatchdog() --> pollAlive==${pollAlive} watchdogAlive==${watchdogAlive}", 4, null, "debug")
// Reschedule polling if it has been a while since the previous poll
if (!pollAlive) { spawnDaemon("poll") }
if (!watchdogAlive) { spawnDaemon("watchdog") }
return true
}
// Watchdog Checker
private def Boolean isDaemonAlive(daemon="all") {
// Daemon options: "poll", "auth", "watchdog", "all"
def daemonList = ["poll", "auth", "watchdog", "all"]
String preText = getDebugLevel() <= 2 ? '' : 'isDeamonAlive() - '
Integer pollingInterval = getPollingInterval()
daemon = daemon.toLowerCase()
def result = true
if (debugLevel(5)) LOG("isDaemonAlive() - now() == ${now()} for daemon (${daemon})", 5, null, "trace")
// No longer running an auth Daemon, because we need the scheduler slot (max 4 scheduled things, poll + watchdog use 2)
// def timeBeforeExpiry = atomicState.authTokenExpires ? ((atomicState.authTokenExpires - now()) / 60000) : 0
// LOG("isDaemonAlive() - Time left (timeBeforeExpiry) until expiry (in min): ${timeBeforeExpiry}", 4, null, "info")
if (daemon == "poll" || daemon == "all") {
def lastScheduledPoll = atomicState.lastScheduledPoll
def timeSinceLastScheduledPoll = (!lastSchedulePoll || (lastScheduledPoll == 0)) ? 0 : ((now() - lastScheduledPoll) / 60000)
if (debugLevel(4)) {
LOG("isDaemonAlive() - Time since last poll? ${timeSinceLastScheduledPoll} -- lastScheduledPoll == ${lastScheduledPoll}", 4, null, "info")
LOG("isDaemonAlive() - Checking daemon (${daemon}) in 'poll'", 4, null, "trace")
}
def maxInterval = pollingInterval + 2
if ( timeSinceLastScheduledPoll >= maxInterval ) { result = false }
}
if (daemon == "watchdog" || daemon == "all") {
def lastScheduledWatchdog = atomicState.lastScheduledWatchdog
def timeSinceLastScheduledWatchdog = (!lastScheduledWatchdog || (lastScheduledWatchdog == 0)) ? 0 : ((now() - lastScheduledWatchdog) / 60000)
if (debugLevel(4)) {
LOG("isDaemonAlive() - Time since watchdog activation? ${timeSinceLastScheduledWatchdog} -- lastScheduledWatchdog == ${lastScheduledWatchdog}", 4, null, "info")
LOG("isDaemonAlive() - Checking daemon (${daemon}) in 'watchdog'", 4, null, "trace")
}
def maxInterval = atomicState.watchdogInterval + 2
if (debugLevel(4)) LOG("isDaemonAlive(watchdog) - timeSinceLastScheduledWatchdog=(${timeSinceLastScheduledWatchdog}) Timestamps: (${atomicState.lastScheduledWatchdogDate}) (epic: ${lastScheduledWatchdog}) now-(${now()})", 4, null, "trace")
if ( timeSinceLastScheduledWatchdog >= maxInterval ) { result = false }
}
if (!daemonList.contains(daemon) ) {
// Unkown option passed in, gotta punt
LOG("isDaemonAlive() - Unknown daemon: ${daemon} received. Do not know how to check this daemon.", 1, null, "error")
result = false
}
if (debugLevel(4)) LOG("isDaemonAlive() - result is ${result}", 4, null, "trace")
if (!result) LOG("${preText}(${daemon}) has died", 1, null, 'warn')
return result
}
private def Boolean spawnDaemon(daemon="all", unsched=true) {
// Daemon options: "poll", "auth", "watchdog", "all"
def daemonList = ["poll", "auth", "watchdog", "all"]
Random rand = new Random()
Integer pollingInterval = getPollingInterval()
daemon = daemon.toLowerCase()
def result = true
if (daemon == "poll" || daemon == "all") {
if (debugLevel(4)) LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'poll'", 4, null, "trace")
// Reschedule the daemon
try {
if( unsched ) { unschedule("pollScheduled") }
if ( canSchedule() ) {
LOG("Polling Interval == ${pollingInterval}", 4)
if ((pollingInterval < 5) && (pollingInterval > 1)) { // choices were 1,2,3,5,10,15,30
LOG("Using schedule instead of runEvery with pollingInterval: ${pollingInterval}", 4)
int randomSeconds = rand.nextInt(59)
schedule("${randomSeconds} 0/${pollingInterval} * * * ?", "pollScheduled")
} else {
LOG("Using runEvery to setup polling with pollingInterval: ${pollingInterval}", 4)
"runEvery${pollingInterval}Minute${pollingInterval!=1?'s':''}"("pollScheduled")
}
// Only poll now if we were recovering - if not asked to unschedule, then whoever called us will handle the first poll (as in initialize())
if (unsched) result = pollScheduled() && result
} else {
LOG("canSchedule() is NOT allowed or result already false! Unable to schedule poll daemon!", 1, null, "error")
result = false
}
} catch (Exception e) {
LOG("spawnDaemon() - Exception when performing spawn for ${daemon}. Exception: ${e}", 1, null, "error")
result = false
}
}
if (daemon == "watchdog" || daemon == "all") {
if (debugLevel(4)) LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'watchdog'", 4, null, "trace")
// Reschedule the daemon
try {
if( unsched ) { unschedule("scheduleWatchdog") }
if ( canSchedule() ) {
"runEvery${atomicState.watchdogInterval}Minutes"("scheduleWatchdog")
result = result && true
} else {
LOG("canSchedule() is NOT allowed or result already false! Unable to schedule daemon!", 1, null, "error")
result = false
}
} catch (Exception e) {
LOG("spawnDaemon() - Exception when performing spawn for ${daemon}. Exception: ${e}", 1, null, "error")
result = false
}
atomicState.lastScheduledWatchdog = now()
atomicState.lastScheduledWatchdogDate = getTimestamp()
atomicState.getWeather = true // next pollEcobeeApi for runtime changes should also get the weather object
}
if (!daemonList.contains(daemon) ) {
// Unkown option passed in, gotta punt
LOG("isDaemonAlive() - Unknown daemon: ${daemon} received. Do not know how to check this daemon.", 1, null, "error")
result = false
}
return result
}
def updateLastPoll(Boolean isScheduled=false) {
if (isScheduled) {
atomicState.lastScheduledPoll = now()
atomicState.lastScheduledPollDate = getTimestamp()
} else {
atomicState.lastPoll = now()
atomicState.lastPollDate = getTimestamp()
}
}
def poll() {
if (debugLevel(4)) LOG("poll() - Running at ${getTimestamp()} (epic: ${now()})", 4, null, "trace")
pollChildren() // Poll ALL the children at the same time for efficiency
}
// Called by scheduled() event handler
def pollScheduled() {
updateLastPoll(true)
if (debugLevel(4)) LOG("pollScheduled() - Running at ${atomicState.lastScheduledPollDate} (epic: ${atomicState.lastScheduledPoll})", 4, null, "trace")
return poll()
}
// Called during initialization to get the inital poll
def pollInit() {
if (debugLevel(5)) LOG("pollInit()", 5)
// atomicState.forcePoll = true // Initialize the variable and force a poll even if there was one recently
runIn(2, pollChildren, [overwrite: true]) // Hit the ecobee API for update on all thermostats, in 2 seconds
}
def pollChildren(deviceId = null) {
if (debugLevel(4)) LOG("pollChildren() - deviceId ${deviceId}", 4, child, 'trace')
def forcePoll
if (deviceId != null) {
atomicState.forcePoll = true
forcePoll = true
} else {
forcePoll = atomicState.forcePoll
}
if (debugLevel(4)) LOG("=====> pollChildren() - atomicState.forcePoll(${forcePoll}) atomicState.lastPoll(${atomicState.lastPoll}) now(${now()}) atomicState.lastPollDate(${atomicState.lastPollDate})", 4, child, "trace")
if(apiConnected() == "lost") {
// Possibly a false alarm? Check if we can update the token with one last fleeting try...
if (debugLevel(3)) LOG("apiConnected() == lost, try to do a recovery, else we are done...", 3, null, "debug")
if( refreshAuthToken() ) {
// We are back in business!
LOG("pollChildren() - Was able to recover the lost connection. Please ignore any notifications received.", 1, null, "warn")
} else {
LOG("pollChildren() - Unable to schedule handlers do to loss of API Connection. Please ensure you are authorized.", 1, null, "error")
return false
}
}
// Run a watchdog checker here
scheduleWatchdog(null, true)
if (settings.thermostats?.size() < 1) {
LOG("pollChildren() - Nothing to poll as there are no thermostats currently selected", 1, null, "warn")
return true
}
// Check if anything has changed in the thermostatSummary (really don't need to call EcobeeAPI if it hasn't).
String thermostatsToPoll = (deviceId!=null) ? deviceId : getChildThermostatDeviceIdsString()
boolean somethingChanged = forcePoll ? true : checkThermostatSummary(thermostatsToPoll)
if (!forcePoll) thermostatsToPoll = atomicState.changedThermostatIds
String preText = getDebugLevel() <= 2 ? '' : 'pollChildren() - '
if (forcePoll || somethingChanged) {
LOG("${preText}Requesting updates for thermostat${thermostatsToPoll.contains(',')?'s':''} ${thermostatsToPoll}${forcePoll?' (forced)':''}", 2, null, 'trace')
pollEcobeeAPI(thermostatsToPoll) // This will update the values saved in the state which can then be used to send the updates
} else {
LOG(preText+'No updates...', 2, null, 'trace')
return true
}
Integer numSensors = 0
if (forcePoll || somethingChanged) {
// Iterate over ONLY THE CHILDREN THAT HAVE DATA - this is MUCH faster than iterating over ALL the children, because
// we now only send changed data for changed thermostats
atomicState.thermostats?.each { DNI ->
if (DNI.value?.data) getChildDevice(DNI.key)?.generateEvent(DNI.value.data)
}
atomicState.remoteSensorsData?.each { DNI ->
if (DNI.value?.data) getChildDevice(DNI.key)?.generateEvent(DNI.value.data)
}
}
if (numSensors > 0) LOG("pollChildren() - Events transferred to ${numSensors} sensors", 3, null, 'info')
return true
}
// enables child SmartApps to send events to child Smart Devices using only the DNI
def generateChildEvent( childDNI, dataMap) {
getChildDevice(childDNI)?.generateEvent(dataMap)
}
// NOTE: This only updated the apiConnected state now - used to resend all the data, but that's pretty much a waste of CPU cycles now.
// If the UI needs updating, the refresh now does a forcePoll on the entire device.
private def generateEventLocalParams() {
// Iterate over all the children
if (debugLevel(3)) LOG("generateEventLocalParams() - updating API status", 3, null, 'info')
def connected = apiConnected()
def data = [
apiConnected: connected
]
settings.thermostats?.each {
getChildDevice(it)?.generateEvent(data)
}
/*
def d = getChildDevices()
d?.each() { oneChild ->
LOG("generateEventLocalParams() - Processing data for child: ${oneChild} has ${oneChild.capabilities}", 4, "", 'info')
if( oneChild.hasCapability("Thermostat") ) {
// We found a Thermostat, send local params as events
LOG("generateEventLocalParams() - We found a Thermostat!", 4)
// Update the API connected state for ALL the associated thermostats
// Note that we can't modify a Map element in atomicState directly - we have to read to a local variable, modify, then write it back
// atomicState.thermostats[oneChild.device.deviceNetworkId]?.data?.apiConnected = apiConnected()
Map tempData = atomicState.thermostats
tempData[oneChild.device.deviceNetworkId]?.data?.apiConnected = connected
atomicState.thermostats = tempData
oneChild.generateEvent(data)
} else {
// We must have a remote sensor
// LOG("generateEventLocalParams() - Updating sensor data: ${oneChild.device.deviceNetworkId}", 4)
// No local params to send
}
}
*/
}
// Checks if anything has changed since the last time this routine was called, using lightweight thermostatSummary poll
// NOTE: Despite the documentation, Runtime Revision CAN change more frequently than every 3 minutes - equipmentStatus is
// apparently updated in real time (or at least very close to real time)
private boolean checkThermostatSummary(thermostatIdsString) {
String preText = getDebugLevel() <= 2 ? '' : 'checkThermostatSummary - '
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + thermostatIdsString + '","includeEquipmentStatus":"false"}}'
def pollParams = [
uri: apiEndpoint,
path: "/1/thermostatSummary",
headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
query: [format: 'json', body: jsonRequestBody]
]
def result = false
def statusCode=999
String tstatsStr = ''
try {
httpGet(pollParams) { resp ->
if(resp.status == 200) {
if (debugLevel(4)) LOG("checkThermostatSummary() - poll results returned resp.data ${resp.data}", 4)
statusCode = resp.data.status.code
if (statusCode == 0) {
def revisions = resp.data.revisionList
def thermostatUpdated = false
def alertsUpdated = false
def runtimeUpdated = false
tstatsStr = ""
def latestRevisions = [:]
// each revision is a separate thermostat
revisions.each { rev ->
def tempList = rev.split(':') as List
def tstat = tempList[0]
def latestDetails = tempList.reverse().take(4).reverse() as List // isolate the 4 timestamps
def lastRevisions = atomicState.lastRevisions
if (lastRevisions && !(lastRevisions instanceof Map)) lastRevisions = [:] // hack to switch from List to Map for revision cache
def lastDetails = lastRevisions && lastRevisions.containsKey(tstat) ? lastRevisions[tstat] : [] // lastDetails should be the prior 4 timestamps
// check if the current tstat is updated
def tru = false
def tau = false
def ttu = false
if (lastDetails) { // if we have prior revision details
if (lastDetails[2] != latestDetails[2]) tru = true // runtime
if (lastDetails[1] != latestDetails[1]) tau = true // alerts
if (lastDetails[0] != latestDetails[0]) ttu = true // thermostat
} else { // no priors, assume all have been updated
tru = true
tau = true
ttu = true
}
// update global flags (we update the superset of changes for all requested tstats)
if (tru || tau || ttu) {
runtimeUpdated = (runtimeUpdated || tru)
alertsUpdated = (alertsUpdated || tau)
thermostatUpdated = (thermostatUpdated || ttu)
result = true
tstatsStr = (tstatsStr=="") ? tstat : (tstatsStr.contains(tstat)) ? tstatsStr : tstatsStr + ",${tstat}"
}
latestRevisions[tstat] = latestDetails // and, log the new timestamps
}
atomicState.latestRevisions = latestRevisions // let pollEcobeeAPI update last with latest after it finishes the poll
atomicState.thermostatUpdated = thermostatUpdated // Revised: settings, program, event, device
atomicState.alertsUpdated = alertsUpdated // Revised: alerts
atomicState.runtimeUpdated = runtimeUpdated // Revised: runtime, equip status, remote sensors, weather?
atomicState.changedThermostatIds = tstatsStr // only these thermostats need to be requested in pollEcobeeAPI
}
// if we get here, we had http status== 200, but API status != 0
} else {
LOG("${preText}ThermostatSummary poll got http status ${resp.status}", 1, null, "error")
}
}
} catch (groovyx.net.http.HttpResponseException e) {
result = false // this thread failed to get the summary
if ((e.statusCode == 500) && (e.response.data.status.code == 14) /*&& ((atomicState.authTokenExpires - now()) <= 0) */){
LOG('checkThermostatSummary() - HttpResponseException occurred: Auth_token has expired', 3, null, "info")
atomicState.action = "pollChildren"
if (debugLevel(4)) LOG( "Refreshing your auth_token!", 4)
if ( refreshAuthToken() ) {
// Note that refreshAuthToken will reschedule pollChildren if it succeeds in refreshing the token...
LOG( preText+'Auth_token refreshed', 2, null, 'info')
} else {
LOG(preText+'Auth_token refresh failed', 1, null, 'error')
}
} else {
LOG("${preText}HttpResponseException; Exception info: ${e} StatusCode: ${e.statusCode} response.data.status.code: ${e.response.data.status.code}", 1, null, "error")
}
} catch (java.util.concurrent.TimeoutException e) {
LOG("checkThermostatSummary() - TimeoutException: ${e}.", 1, null, "warn")
// Do not add an else statement to run immediately as this could cause an long looping cycle if the API is offline
runIn(atomicState.reAttemptInterval.toInteger(), "pollChildren", [overwrite: true])
result = false
}
if (debugLevel(4)) LOG("<===== Leaving checkThermostatSummary() result: ${result}, tstats: ${tstatsStr}", 4, null, "info")
return result
}
private def pollEcobeeAPI(thermostatIdsString = '') {
if (debugLevel(4)) LOG("=====> pollEcobeeAPI() entered - thermostatIdsString = ${thermostatIdsString}", 4, null, "info")
boolean forcePoll = atomicState.forcePoll // lightweight way to use atomicStates that we don't want to change under us
boolean thermostatUpdated = atomicState.thermostatUpdated
boolean alertsUpdated = atomicState.alertsUpdated
boolean runtimeUpdated = atomicState.runtimeUpdated
boolean getWeather = atomicState.getWeather
String preText = getDebugLevel() <= 2 ? '' : 'pollEcobeeAPI() - '
boolean somethingChanged = false
String checkTherms
String allMyChildren = getChildThermostatDeviceIdsString()
// forcePoll = true
if (forcePoll) {
// if it's a forcePoll, and thermostatIdString specified, we check only the specified thermostat, else we check them all
checkTherms = thermostatIdsString ? thermostatIdsString : allMyChildren
if (checkTherms == allMyChildren) atomicState.hourlyForcedUpdate = 0 // reset the hourly check counter if we are forcePolling ALL the thermostats
somethingChanged = true
} else if (atomicState.lastRevisions != atomicState.latestRevisions) {
// we already know there are changes
somethingChanged = true
} else {
// log.debug "Shouldn't be checkingThermostatSummary() again!"
somethingChanged = checkThermostatSummary(thermostatIdsString)
thermostatUpdated = atomicState.thermostatUpdated // update these again after checkThermostatSummary
alertsUpdated = atomicState.alertsUpdated
runtimeUpdated = atomicState.runtimeUpdated
}
// if nothing has changed, and this isn't a forced poll, just return (keep count of polls we have skipped)
// This probably can't happen anymore...shouldn't event be here if nothing has changed and not a forced poll...
if (!somethingChanged && !forcePoll) {
if (debugLevel(4)) LOG("<===== Leaving pollEcobeeAPI() - nothing changed, skipping heavy poll...", 4 )
return true // act like we completed the heavy poll without error
} else {
// if getting the weather, get for all thermostats at the same time. Else, just get the thermostats that changed
checkTherms = (runtimeUpdated && getWeather) ? allMyChildren : (forcePoll ? checkTherms : atomicState.changedThermostatIds)
}
// Let's only check those thermostats that actually changed...unless this is a forcePoll - or if we are getting the weather
// (we need to get weather for all therms at the same time, because we only update every 15 minutes and use the cached version
// the rest of the time)
// get equipmentStatus every time
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + checkTherms + '","includeEquipmentStatus":"true"'
String gw = '( equipmentStatus'
if (forcePoll || thermostatUpdated) {
jsonRequestBody += ',"includeSettings":"true","includeProgram":"true","includeEvents":"true"'
gw += ' thermostat program events'
}
if (forcePoll) {
jsonRequestBody += ',"includeOemCfg":"true","includeLocation":"true"'
gw += ' oemCfg location'
}
if (forcePoll || alertsUpdated) {
jsonRequestBody += ',"includeAlerts":"true"'
gw += ' alerts'
}
if (forcePoll || runtimeUpdated) {
jsonRequestBody += ',"includeRuntime":"true"'
gw += ' runtime'
// only get extendedRuntime if we have a humidifier/dehumidifier (depending upon HVAC mode at the moment)
if (atomicState.needExtendedRuntime) {
jsonRequestBody += ',"includeExtendedRuntime":"true"'
gw += ' extendedRuntime'
}
// only get sensorData if we have any sensors configured
if (settings.ecobeesensors?.size() > 0) {
jsonRequestBody += ',"includeSensors":"true"'
gw += ' sensors'
}
if (getWeather) {
jsonRequestBody += ',"includeWeather":"true"' // time to get the weather report (only changes every 15 minutes or so - watchdog sets this when it runs)
gw += ' weather'
}
gw += ' )'
LOG("${preText}Getting ${gw} for thermostat${checkTherms.contains(',')?'s':''} ${checkTherms}", 2, null, 'info')
}
jsonRequestBody += '}}'
if (debugLevel(4)) LOG("pollEcobeeAPI() - jsonRequestBody is: ${jsonRequestBody}", 4, null, 'trace')
def result = false
def pollParams = [
uri: apiEndpoint,
path: "/1/thermostat",
headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
query: [format: 'json', body: jsonRequestBody]
]
def tidList = []
try{
httpGet(pollParams) { resp ->
if(resp.status == 200) {
if (resp.data) atomicState.thermostatData = resp.data // this now only stores the most recent collection of changed data
// this may not be the entire set of data for all of our thermostats, so the rest
// of this code will calculate updates from the individual data objects (which
// always cache the latest values recieved)
// Update the atomicState caches for each received data object(s)
def tempSettings = [:]
def tempProgram = [:]
def tempEvents = [:]
def tempRuntime = [:]
def tempExtendedRuntime = [:]
def tempWeather = [:]
def tempSensors = [:]
def tempEquipStat = [:]
def tempLocation = [:]
def tempOemCfg = [:]
def tempAlerts = [:]
// collect the returned data into temporary individual caches (because we can't update individual Map items in an atomicState Map)
resp.data.thermostatList.each { stat ->
String tid = stat.identifier.toString()
tidList += [tid]
if (debugLevel(3)) LOG("pollEcobeeAPI() - Parsing data for thermostat ${tid}", 3, null, 'info')
tempEquipStat[tid] = stat.equipmentStatus // always store ("" is a valid return value)
if (forcePoll || thermostatUpdated) {
if (stat.settings) tempSettings[tid] = stat.settings
if (stat.program) tempProgram[tid] = stat.program
if (stat.events) tempEvents[tid] = stat.events
if (stat.location) tempLocation[tid] = stat.location
if (stat.oemCfg) tempOemCfg[tid] = stat.oemCfg
}
if (forcePoll || runtimeUpdated) {
if (stat.runtime) tempRuntime[tid] = stat.runtime
if (stat.extendedRuntime) tempExtendedRuntime[tid] = stat.extendedRuntime
if (stat.remoteSensors) tempSensors[tid] = stat.remoteSensors
if (stat.weather) tempWeather[tid] = stat.weather
}
if (forcePoll || alertsUpdated) {
tempAlerts[tid] = stat.alerts
}
}
// OK, we have all the data received stored in their appropriate temporary cache, let's update the atomicState caches
if (tempEquipStat != [:]) {
if (atomicState.equipmentStatus) tempEquipStat = atomicState.equipmentStatus + tempEquipStat
atomicState.equipmentStatus = tempEquipStat
}
if (forcePoll || thermostatUpdated) {
if (tempProgram != [:]) {
if (atomicState.program) tempProgram = atomicState.program + tempProgram
atomicState.program = tempProgram
}
if (tempEvents != [:]) {
if (atomicState.events) tempEvents = atomicState.events + tempEvents
atomicState.events = tempEvents
}
if (tempLocation != [:]) {
if (atomicState.statLocation) tempLocation = atomicState.statLocation + tempLocation
atomicState.statLocation = tempLocation
}
if (tempOemCfg != [:]) {
if (atomicState.oemCfg) tempOemCfg = atomicState.oemCfg + tempOemCfg
atomicState.oemCfg = tempOemCfg
}
if (tempSettings != [:]) {
if (atomicState.settings) tempSettings = atomicState.settings + tempSettings
atomicState.settings = tempSettings
// While we are here, let's find out if we really need the extendedRuntime data...
// We do it here because: a) it only changes when settings changes, and b) we already have the settings Map in hand
// (thus it saves the cost of copying atomicState.settings later)
boolean needExtRT = false
tempSettings.each {
if (!needExtRT && checkTherms.contains(it.key)){
switch (it.value.hvacMode) {
case 'heat':
case 'auxHeatOnly':
if (it.value.hasHumidifier && (it.value.humidifierMode != 'off')) needExtRT = true
break;
case 'cool':
if ((it.value.dehumidifierMode == 'on') && (it.value.hasDehumidifier ||
(it.value.dehumidifyWithAC && (it.value.dehumidifyOvercoolOffset != 0)))) needExtRT = true
break;
case 'auto':
if ((it.value.hasHumidifier && (it.value.humidifierMode != 'off')) ||
(it.value.dehumidifierMode == 'on') && (it.value.hasDehumidifier ||
(it.value.dehumidifyWithAC && (it.value.dehumidifyOvercoolOffset != 0)))) needExtRT = true
break;
}
}
}
atomicState.needExtendedRuntime = needExtRT
}
}
if (forcePoll || runtimeUpdated) {
if (tempRuntime != [:]) {
if (atomicState.runtime) tempRuntime = atomicState.runtime + tempRuntime
atomicState.runtime = tempRuntime
}
if (tempExtendedRuntime != [:]) {
if (atomicState.extendedRuntime) tempExtendedRuntime = atomicState.extendedRuntime + tempExtendedRuntime
atomicState.extendedRuntime = tempExtendedRuntime
}
if (tempSensors != [:]) {
if (atomicState.remoteSensors) tempSensors = atomicState.remoteSensors + tempSensors
atomicState.remoteSensors = tempSensors
}
if (tempWeather != [:]) {
if (atomicState.weather) tempWeather = atomicState.weather + tempWeather
atomicState.weather = tempWeather
}
}
if (forcePoll || alertsUpdated) {
if (tempAlerts != [:]) {
if (atomicState.alerts) tempAlerts = atomicState.alerts + tempAlerts
atomicState.alerts = tempAlerts
}
}
// OK, Everything is safely stored, let's get to parsing the objects and finding the actual changed data
updateLastPoll()
LOG('pollEcobeeAPI() - Parsing complete', 3, null, 'info')
// Update the data and send it down to the devices as events
if (settings.thermostats) updateThermostatData() // update thermostats first (some sensor data is dependent upon thermostat changes)
if (settings.ecobeesensors) updateSensorData() // Only update configured sensors (and then only if they have changes)
if (forcePoll || alertsUpdated) /*updateAlertsData() */ runIn(15, "updateAlertsData", [overwrite: true]) // Update alerts asynchronously
// pollChildren() will actually send all the events we just created, once we return
// just a bit more housekeeping...
result = true
atomicState.lastRevisions = atomicState.latestRevisions // copy the Map
if (forcePoll) atomicState.forcePoll = false // it's ok to clear the flag now
if (runtimeUpdated) atomicState.runtimeUpdated = false
if (thermostatUpdated) { atomicState.thermostatUpdated = false; atomicState.hourlyForcedUpdate = 0 }
if (runtimeUpdated && getWeather) atomicState.getWeather = false
if (alertsUpdated) atomicState.alertsUpdated = false
// Not sure why this is here...it just tells the children that we are once again connected to the Ecobee API Cloud
if (apiConnected() != "full") {
apiRestored()
generateEventLocalParams() // Update the connection status
}
def tNames = resp.data.thermostatList?.name.toString()
def numStats = tidList?.size()
if (preText == '') { // avoid calling debugLevel() again) {
LOG("Updated ${numStats} thermostat${(numStats>1)?'s':''}: ${tidList}", 2, null, 'info')
} else {
LOG("pollEcobeeAPI() - Updated ${numStats} thermostat${(numStats>1)?'s':''}: ${tNames}/${tidList} ${atomicState.thermostats}", 4, null, 'info')
}
} else {
LOG("${preText}Polling children & got http status ${resp.status}", 1, null, "error")
//refresh the auth token
if (resp.status == 500 && resp.data.status.code == 14) {
LOG("${preText}Resp.status: ${resp.status} Status Code: ${resp.data.status.code}. Unable to recover", 1, null, "error")
// Should not be possible to recover from a code 14 but try anyway?
apiLost("pollEcobeeAPI() - Resp.status: ${resp.status} Status Code: ${resp.data.status.code}. Unable to recover.")
}
else {
LOG("pollEcobeeAPI() - Other responses received. Resp.status: ${resp.status} Status Code: ${resp.data.status.code}.", 1, null, "error")
}
}
}
} catch (groovyx.net.http.HttpResponseException e) {
result = false // this thread failed
if ((e.statusCode == 500) && (e.response.data.status.code == 14)) {
if (debugLevel(3)) LOG("pollEcobeAPI() - HttpResponseException occurred: Auth_token has expired", 3, null, "info")
atomicState.action = "pollChildren"
if (debugLevel(4)) LOG( "pollEcobeeAPI() - Refreshing your auth_token!", 4)
if ( refreshAuthToken() ) {
// Note that refreshAuthToken will reschedule pollChildren if it succeeds in refreshing the token...
LOG( preText+'Auth_token refreshed', 2, null, 'info')
} else {
LOG( preText+'Auth_token refresh failed', 1, null, 'error')
}
} else {
LOG("${preText}HttpResponseException; Exception info: ${e} StatusCode: ${e.statusCode} response.data.status.code: ${e.response.data.status.code}", 1, null, "error")
}
} catch (java.util.concurrent.TimeoutException e) {
LOG("${preText}TimeoutException: ${e}.", 1, null, "warn")
// Do not add an else statement to run immediately as this could cause an long looping cycle if the API is offline
runIn(atomicState.reAttemptInterval.toInteger(), "pollChildren", [overwrite: true])
result = false
} catch (Exception e) {
// TODO: Handle "org.apache.http.conn.ConnectTimeoutException" as this is a transient error and shouldn't count against our retries
LOG("${preText}General Exception: ${e}.", 1, null, "error")
atomicState.reAttemptPoll = atomicState.reAttemptPoll + 1
if (atomicState.reAttemptPoll > 3) {
apiLost("${preText}Too many retries (${atomicState.reAttemptPoll - 1}) for polling.")
return false
} else {
LOG(preText+'Setting up retryPolling')
// def reAttemptPeriod = 15 // in sec
if ( canSchedule() ) {
runIn(atomicState.reAttemptInterval.toInteger(), "refreshAuthToken")
} else {
LOG(preText+'Unable to schedule refreshAuthToken, running directly')
refreshAuthToken()
}
}
}
LOG("<===== Leaving pollEcobeeAPI() results: ${result}", 5)
return result
}
def updateAlertsData() {
if (atomicState.alerts == null) return // silently return until we get the alerts environment initialized
boolean debugLevelFour = debugLevel(4)
if (!settings.askAlexa) {
if (debugLevelFour) LOG("Ask Alexa support not configured, nothing to do, returning",4)
return // Nothing to do (at least for now)
}
if (debugLevelFour) LOG("Entered updateAlertsData() ${atomicState.alerts}", 4)
def askAlexaAlerts = atomicState.askAlexaAlerts // list of alerts we have sent to Ask Alexa
if (askAlexaAlerts == null) askAlexaAlerts = [:] // make it a map if it doesn't exist yet
def alerts = atomicState.alerts // the latest alerts from Ecobee (all thermostats, by tid)
def thermostatsWithNames = atomicState.thermostatsWithNames
def newAlexaAlerts = [:]
def totalAlexaAlerts = 0 // including any duplicates we send
def totalNewAlerts = 0
def removedAlerts = 0
// Initialize some Ask Alexa items
String queues = getMQListNames()
def exHours = settings.expire ? settings.expire as int : 0
def exSec=exHours * 3600
// Walk through the list of thermostats
atomicState.thermostatData.thermostatList.each { stat ->
String tid = stat.identifier
// Get the name for this thermostat
String DNI = [ app.id, tid ].join('.')
String tstatName = (thermostatsWithNames?.containsKey(DNI)) ? thermostatsWithNames[DNI] : ''
if (tstatName == '') {
tstatName = getChildDevice(DNI)?.displayName // better than displaying 'null' as the name
}
if (alerts.containsKey(tid) && (alerts[tid] != [])) { // we have alerts to process!
// Avoid saying "Your Downstairs Thermostat thermostat" or even "Your Downstairs Ecobee Thermostat"
String append = ' thermostat'
String tName = tstatName.toLowerCase().trim()
if (tName.endsWith('thermostat') || tName.endsWith('ecobee')) append = ''
tstatName = tstatName + append
// Process the alerts for this thermostat
def newAlerts = []
def allAlerts = []
alerts[tid]?.each { alert ->
if (alert.acknowledgeRef.toString().contains(tid)) { // only process alerts that match this thermostat ID
// reformat the alert type (header)
String alertTypeMsg
switch (alert.alertType) {
case 'alert':
switch(alert.notificationType) {
case 'hvac':
alertTypeMsg = 'an H V A C Maintenance Reminder'
break;
case 'furnaceFilter':
alertTypeMsg = 'a Furnace Air Filter Reminder'
break;
case 'humidifierFilter':
alertTypeMsg = 'a Humidifier Filter Reminder'
break;
case 'dehumidifierFilter':
alertTypeMsg = 'a Dehumidifier Filter Reminder'
break;
case 'ventilator':
alertTypeMsg = 'a Ventilator Reminder'
break;
case 'ac':
alertTypeMsg = 'an Air Conditioner Alert'
break;
case 'airFilter':
alertTypeMsg = 'an Air Filter Reminder'
break;
case 'airCleaner':
alertTypeMsg = 'an Air Cleaner Reminder'
break;
case 'uvLamp':
alertTypeMsg = 'a UV Lamp Reminder'
break;
case 'temp':
alertTypeMsg = 'a Temperature Alert'
break;
case 'lowTemp':
alertTypeMsg = 'a Low Temperature Alert'
break;
case 'highTemp':
alertTypeMsg = 'a High temperature Alert'
break;
case 'lowHumidity':
alertTypeMsg = 'a Low Humidity Alert'
break;
case 'highHumidity':
alertTypeMsg = 'a High Humidity Alert'
break;
case 'auxHeat':
alertTypeMsg = 'an Auxiliary Heat Run Time Alert'
break;
case 'auxOutdoor':
alertTypeMsg = 'an Auxiliary Outdoor Temperature Alert'
break;
case 'alert':
default:
alertTypeMsg = 'an Alert'
break;
}
break;
case 'demandResponse':
alertTypeMsg = 'a demand response message'
break;
case 'emergency':
alertTypeMsg = 'an emergency notice'
break;
case 'message':
alertTypeMsg = 'a message for you'
break;
case 'pricing':
alertTypeMsg = 'a pricing notice'
break;
}
// Build the components of the translated message
String messageID = alert.acknowledgeRef.toString() // Use acknowledgeRef as the messageID - it is supposed to be unique
String dateTimeStr = fixDateTimeString( alert.date, alert.time, stat.thermostatTime) // gets us "today" or "yesterday"
String translatedMessage = "Your ${tstatName} reported ${alertTypeMsg} ${dateTimeStr}: ${alert.text}"
// Now send the message to Ask Alexa
LOG("ID '${messageID}' to Ask Alexa ${queues?.size()>0?queues:'Primary Message'} queue${settings.listOfMQs?.size()>1?'s':''}: ${translatedMessage}",2,null,'info')
sendLocationEvent(name: "AskAlexaMsgQueue", value: "Ecobee Status", unit: messageID, isStateChange: true,
descriptionText: translatedMessage, data: [ queues: settings.listOfMQs, overwrite: true, expires: exSec, suppressTimeDate: true ])
totalAlexaAlerts++ // total that we sent to Ask Alexa for all thermostats
allAlerts << messageID // all the alerts that we found for THIS thermostat
// Create the list of new alerts we sent to Ask Alexa
if (!askAlexaAlerts || !askAlexaAlerts.containsKey(tid) || !askAlexaAlerts[tid].contains(messageID)) {
if (!newAlerts || !newAlerts.contains(messageID)) {
newAlerts << messageID
}
}
}
} // end of all the alerts for this thermostat
// Collect all the new alerts that we found for this thermostat
totalNewAlerts += newAlerts.size()
newAlexaAlerts[tid] = newAlerts
// Now, check if any of the saved alerts have been acknowledged (are no longer in thermostat.alerts)
if (askAlexaAlerts && askAlexaAlerts.containsKey(tid) && (askAlexaAlerts[tid] != [])) {
def closedAlerts = allAlerts ? (askAlexaAlerts[tid] + allAlerts) - askAlexaAlerts[tid].intersect(allAlerts) : askAlexaAlerts[tid]
if (closedAlerts) {
closedAlerts.each { msgID ->
LOG("Removing ID ${msgID}",2,null,'info')
sendLocationEvent( name: "AskAlexaMsgQueueDelete", value: "Ecobee Status", unit: msgID.toString(), isStateChange: true,
data: [ queues: settings.listOfMQs ])
}
// Remove the deleted alerts from the list of outstanding alerts
askAlexaAlerts[tid] = askAlexaAlerts[tid] - closedAlerts
removedAlerts += closedAlerts.size()
}
}
} else {
// There are no alerts for this tid... check if we need to delete any from Ask Alexa's queues
if (askAlexaAlerts && askAlexaAlerts.containsKey(tid) && (askAlexaAlerts[tid] != [])) {
// we have some previously queued messages to delete
LOG("Ecobee Alerts cleared for ${tstatName}, deleting outstanding notices from Ask Alexa",2,null,'info')
askAlexaAlerts[tid].each { msgID ->
LOG("Deleting ID ${msgID}",2,null,'info')
sendLocationEvent( name: "AskAlexaMsgQueueDelete", value: "Ecobee Status", unit: msgID.toString(), isStateChange: true,
data: [ queues: settings.listOfMQs ])
}
removedAlerts += askAlexaAlerts[tid].size()
askAlexaAlerts[tid] = [] // clear out the list
}
}
} // end of { stat ->
if (totalAlexaAlerts > 0) {
def updated = totalAlexaAlerts - totalNewAlerts
LOG("Sent ${totalNewAlerts} new and ${updated} updated Alert${(totalNewAlerts+updated)>1?'s':''} to Ask Alexa",2,null,'info')
}
if (removedAlerts > 0) {
LOG("Removed ${removedAlerts} Alert${removedAlerts>1?'s':''} from Ask Alexa",2,null,'info')
}
if ((askAlexaAlerts != [:]) || (newAlexaAlerts != [:])) askAlexaAlerts = askAlexaAlerts + newAlexaAlerts
atomicState.askAlexaAlerts = askAlexaAlerts
}
def updateSensorData() {
boolean debugLevelFour = debugLevel(4)
if (debugLevelFour) LOG("Entered updateSensorData() ${atomicState.remoteSensors}", 4)
def sensorCollector = [:]
def sNames = []
Integer precision = getTempDecimals()
def forcePoll = atomicState.forcePoll
def thermostatUpdated = atomicState.thermostatUpdated
String preText = getDebugLevel() <= 2 ? '' : 'updateSensorData() - '
atomicState.thermostatData.thermostatList.each { singleStat ->
def tid = singleStat.identifier
def isConnected = atomicState.runtime[tid]?.connected
atomicState.remoteSensors[tid].each {
String sensorDNI = ''
switch (it.type) {
case 'ecobee3_remote_sensor':
// Ecobee3 remote temp/occupancy sensor
sensorDNI = "ecobee_sensor-" + it?.id + "-" + it?.code
break;
case 'control_sensor':
// SmartSI style control sensor (temp or humidity)
sensorDNI = "control_sensor-" + it?.id
break;
case 'thermostat':
// The thermostat itself
sensorDNI = "ecobee_sensor_thermostat-"+ it?.id + "-" + it?.name
break;
}
if (debugLevelFour) LOG("updateSensorData() - Sensor ${it.name}, sensorDNI == ${sensorDNI}", 4)
if (sensorDNI && settings.ecobeesensors?.contains(sensorDNI)) { // only process the selected/configured sensors
def temperature = ''
def occupancy = ''
Integer humidity = null
it.capability.each { cap ->
if (cap.type == "temperature") {
if (debugLevelFour) LOG("updateSensorData() - Sensor (DNI: ${sensorDNI}) temp is ${cap.value}", 4)
if ( cap.value.isNumber() ) { // Handles the case when the sensor is offline, which would return "unknown"
temperature = cap.value as Double
temperature = (temperature / 10).toDouble() //.round(precision) // wantMetric() ? (temperature / 10).toDouble().round(1) : (temperature / 10).toDouble().round(1)
} else if (cap.value == 'unknown') {
// TODO: Do something here to mark the sensor as offline?
if (isConnected) {
// No need to log this unless thermostat is actually connected to the Ecobee Cloud
LOG("${preText}Sensor ${it.name} temperature is 'unknown'. Perhaps it is unreachable?", 1, null, 'warn')
}
// Need to mark as offline somehow
temperature = "unknown"
} else {
LOG("${preText}Sensor ${it.name} (DNI: ${sensorDNI}) returned ${cap.value}.", 1, null, "error")
}
} else if (cap.type == 'occupancy') {
if(cap.value == 'true') {
occupancy = 'active'
} else if (cap.value == 'unknown') {
// Need to mark as offline somehow
LOG("${preText}Sensor ${it.name} occupancy is 'unknown'", 2, null, 'warn')
occupancy = 'unknown'
} else {
occupancy = 'inactive'
}
} else if (cap.type == 'humidity') { // only thermostats have humidity
if (cap.value?.isNumber()) humidity = cap.value.toInteger()
}
}
def sensorData = [:]
def lastList = atomicState."${sensorDNI}" as List
def listSize = 5
if ( !lastList || (lastList.size() < listSize)) {
lastList = [999,'x',-1,'default','null'] // initilize the list
sensorData = [ thermostatId: tid ] // this will never change, but we need to send it at least once
}
if (forcePoll) sensorData << [ thermostatId: tid /*, vents: 'default'*/ ] // belt, meet suspenders
// temperature usually changes every time, goes in *List[0]
def currentTemp = ((temperature == 'unknown') ? 'unknown' : myConvertTemperatureIfNeeded(temperature, "F", precision+1))
if (forcePoll || (lastList[0] != currentTemp)) { sensorData << [ temperature: currentTemp ] }
def sensorList = [currentTemp]
// occupancy generally only changes every 30 minutes, in *List[1]
if (forcePoll || ((occupancy != "") && (lastList[1] != occupancy))) { sensorData << [ motion: occupancy ] }
sensorList += [occupancy]
// precision can change at any time, *List[2]
if (forcePoll || (lastList[2] != precision)) { sensorData << [ decimalPrecision: precision ] }
sensorList += [precision]
// currentProgramName doesn't change all that often, but it does change
def currentPrograms = atomicState.currentProgramName
def currentProgramName = (currentPrograms && currentPrograms.containsKey(tid)) ? currentPrograms[tid] : 'default' // not set yet, we will have to get it later.
if (forcePoll || (lastList[3] != currentProgramName)) { sensorData << [ currentProgramName: currentProgramName ] }
sensorList += [currentProgramName]
// not every sensor has humidity
if ((humidity != null) && (lastList[4] != humidity)) { sensorData << [ humidity: humidity ] }
sensorList += [humidity]
// collect the climates that this sensor is included in
def sensorClimates = [:]
def climatesList = []
if (forcePoll || thermostatUpdated) { // these will only change when the program object changes
def statProgram = atomicState.program[tid]
if (statProgram?.climates) {
statProgram.climates.each { climate ->
def sids = climate.sensors?.collect { sid -> sid.name}
if (sids.contains(it.name)) {
sensorClimates << ["${climate.name}":'on']
climatesList += ['on']
} else {
sensorClimates << ["${climate.name}":'off']
climatesList += ['off']
}
}
}
if (debugLevelFour) LOG("updateSensorData() - sensor ${it.name} is used in ${sensorClimates}", 4, null, 'trace')
}
// check to see if the climates have changed. Notice that there can be 0 climates if the thermostat data wasn't updated this time;
// note also that we will send the climate data every time the thermostat IS updated, even if they didn't change, becuase we don't
// store them in the atomicState unless the thermostat object changes...
if (forcePoll || (lastList.size() < (listSize+climatesList.size())) || (lastList.drop(listSize) != climatesList)) {
sensorData << sensorClimates
sensorList += climatesList
}
// For Health Check, update the sensor's checkInterval any time we get forcePolled - which should happen a MINIMUM of once per hour
// This because sensors don't necessarily update frequently, so this simple event should be enought to let the ST Health Checker know
// we are still alive!
if (forcePoll) {
sensorData << [checkInterval: 3900] // 5 minutes longer than an hour
}
if (forcePoll || (sensorData != [:])) {
sensorCollector[sensorDNI] = [name:it.name,data:sensorData]
atomicState."${sensorDNI}" = sensorList
if (debugLevelFour) LOG("updateSensorData() - sensorCollector being updated with sensorData: ${sensorData}", 4)
sNames += it.name
}
} // End sensor is valid and selected in settings
} // End [tid] sensors loop
} // End thermostats loop
atomicState.remoteSensorsData = sensorCollector
if (preText=='') {
def sz = sNames.size()
LOG("Device data updated for ${sz} sensor${sz!=1?'s':''}${sNames?' '+sNames:''}",2, null, 'info')
} else {
if (debugLevelFour) LOG("updateSensorData() - Updated these remoteSensors: ${sensorCollector}", 4, null, 'trace')
}
}
def updateThermostatData() {
// atomicState.timeOfDay = getTimeOfDay() // shouldn't need to do this - sunrise/sunset events are maintaining it
def runtimeUpdated = atomicState.runtimeUpdated
def thermostatUpdated = atomicState.thermostatUpdated
boolean usingMetric = wantMetric() // cache the value to save the function calls
def forcePoll = atomicState.forcePoll
if (forcePoll) {
if (atomicState.timeZone == null) atomicState.timeZone = getTimeZone()
if (atomicState.zipCode == null) atomicState.zipCode = getZipCode()
}
Integer apiPrecision = usingMetric ? 2 : 1 // highest precision available from the API
Integer userPrecision = getTempDecimals() // user's requested display precision
String preText = getDebugLevel() <= 2 ? '' : 'updateThermostatData() - '
def tstatNames = []
def thermostatsWithNames = atomicState.thermostatsWithNames
def askAlexaAppAlerts = atomicState.askAlexaAppAlerts
if (askAlexaAppAlerts == null) askAlexaAppAlerts = [:]
def needExtendedRuntime = false
def debugLevelFour = debugLevel(4)
atomicState.thermostats = atomicState.thermostatData.thermostatList.inject([:]) { collector, stat ->
// we use atomicState.thermostatData because it holds the latest Ecobee API response, from which we can determine which stats actually
// had updated data. Thus the following work is done ONLY for tstats that have updated data
def tid = stat.identifier
def DNI = [ app.id, stat.identifier ].join('.')
def tstatName = (thermostatsWithNames?.containsKey(DNI)) ? thermostatsWithNames[DNI] : null
if (tstatName == null) {
tstatName = getChildDevice(DNI)?.displayName // better than displaying 'null' as the name
}
if (debugLevel(3)) LOG("updateThermostatData() - Updating event data for thermostat ${tstatName} (${tid})", 3, null, 'info')
// grab a local copy from the atomic storage all at once (avoid repetive reads from backing store)
def es = atomicState.equipmentStatus
def equipStatus = (es && es[tid]) ? es[tid] : ''
if (equipStatus == '') equipStatus = 'idle'
def changeEquip = atomicState.changeEquip ? atomicState.changeEquip : [:]
def lastEquipStatus = (changeEquip && changeEquip[tid]) ? changeEquip[tid] : 'unknown'
def statSettings = atomicState.settings ? atomicState.settings[tid] : [:]
def program = atomicState.program ? atomicState.program[tid] : [:]
def events = atomicState.events ? atomicState.events[tid] : [:]
def runtime = atomicState.runtime ? atomicState.runtime[tid] : [:]
def statLocation = atomicState.statLocation ? atomicState.statLocation[tid] : [:]
def oemCfg = atomicState.oemCfg ? atomicState.oemCfg[tid] : [:]
def extendedRuntime = atomicState.extendedRuntime ? atomicState.extendedRuntime[tid] : [:]
// not worth it - weather is only accessed twice, and it is a LOT of data - we will access it directly from the atomicState cache
// def weather = atomicState.weather ? atomicState.weather[tid] : [:]
// Handle things that only change when runtime object is updated)
def occupancy = 'not supported'
Double tempTemperature
Double tempHeatingSetpoint
Double tempCoolingSetpoint
def tempWeatherTemperature
if (forcePoll || runtimeUpdated) {
// Occupancy (motion)
// TODO: Put a wrapper here based on the thermostat brand
def thermSensor = ''
if (atomicState.remoteSensors) { thermSensor = atomicState.remoteSensors[tid].find { it.type == 'thermostat' } }
def hasInternalSensors
if(!thermSensor) {
if (debugLevel(4)) LOG('updateThermostatData() - This thermostat does not have a built in remote sensor', 4, null, 'info')
hasInternalSensors = false
} else {
hasInternalSensors = true
if (debugLevel(4)) LOG("updateThermostatData() - thermSensor == ${thermSensor}", 4 )
def occupancyCap = thermSensor?.capability.find { it.type == 'occupancy' }
if (occupancyCap) {
if (debugLevel(4)) LOG("updateThermostatData() - occupancyCap = ${occupancyCap} value = ${occupancyCap.value}", 4, null, 'info')
// Check to see if there is even a value, not all types have a sensor
occupancy = occupancyCap.value ? occupancyCap.value : 'not supported'
}
}
// 'not supported' is reported as 'inactive'
if (hasInternalSensors) { occupancy = (occupancy == 'true') ? 'active' : ((occupancy == 'unknown') ? 'unknown' : 'inactive') }
// Temperatures
// NOTE: The thermostat always present Fahrenheit temps with 1 digit of decimal precision. We want to maintain that precision for inside temperature so that apps like vents and Smart Circulation
// can operate efficiently. So, while the user can specify a display precision of 0, 1 or 2 decimal digits, we ALWAYS keep and send max decimal digits and let the device handler adjust for display
// For Fahrenheit, we keep the 1 decimal digit the API provides, for Celsius we allow for 2 decimal digits as a result of the mathematical calculation
tempTemperature = myConvertTemperatureIfNeeded( (runtime.actualTemperature.toDouble() / 10.0), "F", apiPrecision)
Double tempHeatAt = runtime.desiredHeat.toDouble()
Double tempCoolAt = runtime.desiredCool.toDouble()
if ((equipStatus == 'idle') || (equipStatus == 'fan')) { // Show trigger point if idle; tile shows "Heating at 69.5" vs. "Heating to 70.0"
tempHeatAt = tempHeatAt - statSettings.stage1HeatingDifferentialTemp.toDouble()
tempCoolAt = tempCoolAt + statSettings.stage1CoolingDifferentialTemp.toDouble()
}
tempHeatingSetpoint = myConvertTemperatureIfNeeded( (tempHeatAt / 10.0), 'F', apiPrecision)
tempCoolingSetpoint = myConvertTemperatureIfNeeded( (tempCoolAt / 10.0), 'F', apiPrecision)
if (atomicState.weather && (atomicState.weather.size() > 0)) {
tempWeatherTemperature = myConvertTemperatureIfNeeded( ((atomicState.weather[tid].forecasts[0].temperature.toDouble() / 10.0)), "F", apiPrecision)
}
}
// handle[tid] things that only change when the thermostat object is updated
def heatHigh
def heatLow
def coolHigh
def coolLow
def heatRange
def coolRange
def hasHeatPump
def hasForcedAir
def hasElectric
def hasBoiler
def auxHeatMode
Double tempHeatDiff = 0.0
Double tempCoolDiff = 0.0
if (forcePoll || thermostatUpdated) {
tempHeatDiff = statSettings.stage1HeatingDifferentialTemp.toDouble() / 10.0
tempCoolDiff = statSettings.stage1CoolingDifferentialTemp.toDouble() / 10.0
// RANGES
// UI works better with the same ranges for both heat and cool...
// but the device handler isn't using these values for the UI right now (can't dynamically define the range)
heatHigh = myConvertTemperatureIfNeeded((statSettings.heatRangeHigh.toDouble() / 10.0), 'F', userPrecision)
heatLow = myConvertTemperatureIfNeeded((statSettings.heatRangeLow.toDouble() / 10.0), 'F', userPrecision)
coolHigh = myConvertTemperatureIfNeeded((statSettings.coolRangeHigh.toDouble() / 10.0), 'F', userPrecision)
coolLow = myConvertTemperatureIfNeeded((statSettings.coolRangeLow.toDouble() / 10.0), 'F', userPrecision)
// calculate these anyway (for now) - it's easier to read the range while debugging
heatRange = (heatLow && heatHigh) ? "(${Math.round(heatLow)}..${Math.round(heatHigh)})" : (usingMetric ? '(5..35)' : '(45..95)')
coolRange = (coolLow && coolHigh) ? "(${Math.round(coolLow)}..${Math.round(coolHigh)})" : (usingMetric ? '(5..35)' : '(45..95)')
// EQUIPMENT SPECIFICS
hasHeatPump = statSettings.hasHeatPump
hasForcedAir = statSettings.hasForcedAir
hasElectric = statSettings.hasElectric
hasBoiler = statSettings.hasBoiler
auxHeatMode = (hasHeatPump) && (hasForcedAir || hasElectric || hasBoiler) // auxHeat = emergencyHeat if using a heatPump
}
// handle things that depend on both thermostat and runtime objects
// EVENTS
// Determine if an Event is running, find the first running event (only changes when thermostat object is updated)
def runningEvent = [:]
String currentClimateName = ''
String currentClimateId = ''
String currentClimate = ''
def currentFanMode = ''
def climatesList = []
def statMode = statSettings.hvacMode
def fanMinOnTime = statSettings.fanMinOnTime
// what program is supposed to be running now?
String scheduledClimateId = 'unknown'
String scheduledClimateName = 'Unknown'
def schedClimateRef = null
if (program) {
scheduledClimateId = program.currentClimateRef
schedClimateRef = program.climates.find { it.climateRef == scheduledClimateId }
scheduledClimateName = schedClimateRef.name
program.climates?.each { climatesList += '"'+it.name+'"' } // read with: programsList = new JsonSlurper().parseText(theThermostat.currentValue('programsList'))
}
if (debugLevelFour) LOG( "scheduledClimateId: ${scheduledClimateId}, scheduledClimateName: ${scheduledClimateName}, climatesList: ${climatesList.toString()}", 4, null, 'info')
// check which program is actually running now
def vacationTemplate = false
if (events?.size()) {
events.each {
if (it.running == true) {
runningEvent = it
} else if ( it.type == 'template' ) { // templates never run...
vacationTemplate = true
}
}
}
// hack to fix ecobee bug where template is not created until first Vacation is created, which screws up resumeProgram() (last hold event is not deleted)
if (!vacationTemplate) {
LOG("No vacation template exists for ${tid}, creating one...", 2, null, 'warn')
def r = createVacationTemplate(getChildDevice(DNI), tid.toString())
if (debugLevel(3)) LOG("createVacationTemplate() returned ${r}", 3, null, 'trace')
}
// store the currently running event (in case we need to modify or delete it later, as in Vacation handling)
def tempRunningEvent = [:]
tempRunningEvent[tid] = runningEvent ? runningEvent : [:]
if (atomicState.runningEvent) tempRunningEvent = atomicState.runningEvent + tempRunningEvent
atomicState.runningEvent = tempRunningEvent
def thermostatHold = ''
String holdEndsAt = ''
def isConnected = runtime?.connected
if (!isConnected) {
LOG("${tstatName} is not connected!",2,null,'warn')
// Ecobee Cloud lost connection with the thermostat - device, wifi, power or network outage
currentClimateName = 'Offline'
currentClimate = currentClimateName
currentClimateId = 'offline'
occupancy = 'unknown'
// Oddly, the runtime.lastModified is returned in UTC, so we have to convert it to the time zone of the thermostat
def myTimeZone = statLocation?.timeZone ? TimeZone.getTimeZone(statLocation.timeZone) : (location.timeZone?: TimeZone.getTimeZone('UTC'))
String localLastModified = new Date().parse('yyyy-MM-dd HH:mm:ss',runtime.disconnectDateTime).format('yyyy-MM-dd HH:mm:ss', myTimeZone)
// In this case, holdEndsAt is actually the time of the last valid runtime update we have received...
holdEndsAt = fixDateTimeString( localLastModified.take(10), localLastModified.drop(11), stat.thermostatTime )
if (settings.askAlexa) {
// Let's send an Alert for this as well
def exHours = settings.expire ? settings.expire as int : 0
def exSec=exHours * 3600
// Avoid saying "Your Downstairs Thermostat thermostat" or even "Your Downstairs Ecobee Thermostat"
String append = ' thermostat'
String tName = tstatName.toLowerCase().trim()
if (tName.endsWith('thermostat') || tName.endsWith('ecobee')) append = ''
tName = tstatName + append
String messageID = tid.toString()+':disconnected'
String translatedMessage = "Your ${tName} reported a Connectivity Alert: Ecobee Cloud connection lost ${holdEndsAt}. Please check HVAC power, wifi, and network connection."
if (debugLevelFour) LOG("Sending ${translatedMessage} to Ask Alexa",4,null,'trace')
sendLocationEvent(name: "AskAlexaMsgQueue", value: "Ecobee Status", unit: messageID, isStateChange: true,
descriptionText: translatedMessage, data: [ queues: settings.listOfMQs, overwrite: true, expires: exSec, suppressTimeDate: true ])
def newAlexaAppAlerts = [:]
newAlexaAppAlerts[tid] = [messageID]
if (askAlexaAppAlerts) newAlexaAppAlerts = askAlexaAppAlerts + newAlexaAppAlerts
atomicState.askAlexaAppAlerts = newAlexaAppAlerts
}
} else if (settings.askAlexa) {
// We are connected, and Ask Alexa is enabled - check if we need to delete a prior Ask Alexa Alert
if (askAlexaAppAlerts && askAlexaAppAlerts[tid] && (askAlexaAppAlerts[tid] != [])) {
def msgID = tid.toString+':disconnected'
if (askAlexaAppAlerts[tid].contains(msgID)) {
sendLocationEvent( name: "AskAlexaMsgQueueDelete", value: "Ecobee Status", unit: msgID.toString(), isStateChange: true,
data: [ queues: settings.listOfMQs ])
askAlexaAppAlerts[tid] = askAlexaAppAlerts[tid] - [msgId]
atomicState.askAlexaAppAlerts = askAlexaAppAlerts
}
}
}
if (runningEvent && isConnected) {
holdEndsAt = fixDateTimeString( runningEvent.endDate, runningEvent.endTime, stat.thermostatTime)
thermostatHold = runningEvent.type
if (debugLevelFour) LOG("Found a running Event: ${runningEvent}", 4)
String tempClimateRef = runningEvent.holdClimateRef ? runningEvent.holdClimateRef : ''
switch (runningEvent.type) {
case 'hold':
if (tempClimateRef != '') {
currentClimate = (program.climates.find { it.climateRef == tempClimateRef }).name
currentClimateName = 'Hold: ' + currentClimate
} else if (runningEvent.name == 'auto') { // Handle the "auto" climates (includes fan on override, and maybe the Smart Recovery?)
if ((statMode == "heat") && (runningEvent.fan != schedClimateRef.heatFan)) {
currentClimateName = 'Hold: Fan'
} else if ((statMode == 'cool') && (runningEvent.fan != schedClimateRef.coolFan)) {
currentClimateName = 'Hold: Fan'
} else {
currentClimateName = 'Auto'
}
} else if (runningEvent.name == 'hold') { // just a temperature hold, probably from the keypad
// currentClimateName = 'Hold: ' + (statMode == 'heat' ? tempHeatingSetpoint : (statMode == 'cool' ? tempCoolingSetpoint : '??')) + '°'
currentClimateName = 'Hold'
}// if we can't tell which hold is in effect, leave currentClimate, currentClimateName and currentClimateId blank/null/empty
break;
case 'vacation':
currentClimateName = 'Vacation'
fanMinOnTime = runningEvent.fanMinOnTime
break;
case 'quickSave':
currentClimateName = 'Quick Save'
break;
case 'autoAway':
currentClimateName = 'Auto Away'
break;
case 'autoHome':
currentClimateName = 'Auto Home'
break;
default:
currentClimateName = runningEvent.type
break;
}
currentClimateId = tempClimateRef
} else if (isConnected) {
if (scheduledClimateId) {
currentClimateId = scheduledClimateId
currentClimateName = scheduledClimateName
currentClimate = scheduledClimateName
} else {
LOG(preText+'Program or running Event missing', 1, null, 'warn')
currentClimateName = ''
currentClimateId = ''
currentClimate = ''
}
}
if (debugLevel(4)) LOG("updateThermostatData() - currentClimateName set = ${currentClimateName} currentClimateId set = ${currentClimateId}", 4, null, 'info')
if (runningEvent) {
currentFanMode = atomicState.circulateFanModeOn ? "circulate" : atomicState.offFanModeOn ? "off" : runningEvent.fan
} else {
currentFanMode = runtime.desiredFanMode
}
// HUMIDITY
def humiditySetpoint = 0
def humidity = runtime.desiredHumidity
def dehumidity = runtime.desiredDehumidity
def hasHumidifier = (statSettings?.hasHumidifier && (statSettings?.humidifierMode != 'off'))
def hasDehumidifier = ((statSettings?.dehumidifierMode == 'on') && (statSettings?.hasDehumidifier ||
(statSettings?.dehumidifyWithAC && (statSettings?.dehumidifyOvercoolOffset != 0)))) // fortunately, we can hide the details from the device handler
if (hasHumidifier && (extendedRuntime && extendedRuntime.desiredHumidity && extendedRuntime.desiredHumidity[2])) {
humidity = extendedRuntime.desiredHumidity[2] // if supplied, extendedRuntime gives the actual target (Frost Control)
}
if (hasDehumidifier && (extendedRuntime && extendedRuntime.desiredDehumidity && extendedRuntime.desiredDehumidity[2])) {
dehumidity = extendedRuntime.desiredDehumidity[2]
}
switch (statMode) {
case 'heat':
if (hasHumidifier) humiditySetpoint = humidity
break;
case 'cool':
if (hasDehumidifier) humiditySetpoint = dehumidity
break;
case 'auto':
if (hasHumidifier && hasDehumidifier) { humiditySetpoint = "${humidity}-${dehumidity}" }
else if (hasHumidifier) { humiditySetpoint = humidity }
else if (hasDehumidifier) { humiditySetpoint = dehumidity }
break;
}
// EQUIPMENT STATUS
def heatStages = statSettings.heatStages
def coolStages = statSettings.coolStages
String equipOpStat = 'idle' // assume we're idle - this gets fixed below if different
String thermOpStat = 'idle'
Boolean isHeating = false
Boolean isCooling = false
Boolean smartRecovery = false
// Let's deduce if we are in Smart Recovery mode
if (equipStatus.contains('ea')) {
isHeating = true
if ((statSettings?.disablePreHeating == false) && (tempTemperature > (tempHeatingSetpoint + tempHeatDiff))) {
smartRecovery = true
equipStatus = equipStatus + ',smartRecovery'
}
} else if (equipStatus.contains('oo')) {
isCooling = true
if ((statSettings?.disablePreCooling == false) && (tempTemperature < (tempCoolingSetpoint - tempCoolDiff))) {
smartRecovery = true
equipStatus = equipStatus + ',smartRecovery'
}
}
if (forcePoll || (equipStatus != lastEquipStatus)) {
if (equipStatus == 'fan') {
equipOpStat = 'fan only'
thermOpStat = equipOpStat
} else if (isHeating) { // heating
thermOpStat = 'heating'
equipOpStat = 'heating'
if (smartRecovery) thermOpStat = 'heating (smart recovery)'
// Figure out which stage we are running now
if (equipStatus.contains('t1')) { equipOpStat = (auxHeatMode) ? 'emergency' : (heatStages > 1) ? 'heat 1' : 'heating' }
else if (equipStatus.contains('t2')) { equipOpStat = 'heat 2' }
else if (equipStatus.contains('t3')) { equipOpStat = 'heat 3' }
else if (equipStatus.contains('p2')) { equipOpStat = 'heat pump 2' }
else if (equipStatus.contains('p3')) { equipOpStat = 'heat pump 3' }
else if (equipStatus.contains('mp')) { equipOpStat = 'heat pump' }
// We now have icons that depict when we are humidifying or dehumidifying...
if (equipStatus.contains('hu')) { equipOpStat += ' hum' } // humidifying if heat
} else if (isCooling) { // cooling
thermOpStat = 'cooling'
equipOpStat = 'cooling'
if (smartRecovery) thermOpStat = 'cooling (smart recovery)'
if (equipStatus.contains('l1')) { equipOpStat = (coolStages == 1) ? 'cooling' : 'cool 1' }
else if (equipStatus.contains('l2')) { equipOpStat = 'cool 2' }
if (equipStatus.contains('de')) { equipOpStat += ' deh' } // dehumidifying if cool
} else if (equipStatus.contains('de')) { // These can also run independent of heat/cool
equipOpStat = 'dehumidifier'
} else if (equipStatus.contains('hu')) {
equipOpStat = 'humidifier'
} // also: economizer, ventilator, compHotWater, auxHotWater
}
// Update the API link state and the lastPoll data. If we aren't running at a high debugLevel >= 4, then supply simple
// poll status instead of the date/time (this simplifies the UI presentation, and reduces the chatter in the devices'
// message log
def apiConnection = apiConnected()
def lastPoll = (debugLevel(4)) ? atomicState.lastPollDate : (apiConnection=='full') ? 'Succeeded' : (apiConnection=='warn') ? 'Incomplete' : 'Failed'
def statusMsg
if (isConnected) {
statusMsg = (holdEndsAt == '') ? '' : ((thermostatHold=='hold') ? 'Hold' : (thermostatHold=='vacation') ? 'Vacation' : 'Event')+" ends ${holdEndsAt}"
} else {
statusMsg = "Last updated ${holdEndsAt}"
equipOpStat = 'offline' // override if Ecobee Cloud has lost connection with the thermostat
thermOpStat = 'offline'
}
// Okey dokey - time to queue all this data into the atomicState.thermostats queue
def changeCloud = atomicState.changeCloud ? atomicState.changeCloud : [:]
def changeConfig = atomicState.changeConfig ? atomicState.changeConfig : [:]
def changeNever = atomicState.changeNever ? atomicState.changeNever : [:]
def changeRarely = atomicState.changeRarely ? atomicState.changeRarely : [:]
def changeOften = atomicState.changeOften ? atomicState.changeOften : [:]
//changeEquip was initialized earlier
def data = [:]
// Runtime stuff that changes most frequently - we test them 1 at a time, and send only the ones that change
// Send these first, as they generally are the reason anything else changes (so the thermostat's notification log makes sense)
if (forcePoll || runtimeUpdated) {
String wSymbol = atomicState.weather[tid]?.forecasts[0]?.weatherSymbol?.toString()
def oftenList = [tempTemperature,occupancy,runtime.actualHumidity,tempHeatingSetpoint,tempCoolingSetpoint,wSymbol,tempWeatherTemperature,humiditySetpoint,userPrecision]
def lastOList = []
lastOList = changeOften[tid]
if (forcePoll || !lastOList || (lastOList.size() < 8)) lastOList = [999,"x",-1,-1,-1,-999,-999,-1]
if (lastOList[0] != tempTemperature) data += [temperature: String.format("%.${apiPrecision}f", tempTemperature.toDouble().round(apiPrecision)),]
if (lastOList[1] != occupancy) data += [motion: occupancy,]
if (lastOList[2] != runtime.actualHumidity) data += [humidity: runtime.actualHumidity,]
// send these next two also when the userPrecision changes
if ((lastOList[3] != tempHeatingSetpoint) || (lastOList[6] != userPrecision)) data += [heatingSetpoint: String.format("%.${userPrecision}f", tempHeatingSetpoint?.toDouble().round(userPrecision)),]
if ((lastOList[4] != tempCoolingSetpoint) || (lastOList[6] != userPrecision)) data += [coolingSetpoint: String.format("%.${userPrecision}f", tempCoolingSetpoint?.toDouble().round(userPrecision)),]
if ((lastOList[5] != wSymbol) && isConnected) data += [weatherSymbol: wSymbol] // only update weather if we are still connected - else we need to be silent
if ((lastOList[6] != tempWeatherTemperature) && isConnected) data += [weatherTemperature: String.format("%.${userPrecision}f", tempWeatherTemperature?.toDouble().round(userPrecision)),]
if (lastOList[7] != humiditySetpoint) data += [humiditySetpoint: humiditySetpoint,]
changeOften[tid] = oftenList
atomicState.changeOften = changeOften
}
// API link to Ecobee's Cloud status - doesn't change unless things get broken
Integer pollingInterval = getPollingInterval() // if this changes, we recalculate checkInterval for Health Check
def cloudList = [lastPoll,apiConnection,pollingInterval,isConnected]
if (forcePoll || (changeCloud == [:]) || !changeCloud.containsKey(tid) || (changeCloud[tid] != cloudList)) {
def checkInterval = (pollingInterval <= 5) ? (12*60) : (((pollingInterval+1)*2)*60)
if (checkInterval > 3600) checkInterval = 3900 // 5 minutes longer than an hour
data += [
lastPoll: lastPoll,
apiConnected: apiConnection, // link from ST to Ecobee
ecobeeConnected: isConnected, // link from Ecobee to Thermostat
checkInterval: checkInterval,
]
changeCloud[tid] = cloudList
atomicState.changeCloud = changeCloud
}
// SmartApp configuration settings that almost never change (Listed in order of frequency that they should change normally)
Integer dbgLevel = getDebugLevel()
String tmpScale = getTemperatureScale()
def timeOfDay = atomicState.timeZone ? getTimeOfDay() : getTimeOfDay(tid)
// log.debug "timeOfDay: ${timeOfDay}"
def configList = [timeOfDay,userPrecision,dbgLevel,tmpScale]
if (forcePoll || (changeConfig == [:]) || !changeConfig.containsKey(tid) || (changeConfig[tid] != configList)) {
data += [
timeOfDay: timeOfDay,
decimalPrecision: userPrecision,
temperatureScale: tmpScale,
debugLevel: dbgLevel,
]
changeConfig[tid] = configList
atomicState.changeConfig = changeConfig
}
// Equipment operating status - I *think* this can change with either thermostat or runtime changes
if (forcePoll || (lastEquipStatus != equipStatus)) {
data += [
equipmentStatus: equipStatus,
thermostatOperatingState: thermOpStat,
equipmentOperatingState: equipOpStat,
]
changeEquip[tid] = equipStatus
atomicState.changeEquip = changeEquip
}
// Thermostat configuration settinngs
if (forcePoll || thermostatUpdated) { // new settings, programs or events
def autoMode = statSettings?.autoHeatCoolFeatureEnabled
// Thermostat configuration stuff that almost never changes - if any one changes, send them all
def neverList = [statMode,autoMode,coolStages,heatStages,heatHigh,heatLow,coolHigh,coolLow,heatRange,coolRange,climatesList,
hasHeatPump,hasForcedAir,hasElectric,hasBoiler,auxHeatMode,hasHumidifier,hasDehumidifier,tempHeatDiff,tempCoolDiff]
if (forcePoll || (changeNever == [:]) || !changeNever.containsKey(tid) || (changeNever[tid] != neverList)) {
def heatDiff = String.format("%.${apiPrecision}f", tempHeatDiff.toDouble().round(apiPrecision))
def coolDiff = String.format("%.${apiPrecision}f", tempCoolDiff.toDouble().round(apiPrecision))
data += [
coolMode: (coolStages > 0),
coolStages: coolStages,
heatMode: (heatStages > 0),
heatStages: heatStages,
autoMode: autoMode,
thermostatMode: statMode,
heatRangeHigh: heatHigh,
heatRangeLow: heatLow,
coolRangeHigh: coolHigh,
coolRangeLow: coolLow,
heatRange: heatRange,
coolRange: coolRange,
programsList: climatesList,
hasHeatPump: hasHeatPump,
hasForcedAir: hasForcedAir,
hasElectric: hasElectric,
hasBoiler: hasBoiler,
auxHeatMode: auxHeatMode,
hasHumidifier: hasHumidifier,
hasDehumidifier: hasDehumidifier,
heatDifferential: heatDiff,
coolDifferential: coolDiff,
]
changeNever[tid] = neverList
atomicState.changeNever = changeNever
}
// Thermostat operational things that rarely change, (a few times a day at most)
def rarelyList = [fanMinOnTime,isConnected,thermostatHold,holdEndsAt,statusMsg,currentClimateName,currentClimateId,scheduledClimateName,
scheduledClimateId,currentFanMode]
if (forcePoll || (changeRarely == [:]) || !changeRarely.containsKey(tid) || (changeRarely[tid] != rarelyList)) {
data += [
thermostatHold: thermostatHold,
holdEndsAt: holdEndsAt,
holdStatus: statusMsg,
currentProgramName: currentClimateName,
currentProgramId: currentClimateId,
currentProgram: currentClimate,
scheduledProgramName: scheduledClimateName,
scheduledProgramId: scheduledClimateId,
scheduledProgram: scheduledClimateName,
thermostatFanMode: currentFanMode,
fanMinOnTime: fanMinOnTime,
]
changeRarely[tid] = rarelyList
atomicState.changeRarely = changeRarely
// Save the Program when it changes so that we can get to it easily for the child sensors
def tempProgram = [:]
tempProgram[tid] = currentClimateName
if (atomicState.currentProgramName) tempProgram = atomicState.currentProgramName + tempProgram
atomicState.currentProgramName = tempProgram
}
}
if (debugLevelFour) LOG("updateThermostatData() - Event data updated for thermostat ${tstatName} (${tid}) = ${data}", 4, null, 'trace')
// it is possible that thermostatSummary indicated things have changed that we don't care about...
if (data != [:]) {
data += [ thermostatTime:stat.thermostatTime ]
// def DNI = [ app.id, stat.identifier ].join('.')
// def tstatName = (thermostatsWithNames?.containsKey(DNI)) ? thermostatsWithNames[DNI] : null
// if (tstatName == null) {
// tstatName = getChildDevice(DNI)?.displayName // better than displaying 'null' as the name
// }
tstatNames += [tstatName]
collector[DNI] = [thermostatId:tid, data:data]
}
return collector
}
Integer nSize = tstatNames.size()
LOG("${preText}Device data updated for ${nSize} thermostat${nSize!=1?'s':''} ${nSize!=0?tstatNames:''}", 2, null, 'info')
}
def getChildThermostatDeviceIdsString(singleStat = null) {
def debugLevelFour = debugLevel(4)
if(!singleStat) {
if (debugLevelFour) LOG('getChildThermostatDeviceIdsString() - !singleStat returning the list for all thermostats', 4, null, 'info')
return thermostats.collect { it.split(/\./).last() }.join(',')
} else {
// Only return the single thermostat
if (debugLevelFour) LOG('Only have a single stat.', 4, null, 'debug')
def ecobeeDevId = singleStat.device.deviceNetworkId.split(/\./).last()
if (debugLevelFour) LOG("Received a single thermostat, returning the Ecobee Device ID as a String: ${ecobeeDevId}", 4, null, 'info')
return ecobeeDevId as String
}
}
def toQueryString(Map m) {
return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
}
private refreshAuthToken(child=null) {
if (debugLevel(5)) LOG('Entered refreshAuthToken()', 5)
def timeBeforeExpiry = atomicState.authTokenExpires ? atomicState.authTokenExpires - now() : 0
// check to see if token was recently refreshed (eliminate multiple concurrent threads)
if (timeBeforeExpiry > 2000) {
LOG("refreshAuthToken() - skipping, token expires in ${timeBeforeExpiry/1000} seconds",3,null,'info')
return true
}
atomicState.lastTokenRefresh = now()
atomicState.lastTokenRefreshDate = getTimestamp()
if (!atomicState.refreshToken) {
LOG('refreshAuthToken() - There is no refreshToken stored! Unable to refresh OAuth token', 1, child, "error")
apiLost("refreshAuthToken() - No refreshToken")
return false
} else {
LOG('Performing a refreshAuthToken()', 4)
def refreshParams = [
method: 'POST',
uri : apiEndpoint,
path : '/token',
query : [grant_type: 'refresh_token', code: "${atomicState.refreshToken}", client_id: smartThingsClientId],
]
LOG("refreshParams = ${refreshParams}", 4)
def jsonMap
try {
httpPost(refreshParams) { resp ->
LOG("Inside httpPost resp handling.", 4, child, "trace")
if(resp.status == 200) {
LOG('refreshAuthToken() - 200 Response received - Extracting info.', 4, child, 'trace' )
atomicState.reAttempt = 0
apiRestored()
generateEventLocalParams() // Update the connected state at the thermostat devices
jsonMap = resp.data // Needed to work around strange bug that wasn't updating state when accessing resp.data directly
LOG("resp.data = ${resp.data} -- jsonMap is? ${jsonMap}", 4, child)
if(jsonMap) {
LOG("resp.data == ${resp.data}, jsonMap == ${jsonMap}", 4, child)
atomicState.refreshToken = jsonMap.refresh_token
// TODO - Platform BUG: This was not updating the state values for some reason if we use resp.data directly???
// Workaround using jsonMap for authToken
LOG("atomicState.authToken before: ${atomicState.authToken}", 4, child, "trace")
def oldAuthToken = atomicState.authToken
atomicState.authToken = jsonMap?.access_token
LOG("atomicState.authToken after: ${atomicState.authToken}", 4, child, "trace")
if (oldAuthToken == atomicState.authToken) {
LOG('WARN: atomicState.authToken did NOT update properly! This is likely a transient problem', 1, child, 'warn')
}
// Save the expiry time for debugging purposes
atomicState.authTokenExpires = (resp?.data?.expires_in * 1000) + now()
LOG("refreshAuthToken() - Success! Token expires in ${String.format("%.2f",resp?.data?.expires_in/60)} minutes", 3, child, 'info')
if (debugLevel(4)) {
LOG("Updated state.authTokenExpires = ${atomicState.authTokenExpires}", 4, child, 'trace')
LOG("Refresh Token = state =${atomicState.refreshToken} == in: ${resp?.data?.refresh_token}", 4, child, 'trace')
LOG("OAUTH Token = state ${atomicState.authToken} == in: ${resp?.data?.access_token}", 4, child, 'trace')
}
def action = atomicState.action
// Reset saved action
atomicState.action = ''
if (action) { // && atomicState.action != "") {
LOG("Token refreshed. Rescheduling aborted action: ${action}", 4, child, 'trace')
runIn( 5, "${action}", [overwrite: true]) // this will collapse multiple threads back into just one
}
} else {
LOG("No jsonMap??? ${jsonMap}", 2, child, 'trace')
}
return true
} else {
LOG("refreshAuthToken() - Failed ${resp.status} : ${resp.status.code}!", 1, child, 'error')
return false
}
}
} catch (groovyx.net.http.HttpResponseException e) {
def result = false
//LOG("refreshAuthToken() - HttpResponseException occurred. Exception info: ${e} StatusCode: ${e.statusCode} response? data: ${e.getResponse()?.getData()}", 1, null, "error")
LOG("refreshAuthToken() - HttpResponseException occurred. Exception info: ${e} StatusCode: ${e.statusCode}", 1, child, "error")
if (e.statusCode != 401) {
runIn(atomicState.reAttemptInterval, "refreshAuthToken", [overwrite: true])
} else if (e.statusCode == 401) {
atomicState.reAttempt = atomicState.reAttempt + 1
if (atomicState.reAttempt > 3) {
apiLost("Too many retries (${atomicState.reAttempt - 1}) for token refresh.")
result = false
} else {
LOG("Setting up runIn for refreshAuthToken", 4, child)
if ( canSchedule() ) {
runIn(atomicState.reAttemptInterval, "refreshAuthToken", [overwrite: true])
} else {
LOG("Unable to schedule refreshAuthToken, running directly", 4, child)
result = refreshAuthToken(child)
}
}
}
generateEventLocalParams() // Update the connected state at the thermostat devices
return result
} catch (java.util.concurrent.TimeoutException e) {
LOG("refreshAuthToken(), TimeoutException: ${e}.", 1, child, "error")
// Likely bad luck and network overload, move on and let it try again
if(canSchedule()) { runIn(atomicState.reAttemptInterval, "refreshAuthToken", [overwrite: true]) } else { refreshAuthToken() }
return false
} catch (Exception e) {
LOG("refreshAuthToken(), General Exception: ${e}.", 1, child, "error")
/*
atomicState.reAttempt = atomicState.reAttempt + 1
if (atomicState.reAttempt > 3) {
apiLost("Too many retries (${atomicState.reAttempt - 1}) for token refresh.")
return false
} else {
if ( canSchedule() ) {
// atomicState.connected = "warn"
runIn(atomicState.reAttemptInterval, "refreshAuthToken")
} else {
LOG("Unable to schedule refreshAuthToken, running directly", 2, child, "warn")
// atomicState.connected = "warn"
refreshAuthToken(child)
}
} */
return false
}
}
}
def resumeProgram(child, String deviceId, resumeAll=true) {
LOG("Entered resumeProgram for deviceId: ${deviceId} with child ${child}", 2, child, 'trace')
def result = true
String allStr = resumeAll ? 'true' : 'false'
def previousFanMinOnTime = atomicState."previousFanMinOnTime${deviceId}"
def currentFanMinOnTime = getFanMinOnTime(child)
def previousHVACMode = atomicState."previousHVACMode${deviceId}"
def currentHVACMode = getHVACMode(child)
// def currentHoldType = child.currentValue("thermostatHold") // TODO: If we are in a vacation hold, need to delete the vacation
// theoretically, if currentHoldType == "", we can skip all of this...theoretically
if (debugLevel(3)) LOG("resumeProgram() - atomicState.previousHVACMode = ${previousHVACMode}, currentHVACMode = ${currentHVACMode} atomicState.previousFanMinOnTime = ${previousFanMinOnTime} current (${currentFanMinOnTime})", 3, child, 'info')
if ((previousHVACMode != null) && (currentHVACMode != previousHVACMode)) {
// Need to reset the HVAC Mode back to the previous state
if (currentHVACMode == "off") { atomicState.offFanModeOn = false }
if (currentHVACMode == "circulate") { atomicState.circulateFanModeOn = false }
//LOG("getHVACMode(child) != atomicState.previousHVACMode${deviceId} (${previousHVACMode})", 5, child, "trace")
result = setHVACMode(child, deviceId, previousHVACMode)
}
if ((previousFanMinOnTime != null) && (currentFanMinOnTime != previousFanMinOnTime)) {
// Need to reset the fanMinOnTime back to the previous settings
LOG("getFanMinOnTime(child) != atomicState.previousFanMinOnTime${deviceId} (${previousFanMinOnTime})", 5, child, "trace")
def fanResult = setFanMinOnTime(child, deviceId, previousFanMinOnTime)
result = result && fanResult
}
// {"functions":[{"type":"resumeProgram"}],"selection":{"selectionType":"thermostats","selectionMatch":"YYY"}}
def jsonRequestBody = '{"functions":[{"type":"resumeProgram"}],"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '","resumeAll":"' + allStr + '"}}'
LOG("jsonRequestBody = ${jsonRequestBody}", 4, child)
result = sendJson(jsonRequestBody) && result
LOG("resumeProgram(${resumeAll}) returned ${result}", 3, child,'info')
atomicState."previousHVACMode${deviceId}" = null
atomicState."previousFanMinOnTime${deviceId}" = null
return result
}
def setHVACMode(child, deviceId, mode) {
LOG("setHVACMode(${mode})", 4, child)
def thermostatSettings = ',"thermostat":{"settings":{"hvacMode":"'+mode+'"}}'
def thermostatFunctions = ''
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '"},"functions":['+thermostatFunctions+']'+thermostatSettings+'}'
def result = sendJson(child, jsonRequestBody)
LOG("setHVACMode(${mode}) returned ${result}", 3, child,'info')
return result
}
def setFanMinOnTime(child, deviceId, howLong) {
LOG("setFanMinOnTime(${howLong})", 4, child)
if (!howLong.isNumber() || (howLong.toInteger() < 0) || howLong.toInteger() > 55) {
LOG("setFanMinOnTime() - Invalid Argument ${howLong}",4,child,'warn')
return false
}
def thermostatSettings = ',"thermostat":{"settings":{"fanMinOnTime":'+howLong+'}}'
def thermostatFunctions = ''
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '"},"functions":['+thermostatFunctions+']'+thermostatSettings+'}'
def result = sendJson(child, jsonRequestBody)
LOG("setFanMinOnTime(${howLong}) returned ${result}", 4, child,'info')
return result
}
def setVacationFanMinOnTime(child, deviceId, howLong) {
LOG("setVacationFanMinOnTime(${howLong})", 4, child)
if (!howLong.isNumber() || (howLong.toInteger() < 0) || howLong.toInteger() > 55) { // Documentation says 60 is the max, but the Ecobee3 thermostat maxes out at 55 (makes 60 = 0)
LOG("setVacationFanMinOnTime() - Invalid Argument ${howLong}",4,child,'warn')
return false
}
def evt = atomicState.runningEvent[deviceId]
def hasEvent = true
if (!evt) {
hasEvent = false // no running event defined
} else {
if (evt.running != true) hasEvent = false // shouldn't have saved it if it wasn't running
if (hasEvent && (evt.type != "vacation")) hasEvent = false // we only override vacation fanMinOnTime setting
}
if (!hasEvent) {
LOG("setVacationFanMinOnTime() - Vacation not active on thermostatId ${deviceId}", 4, child, 'warn')
return false
}
if (evt.fanMinOnTime.toInteger() == howLong.toInteger()) return true // didn't need to do anything!
if (deleteVacation(child, deviceId, evt.name)) { // apparently on order to change something in a vacation, we have to delete it and then re-create it..
def thermostatSettings = ''
def thermostatFunctions = '{"type":"createVacation","params":{"name":"' + evt.name + '","coolHoldTemp":"' + evt.coolHoldTemp + '","heatHoldTemp":"' + evt.heatHoldTemp +
'","startDate":"' + evt.startDate + '","startTime":"' + evt.startTime + '","endDate":"' + evt.endDate + '","endTime":"' + evt.endTime +
'","fan":"' + evt.fan + '","fanMinOnTime":"' + "${howLong}" + '"}}'
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '"},"functions":['+thermostatFunctions+']'+thermostatSettings+'}'
LOG("before sendJson() jsonRequestBody: ${jsonRequestBody}", 4, child, "info")
def result = sendJson(child, jsonRequestBody)
LOG("setVacationFanMinOnTime(${howLong}) returned ${result}", 4, child, 'info')
return result
}
}
private def createVacationTemplate(child, deviceId) {
String vacationName = 'tempVac810n'
// delete any old temporary vacation that we created
deleteVacation(child, deviceId, vacationName)
// Create the temporary vacation
def thermostatSettings = ''
def thermostatFunctions = '{"type":"createVacation","params":{"name":"' + vacationName +
'","coolHoldTemp":"850","heatHoldTemp":"550","startDate":"2034-01-01","startTime":"08:30:00","endDate":"2034-01-01","endTime":"09:30:00","fan":"auto","fanMinOnTime":"5"}}'
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '"},"functions":['+thermostatFunctions+']'+thermostatSettings+'}'
LOG("before sendJson() jsonRequestBody: ${jsonRequestBody}", 4, child, 'trace')
def result = sendJson(child, jsonRequestBody)
LOG("createVacationTemplate(${vacationName}) returned ${result}", 4, child, 'trace')
// Now, delete the temporary vacation
result = deleteVacation(child, deviceId, vacationName)
LOG("deleteVacation(${vacationName}) returned ${result}", 4, child, 'trace')
return true
}
def deleteVacation(child, deviceId, vacationName=null ) {
def vacaName = vacationName
if (!vacaName) { // no vacationName specified, let's find out if one is currently running
def evt = atomicState.runningEvent[deviceId]
if (!evt || (evt.running != true) || (evt.type != "vacation") || !evt.name) {
LOG("deleteVacation() - Vacation not active on thermostatId ${deviceId}", 4, child, 'warn')
return true // Asked us to delete the current vacation, and there isn't one - I'd still call that a success!
}
vacaName = evt.name as String // default names are Very Big Numbers
}
def thermostatSettings = ''
def thermostatFunctions = '{"type":"deleteVacation","params":{"name":"' + vacaName + '"}}'
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '"},"functions":['+thermostatFunctions+']'+thermostatSettings+'}'
def result = sendJson(child, jsonRequestBody)
LOG("deleteVacation() returned ${result}", 4, child,'info')
return result
}
def setHold(child, heating, cooling, deviceId, sendHoldType=null, fanMode="", extraParams=[]) {
int h = (getTemperatureScale() == "C") ? (cToF(heating) * 10) : (heating * 10)
int c = (getTemperatureScale() == "C") ? (cToF(cooling) * 10) : (cooling * 10)
LOG("setHold() - h: ${heating}(${h}), c: ${cooling}(${c}), ${sendHoldType}", 3, child, 'info')
if (fanMode != "") {
tstatSettings << [fan:"${fanMode}"]
}
if (extraParams != []) {
tstatSettings << extraParams
}
def theHoldType = sendHoldType ? sendHoldType : whatHoldType()
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '"},"functions":[{"type":"setHold","params":{"coolHoldTemp":"' + c + '","heatHoldTemp":"' + h + '","holdType":"' + theHoldType + '"}}]}'
LOG("about to sendJson with jsonRequestBody (${jsonRequestBody}", 4, child)
def result = sendJson(child, jsonRequestBody)
LOG("setHold() returned ${result}", 4, child,'info')
return result
}
def setMode(child, mode, deviceId) {
LOG("setMode(${mode}) for ${deviceId}", 5, child)
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '"},"thermostat":{"settings":{"hvacMode":"'+"${mode}"+'"}}}'
// {"selection":{"selectionType":"thermostats","selectionMatch":"XXX"}, "thermostat":{"settings":{"hvacMode":"cool"}}}
LOG("Mode Request Body = ${jsonRequestBody}", 4, child)
def result = sendJson(jsonRequestBody)
LOG("setMode to ${mode} with result ${result}", 4, child)
if (result) {
LOG("setMode(${mode}) returned ${result}", 4, child, "info")
child.generateQuickEvent("thermostatMode", mode, 15)
} else {
LOG("setMode(${mode}) - Failed", 1, child, "warn")
}
return result
}
def setFanMode(child, fanMode, deviceId, sendHoldType=null) {
LOG("setFanMode(${fanMode}) for DeviceID: ${deviceId}", 5, child)
// These values are ignored anyway when setting the fan
def h = child.device.currentValue("heatingSetpoint")
def c = child.device.currentValue("coolingSetpoint")
def theHoldType = sendHoldType ? sendHoldType : whatHoldType()
// Per this thread: http://developer.ecobee.com/api/topics/qureies-related-to-setfan
// def extraParams = [isTemperatureRelative: "false", isTemperatureAbsolute: "false"]
def thermostatSettings = ''
def thermostatFunctions = ''
if (fanMode == "circulate") {
fanMode = "auto"
LOG("fanMode == 'circulate'", 5, child, "trace")
// Add a minimum circulate time here
// TODO: Need to capture the previous fanMinOnTime and return to that value when the mode is changed again?
atomicState."previousFanMinOnTime${deviceId}" = getFanMinOnTime(child)
atomicState."previousHVACMode${deviceId}" = getHVACMode(child)
atomicState.circulateFanModeOn = true
atomicState.offFanModeOn = false
thermostatSettings = ',"thermostat":{"settings":{"fanMinOnTime":15}}'
thermostatFunctions = '{"type":"setHold","params":{"coolHoldTemp":"' + c + '","heatHoldTemp":"' + h + '","holdType":"' + theHoldType + '","fan":"'+fanMode+'","isTemperatureAbsolute":false,"isTemperatureRelative":false}}'
} else if (fanMode == "off") {
// How to turn off the fan http://developer.ecobee.com/api/topics/how-to-turn-fan-off
// NOTE: Once you turn it off it does not automatically come back on if you select resume program
atomicState."previousFanMinOnTime${deviceId}" = getFanMinOnTime(child)
atomicState."previousHVACMode${deviceId}" = getHVACMode(child)
atomicState.circulateFanModeOn = false
atomicState.offFanModeOn = true
fanMode = "auto"
thermostatSettings = ',"thermostat":{"settings":{"hvacMode":"off","fanMinOnTime":0}}'
thermostatFunctions = ''
} else {
atomicState.circulateFanModeOn = false
atomicState.offFanModeOn = false
thermostatSettings = ''
thermostatFunctions = '{"type":"setHold","params":{"coolHoldTemp":"'+c+'","heatHoldTemp":"'+h+'","holdType":"'+theHoldType+'","fan":"'+fanMode+'","isTemperatureAbsolute":false,"isTemperatureRelative":false}}'
}
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"'+deviceId+'"},"functions":['+thermostatFunctions+']'+thermostatSettings+'}'
LOG("about to sendJson with jsonRequestBody (${jsonRequestBody}", 4, child)
def result = sendJson(child, jsonRequestBody)
LOG("setFanMode(${fanMode}) returned ${result}", 4, child, 'info')
return result
}
def setProgram(child, program, String deviceId, sendHoldType=null) {
// NOTE: Will use only the first program if there are two with the same exact name
LOG("setProgram(${program}) for ${deviceId} - child ${child}", 2, child, 'info')
// def climateRef = program.toLowerCase()
def climates = atomicState.program[deviceId].climates
def climate = climates?.find { it.name.toString() == program.toString() } // vacation holds can have a number as their name
LOG("climates - {$climates}", 5, child)
LOG("climate - {$climate}", 5, child)
def climateRef = climate?.climateRef.toString()
LOG("setProgram() - climateRef = {$climateRef}", 4, child)
if (climate == null) { return false }
def theHoldType = sendHoldType ? sendHoldType : whatHoldType()
def jsonRequestBody = '{"functions":[{"type":"setHold","params":{"holdClimateRef":"'+climateRef+'","holdType":"'+theHoldType+'"}}],"selection":{"selectionType":"thermostats","selectionMatch":"'+deviceId+'"}}'
LOG("about to sendJson with jsonRequestBody (${jsonRequestBody}", 4, child)
def result = sendJson(child, jsonRequestBody)
LOG("setProgram(${climateRef}) returned ${result}", 4, child, 'info')
return result
}
def addSensorToProgram(child, deviceId, sensorId, programId) {
LOG("addSensorToProgram(${child},${deviceId},${sensorId},${programId})",4,child,'trace')
String preText = getDebugLevel() <= 2 ? '' : 'addSensorToProgram() - '
// we basically have to edit the program object in place, and then return the entire thing back to the Ecobee API
def program = atomicState.program[deviceId]
if (!program) {
return false
}
def result = false
int c = 0
int s = 0
while ( program.climates[c] ) {
if (program.climates[c].climateRef == programId) {
String tempSensor = "${sensorId}:1"
while (program.climates[c].sensors[s]) {
if (program.climates[c].sensors[s].id == tempSensor ) {
LOG("addSensorToProgram() - ${sensorId} is already in the ${programId.capitalize()} program on thermostat ${deviceId}",4,child,'info')
return true // mission accomplished - sensor is already in sensors list
}
s++
}
program.climates[c].sensors << [id:"${tempSensor}"] // add this sensor to the sensors list
result = true
}
if (result) {
def programJson = JsonOutput.toJson(program)
def thermostatSettings = ',"thermostat":{"program":' + programJson +'}'
def thermostatFunctions = ''
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '"},"functions":['+thermostatFunctions+']'+thermostatSettings+'}'
result = sendJson(child, jsonRequestBody)
LOG("addSensorToProgram() returned ${result}", 4, child,'trace')
if (result) {
LOG("${preText}Added sensor ${sensorId} to the ${programId.capitalize()} program on thermostat ${deviceId}",3,child,'info')
return true
}
break
}
c++
}
LOG('addSensorToProgram() - Something went wrong',4,child,'trace')
return false
}
def deleteSensorFromProgram(child, deviceId, sensorId, programId) {
LOG("deleteSensorFromProgram(${child},${deviceId},${sensorId},${programId})",4,child,'trace')
String preText = getDebugLevel() <= 2 ? '' : 'deleteSensorFromProgram() - '
def program = atomicState.program[deviceId]
if (!program) {
return false
}
int c = 0
int s = 0
while ( program.climates[c] ) {
if (program.climates[c].climateRef == programId) { // found the program we want to delete from
String tempSensor = "${sensorId}:1"
def currentSensors = program.climates[c].sensors
while (program.climates[c].sensors[s]) {
if (program.climates[c].sensors[s].id == tempSensor ) {
// found it, now we need to delete it - subtract it from the list of sensors
program.climates[c].sensors = program.climates[c].sensors - program.climates[c].sensors[s]
def programJson = JsonOutput.toJson(program)
def thermostatSettings = ',"thermostat":{"program":' + programJson +'}'
def thermostatFunctions = ''
def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '"},"functions":['+thermostatFunctions+']'+thermostatSettings+'}'
def result = sendJson(child, jsonRequestBody)
LOG("deleteSensorFromProgram() returned ${result}", 4, child,'trace')
if (result) {
LOG("${preText}Deleted sensor ${sensorId} from the ${programId.capitalize()} program on thermostat ${deviceId}",3,child,'info')
return true
}
break
}
s++
}
}
c++
}
// didn't find the specified climate
LOG("${preText}Sensor ${sensorId} not found in the ${programId.capitalize()} program on thermostat ${deviceId}",4,child,'info') // didn't find sensor in the specified climate: Success!
return true
}
// API Helper Functions
private def sendJson(child=null, String jsonBody) {
def returnStatus
def result = false
def cmdParams = [
uri: apiEndpoint,
path: "/1/thermostat",
headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
body: jsonBody
]
try{
httpPost(cmdParams) { resp ->
returnStatus = resp.data.status.code
if (debugLevel(4)) LOG("sendJson() resp.status ${resp.status}, resp.data: ${resp.data}, returnStatus: ${returnStatus}", 4, child)
// TODO: Perhaps add at least two tries incase the first one fails?
if(resp.status == 200) {
if (debugLevel(4)) LOG("Updated ${resp.data}", 4)
returnStatus = resp.data.status.code
if (resp.data.status.code == 0) {
if (debugLevel(4)) LOG("Successful call to ecobee API.", 4, child)
result = true
apiRestored()
generateEventLocalParams()
} else {
LOG("Error return code = ${resp.data.status.code}", 1, child, "error")
}
// Reset saved state
atomicState.savedActionJsonBody = null
atomicState.savedActionChild = null
} else {
// Should never get here as a non-200 response is supposed to trigger an Exception
LOG("Sent Json & got http status ${resp.status} - ${resp.status.code}", 2, child, "warn")
} // resp.status if/else
} // HttpPost
} catch (groovyx.net.http.HttpResponseException e) {
result = false // this thread failed...hopefully we can succeed after we refresh the auth_token
if ((e.statusCode == 500) && (e.response.data.status.code == 14)) {
LOG("sendJson() - HttpResponseException occurred: Auth_token has expired", 3, null, "info")
// atomicState.savedActionJsonBody = jsonBody
// atomicState.savedActionChild = child.deviceNetworkId
// atomicState.action = "sendJsonRetry"
atomicState.action = "" // we don't want refreshAuthToken to sendJsonRetry - we will retry ourselves instead
LOG( "Refreshing your auth_token!", 4)
if ( refreshAuthToken() ) {
LOG( "sendJson() - Auth_token refreshed", 2, null, 'info')
if (!atomicState.sendJsonRetry) {
atomicState.sendJsonRetry = true // retry only once
LOG( "sendJson() - Retrying once...", 2, null, 'info')
result = sendJson( child, jsonBody ) // recursively re-attempt now that the token was refreshed
LOG( "sendJson() - Retry ${result ? 'succeeded!' : 'failed.'}", 2, null, "${result ? 'info' : 'warn'}")
atomicState.sendJsonRetry = false
}
} else {
LOG( "sendJson() - Auth_token refresh failed", 1, null, 'error')
}
} else {
LOG("sendJson() - HttpResponseException occurred. Exception info: ${e} StatusCode: ${e.statusCode}", 1, null, "error")
}
} catch(Exception e) {
// Might need to further break down
LOG("sendJson() - Exception Sending Json: " + e, 1, child, "error")
// atomicState.connected = "warn"
// generateEventLocalParams()
}
return result
}
private def sendJsonRetry() {
LOG("sendJsonRetry() called", 4)
def child = null
if (atomicState.savedActionChild) {
child = getChildDevice(atomicState.savedActionChild)
}
if (child == null) {
LOG("sendJsonRetry() - nosave Action child!", 2, child, "warn")
return false
}
if (atomicState.savedActionJsonBody == null) {
LOG("sendJsonRetry() - no saved JSON Body to send!", 2, child, "warn")
return false
}
return sendJson(child, atomicState.savedActionJsonBody)
}
private def getChildThermostatName() { return "Ecobee Thermostat" }
private def getChildSensorName() { return "Ecobee Sensor" }
private def getServerUrl() { return "https://graph.api.smartthings.com" }
private def getShardUrl() { return getApiServerUrl() }
private def getCallbackUrl() { return "${serverUrl}/oauth/callback" }
private def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" }
private def getApiEndpoint() { return "https://api.ecobee.com" }
// This is the API Key from the Ecobee developer page. Can be provided by the app provider or use the appSettings
private def getSmartThingsClientId() {
if(!appSettings.clientId) {
return "obvlTjUuuR2zKpHR6nZMxHWugoi5eVtS"
} else {
return appSettings.clientId
}
}
private def LOG(message, level=3, child=null, String logType='debug', event=false, displayEvent=true) {
def dbgLevel = debugLevel(level)
if (!dbgLevel) return // let's not waste CPU cycles if we don't have to...
def prefix = ""
def logTypes = ['error', 'debug', 'info', 'trace', 'warn']
if(!logTypes.contains(logType)) {
log.error "LOG() - Received logType (${logType}) which is not in the list of allowed types ${logTypes}, message: ${message}, level: ${level}"
if (event && child) { debugEventFromParent(child, "LOG() - Invalid logType ${logType}") }
logType = 'debug'
}
if ( logType == 'error' ) {
atomicState.lastLOGerror = "${message} @ ${getTimestamp()}"
atomicState.LastLOGerrorDate = getTimestamp()
}
// if ( debugLevel(0) ) { return }
if ( debugLevel(5) ) { prefix = 'LOG: ' }
if ( dbgLevel ) {
log."${logType}" "${prefix}${message}"
if (event) { debugEvent("${message}", displayEvent) }
if (child) { debugEventFromParent(child, message) }
}
}
private def debugEvent(message, displayEvent = false) {
def results = [
name: 'appdebug',
descriptionText: message,
displayed: displayEvent
]
if ( debugLevel(4) ) { log.debug "Generating AppDebug Event: ${results}" }
sendEvent (results)
}
private def debugEventFromParent(child, message) {
def data = [
debugEventFromParent: message
]
if (child) { child.generateEvent(data) }
}
// TODO: Create a more generic push capability to send notifications
// send both push notification and mobile activity feeds
private def sendPushAndFeeds(notificationMessage) {
LOG("sendPushAndFeeds >> notificationMessage: ${notificationMessage}", 1, null, "warn")
LOG("sendPushAndFeeds >> atomicState.timeSendPush: ${atomicState.timeSendPush}", 1, null, "warn")
def msg = "Your Ecobee thermostat(s) at ${location.name} " + notificationMessage // for those that have multiple locations, tell them where we are
if (atomicState.timeSendPush) {
if ( (now() - state.timeSendPush) >= (1000 * 60 * 60 * 1)) { // notification is sent to remind user no more than once per hour
if (location.contactBookEnabled && settings.recipients) {
sendNotificationToContacts(msg, settings.recipients, [event: true])
} else {
sendPush(msg)
}
sendActivityFeeds(notificationMessage)
atomicState.timeSendPush = now()
}
} else {
if (location.contactBookEnabled && settings.recipients) {
sendNotificationToContacts(msg, settings.recipients, [event: true])
} else {
sendPush(msg)
}
sendActivityFeeds(notificationMessage)
atomicState.timeSendPush = now()
}
// This is done in apiLost now
// atomicState.authToken = null
}
private def sendActivityFeeds(notificationMessage) {
def devices = getChildDevices()
devices.each { child ->
child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent
}
}
// Helper Functions
// Creating my own as it seems that the built-in version only works for a device, NOT a SmartApp
def myConvertTemperatureIfNeeded(scaledSensorValue, cmdScale, precision) {
if ( (cmdScale != "C") && (cmdScale != "F") && (cmdScale != "dC") && (cmdScale != "dF") ) {
// We do not have a valid Scale input, throw a debug error into the logs and just return the passed in value
LOG("Invalid temp scale used: ${cmdScale}", 2, null, "error")
return scaledSensorValue
}
def returnSensorValue
// Normalize the input
if (cmdScale == "dF") { cmdScale = "F" }
if (cmdScale == "dC") { cmdScale = "C" }
LOG("About to convert/scale temp: ${scaledSensorValue}", 5, null, "trace", false)
if (cmdScale == getTemperatureScale() ) {
// The platform scale is the same as the current value scale
returnSensorValue = scaledSensorValue.round(precision)
} else if (cmdScale == "F") {
returnSensorValue = fToC(scaledSensorValue).round(precision)
} else {
returnSensorValue = cToF(scaledSensorValue).round(precision)
}
LOG("returnSensorValue == ${returnSensorValue}", 5, null, "trace", false)
return returnSensorValue
}
def wantMetric() {
return (getTemperatureScale() == "C")
}
private Double cToF(temp) {
if (debugLevel(5)) LOG("cToF entered with ${temp}", 5, null, "info")
return (temp * 1.8 + 32) as Double
// return celsiusToFahrenheit(temp)
}
private Double fToC(temp) {
if (debugLevel(5)) LOG("fToC entered with ${temp}", 5, null, "info")
return (temp - 32) / 1.8 as Double
// return fahrenheitToCelsius(temp)
}
// Establish the minimum amount of time to wait to do another poll
private def getMinMinBtwPolls() {
// TODO: Make this configurable in the SmartApp
return 1
}
// need these next 4 get routines because settings variable defaults aren't set unless the "Preferences" page is actually opened/rendered
def Integer getPollingInterval() {
return (settings.pollingInterval?.isNumber() ? settings.pollingInterval.toInteger() : 5)
}
private Integer getTempDecimals() {
return ( settings.tempDecimals?.isNumber() ? settings.tempDecimals.toInteger() : (wantMetric() ? 1 : 0))
}
private Integer getDebugLevel() {
return ( settings.debugLevel?.isNumber() ? settings.debugLevel.toInteger() : 3)
}
private String getHoldType() {
return ( settings.holdType ? settings.holdType : 'Until I Change')
}
private def String getTimestamp() {
// There seems to be some possibility that the timeZone will not be returned and will cause a NULL Pointer Exception
def timeZone = location?.timeZone ?: ""
// LOG("timeZone found as ${timeZone}", 5)
if(timeZone == "") {
return new Date().format("yyyy-MM-dd HH:mm:ss z")
} else {
return new Date().format("yyyy-MM-dd HH:mm:ss z", timeZone)
}
}
private def getTimeOfDay(tid = null) {
def nowTime
if(atomicState.timeZone) {
nowTime = new Date().format("HHmm", TimeZone.getTimeZone(atomicState.timeZone)).toInteger()
} else if (tid && atomicState.statLocation && atomicState.statLocation[tid]) {
nowTime = new Date().format("HHmm", TimeZone.getTimeZone(atomicState.statLocation[tid].timeZone)).toInteger()
} else {
nowTime = new Date().format("HHmm").toInteger()
}
if (debugLevel(4)) LOG("getTimeOfDay() - nowTime = ${nowTime}", 4, null, "trace")
if ( (nowTime < atomicState.sunriseTime) || (nowTime > atomicState.sunsetTime) ) {
return "night"
} else {
return "day"
}
}
// we'd prefer to use the timeZone that the thermostat says it is in, so long as ALL the thermostats agree
// if thermostats don't have a timeZone, use the SmartThings location.timeZone
// if ST location doesn't have a time zone, we're just going to have to use ST's "local time"
private def getTimeZone() {
if ((atomicState.timeZone == null) || atomicState.forcePoll) {
// default to the SmartThings location's timeZone (if there is one)
def myTimeZone = location?.timeZone ? location.timeZone.ID : null
def timeZones = []
settings.thermostats?.each {
def tid = it.split(/\./).last()
def statTimeZone = (atomicState.statLocation && atomicState.statLocation[tid]) ? atomicState.statLocation[tid].timeZone : null
if (statTimeZone != null) {
if (debugLevel(4)) LOG("thermostat ${tid}'s timeZone ID: ${statTimeZone}",4,null,'trace')
// let's see how many timeZones we are using across all the thermostats
if (!timeZones || (!timeZones.contains(statTimeZone))) timeZones += [statTimeZone]
// if we have the Thermostat Location, use the timeZone from the thermostat
if (myTimeZone != statTimeZone) myTimeZone = statTimeZone
}
}
if (timeZones.size() != 1) {
// we have thermostats in more than one time zone - going to have to use location data every time
myTimeZone = null
}
if (myTimeZone != null) atomicState.timeZone = myTimeZone // can't save the timeZone object, so we store the ID/Name
}
return atomicState.timeZone
}
private String getZipCode() {
// default to the SmartThings location's timeZone (if there is one)
String myZipCode = location?.zipCode
if ((atomicState.zipCode == null) || atomicState.forcePoll) {
def zipCodes = []
settings.thermostats?.each{
String tid = it.split(/\./).last()
String statZipCode = (atomicState.statLocation && atomicState.statLocation[tid]) ? atomicState.statLocation[tid].postalCode : ''
if (statZipCode != '') {
// let's see how many postalCodes we are using across all the thermostats
if (!zipCodes || (!zipCodes.contains(statZipCode))) zipCodes += [statZipCode]
// if we have the Thermostat Location, use the postalCode from the thermostat
if (myZipCode != statZipCode) myZipCode = statZipCode
}
}
if (zipCodes.size() > 1) {
// we have thermostats in more than one postalCode - going to have to use location data every time
myZipCode = null
}
}
atomicState.zipCode = myZipCode
return myZipCode
}
// Are we connected with the Ecobee service?
private String apiConnected() {
// values can be "full", "warn", "lost"
if (atomicState.connected == null) atomicState.connected = "warn"
return atomicState.connected?.toString() ?: "lost"
}
private def apiRestored() {
atomicState.connected = "full"
unschedule("notifyApiLost")
atomicState.reAttemptPoll = 0
}
private def getDebugDump() {
def debugParams = [when:"${getTimestamp()}", whenEpic:"${now()}",
lastPollDate:"${atomicState.lastPollDate}", lastScheduledPollDate:"${atomicState.lastScheduledPollDate}",
lastScheduledWatchdogDate:"${atomicState.lastScheduledWatchdogDate}",
lastTokenRefreshDate:"${atomicState.lastTokenRefreshDate}",
initializedEpic:"${atomicState.initializedEpic}", initializedDate:"${atomicState.initializedDate}",
lastLOGerror:"${atomicState.lastLOGerror}", authTokenExpires:"${atomicState.authTokenExpires}"
]
return debugParams
}
private def apiLost(where = "[where not specified]") {
LOG("apiLost() - ${where}: Lost connection with APIs. unscheduling Polling and refreshAuthToken. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 1, null, "error")
atomicState.apiLostDump = getDebugDump()
if (apiConnected() == "lost") {
LOG("apiLost() - already in lost atomicState. Nothing else to do. (where= ${where})", 5, null, "trace")
return
}
// provide cleanup steps when API Connection is lost
def notificationMessage = "is disconnected from SmartThings/Ecobee, because the access credential changed or was lost. Please go to the Ecobee (Connect) SmartApp and re-enter your account login credentials."
atomicState.connected = "lost"
atomicState.authToken = null
sendPushAndFeeds(notificationMessage)
generateEventLocalParams()
LOG("Unscheduling Polling and refreshAuthToken. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 0, null, "error")
// Notify each child that we lost so it gets logged
if ( debugLevel(3) ) {
def d = getChildDevices()
d?.each { oneChild ->
LOG("apiLost() - notifying each child: ${oneChild.device.displayName} of loss", 1, child, "error")
}
}
unschedule("pollScheduled")
unschedule("scheduleWatchdog")
runEvery3Hours("notifyApiLost")
}
def notifyApiLost() {
def notificationMessage = "is disconnected from SmartThings/Ecobee, because the access credential changed or was lost. Please go to the Ecobee (Connect) SmartApp and re-enter your account login credentials."
if ( atomicState.connected == "lost" ) {
generateEventLocalParams()
sendPushAndFeeds(notificationMessage)
LOG("notifyApiLost() - API Connection Previously Lost. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 0, null, "error")
} else {
// Must have restored connection
unschedule("notifyApiLost")
}
}
private String childType(child) {
// Determine child type (Thermostat or Remote Sensor)
if ( child.hasCapability("Thermostat") ) { return getChildThermostatName() }
if ( child.name.contains( getChildSensorName() ) ) { return getChildSensorName() }
return "Unknown"
}
private def getFanMinOnTime(child) {
if (debugLevel(4)) LOG("getFanMinOnTime() - Looking up current fanMinOnTime for ${child.device.displayName}", 4, child)
String devId = getChildThermostatDeviceIdsString(child)
LOG("getFanMinOnTime() Looking for ecobee thermostat ${devId}", 5, child, "trace")
def fanMinOnTime = atomicState.settings[devId]?.fanMinOnTime
if (debugLevel(4)) LOG("getFanMinOnTime() fanMinOnTime is ${fanMinOnTime} for therm ${devId}", 4, child)
return fanMinOnTime
}
private String getHVACMode(child) {
if (debugLevel(4)) LOG("Looking up current hvacMode for ${child.device.displayName}", 4, child)
String devId = getChildThermostatDeviceIdsString(child)
// LOG("getHVACMode() Looking for ecobee thermostat ${devId}", 5, child, "trace")
def hvacMode = atomicState.settings[devId].hvacMode // FIXME
if (debugLevel(4)) LOG("getHVACMode() hvacMode is ${hvacMode} for therm ${devId}", 4, child)
return hvacMode
}
def getAvailablePrograms(thermostat) {
// TODO: Finish implementing this!
if (debugLevel(4)) LOG("Looking up the available Programs for this thermostat (${thermostat})", 4)
String devId = getChildThermostatDeviceIdsString(thermostat)
// LOG("getAvailablePrograms() Looking for ecobee thermostat ${devId}", 5, thermostat, "trace")
def climates = atomicState.program[devId].climates
return climates?.collect { it.name }
}
private def whatHoldType() {
def myHoldType = getHoldType()
def sendHoldType = (myHoldType=='Until Next Program' || myHoldType=='Temporary') ? 'nextTransition' : (( myHoldType=='Until I Change' || myHoldType=='Permanent') ? 'indefinite' : 'indefinite')
// LOG("Entered whatHoldType() with ${sendHoldType} settings.holdType == ${settings.holdType}")
return sendHoldType
}
private def debugLevel(level=3) {
// log.trace("debugLevel() -- settings.debugLevel == ${settings.debugLevel}")
// if(settings.debugLevel == "0") {
// log.trace("debugLevel() - debugLvlNum == 0 triggered")
// return false
//}
// def debugLvlNum = getDebugLevel()
// def wantedLvl = level?.toInteger()
// log.trace("debugLvlNum = ${debugLvlNum}; wantedLvl = ${wantedLvl}")
Integer dbgLevel = getDebugLevel()
return (dbgLevel == 0) ? false : (dbgLevel >= level?.toInteger())
// return ( getDebugLevel() >= level?.toInteger() )
}
private String fixDateTimeString( String dateStr, String timeStr, String thermostatTime) {
String todayDate = thermostatTime.take(10)
// date is in ecobee format: YYYY-MM-DD or null (meaning "today")
// time is in ecobee format: HH:MM:SS
String resultStr = ''
String myDate = ''
String myTime = ''
String tTime = timeStr
if (dateStr == '2035-01-01' ) { // to Infinity
myDate = 'a long time from now'
tTime = ''
} else if (dateStr == todayDate) {
myDate = 'today'
} else {
myDate = dateStr.drop(5) // get MM-DD
String delim = myDate.substring(2,3)
int month = myDate.take(2).toInteger()
int days = myDate.drop(3).toInteger()
int todayMonth = todayDate.drop(5).take(2).toInteger()
if (month == todayMonth) {
int todayDays = todayDate.drop(8).toInteger()
if (todayDays+1 == days) { myDate = 'tomorrow' }
else if (todayDays-1 == days) { myDate = 'yesterday' }
else { myDate = "on ${month}${delim}${days}" }
} else {
myDate = "on ${month}${delim}${days}"
}
}
if (tTime != '') {
myTime = timeStr.take(5) // get HH:MM
int hours = myTime.take(2).toInteger()
int mins = myTime.drop(3).toInteger()
if (hours < 12) {
if (hours==0) hours = 12
myTime = "${hours}" + myTime.drop(2) + 'am'
} else {
if (hours==12) hours = 24 // hack
myTime = "${hours-12}" + myTime.drop(2) + "pm"
}
}
if (myDate || myTime) {
resultStr = myTime ? "${myDate} at ${myTime}" : "${myDate}"
}
return resultStr
}