diff --git a/devicetypes/smartthings/ecobee-sensor.src/ecobee-sensor.groovy b/devicetypes/smartthings/ecobee-sensor.src/ecobee-sensor.groovy index 80de14d2e24..7e2603c0531 100644 --- a/devicetypes/smartthings/ecobee-sensor.src/ecobee-sensor.groovy +++ b/devicetypes/smartthings/ecobee-sensor.src/ecobee-sensor.groovy @@ -12,15 +12,16 @@ * 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. * - * Current Version: 0.8.0-RC - * Release Date: 2016-01-26 - * See separate Changelog for change history + * See Changelog for change history * * Current Version: 0.7.5 * Release Date: 20160125 * See separate Changelog for change history */ - + +def getVersionNum() { return "0.9.0" } +private def getVersionLabel() { return "Ecobee Sensor Version 0.9.0-RC2" } + metadata { definition (name: "Ecobee Sensor", namespace: "smartthings", author: "SmartThings") { capability "Sensor" @@ -178,4 +179,4 @@ private def debugEvent(message, displayEvent = false) { ] if ( debugLevel(4) ) { log.debug "Generating AppDebug Event: ${results}" } sendEvent (results) -} +} \ No newline at end of file diff --git a/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy b/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy index 6fff2698136..b7067db169c 100644 --- a/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy +++ b/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy @@ -20,10 +20,14 @@ * Incorporate additional device capabilities, some based on code by Yves Racine * * - * Current Version: 0.8.0 - * See separate Changelog for change history + * See Changelog for change history * */ + +def getVersionNum() { return "0.9.0" } +private def getVersionLabel() { return "Ecobee Thermostat Version 0.9.0-RC6" } + + metadata { definition (name: "Ecobee Thermostat", namespace: "smartthings", author: "SmartThings") { capability "Actuator" @@ -60,204 +64,58 @@ metadata { attribute "thermostatStatus","string" attribute "apiConnected","string" attribute "averagedTemperature","number" - attribute "currentProgram","string" - attribute "currentProgramId","string" - - attribute "weatherSymbol", "string" - + attribute "currentProgramId","string" + attribute "weatherSymbol", "string" attribute "debugEventFromParent","string" - - - /* - attribute "thermostatName", "string" - attribute "temperatureDisplay", "string" - attribute "coolingSetpointDisplay", "string" - attribute "heatingSetpointDisplay", "string" - attribute "heatLevelUp", "string" - attribute "heatLevelDown", "string" - attribute "coolLevelUp", "string" - attribute "coolLevelDown", "string" - attribute "verboseTrace", "string" - attribute "fanMinOnTime", "string" - attribute "humidifierMode", "string" - attribute "dehumidifierMode", "string" - attribute "humidifierLevel", "string" - attribute "dehumidifierLevel", "string" - attribute "condensationAvoid", "string" - attribute "groups", "string" - attribute "equipmentStatus", "string" - attribute "alerts", "string" - attribute "programScheduleName", "string" - attribute "programFanMode", "string" - attribute "programType", "string" - attribute "programCoolTemp", "string" - attribute "programHeatTemp", "string" - attribute "programCoolTempDisplay", "string" - attribute "programHeatTempDisplay", "string" - attribute "programEndTimeMsg", "string" - - attribute "weatherDateTime", "string" - attribute "weatherSymbol", "string" - attribute "weatherStation", "string" - attribute "weatherCondition", "string" - attribute "weatherTemperatureDisplay", "string" - attribute "weatherPressure", "string" - attribute "weatherRelativeHumidity", "string" - attribute "weatherWindSpeed", "string" - attribute "weatherWindDirection", "string" - attribute "weatherPop", "string" - attribute "weatherTempHigh", "string" - attribute "weatherTempLow", "string" - attribute "weatherTempHighDisplay", "string" - attribute "weatherTempLowDisplay", "string" - - attribute "plugName", "string" - attribute "plugState", "string" - attribute "plugSettings", "string" - attribute "hasHumidifier", "string" - attribute "hasDehumidifier", "string" - attribute "hasErv", "string" - attribute "hasHrv", "string" - attribute "ventilatorMinOnTime", "string" - attribute "ventilatorMode", "string" - attribute "programNameForUI", "string" - // Passed in via the SmartApp - // attribute "thermostatOperatingState", "string" - attribute "climateList", "string" - attribute "modelNumber", "string" - attribute "followMeComfort", "string" - attribute "autoAway", "string" - attribute "intervalRevision", "string" - attribute "runtimeRevision", "string" - attribute "thermostatRevision", "string" - attribute "heatStages", "string" - attribute "coolStages", "string" - attribute "climateName", "string" - attribute "setClimate", "string" - - // Report Runtime events - attribute "auxHeat1RuntimeInPeriod", "string" - attribute "auxHeat2RuntimeInPeriod", "string" - attribute "auxHeat3RuntimeInPeriod", "string" - attribute "compCool1RuntimeInPeriod", "string" - attribute "compCool2RuntimeInPeriod", "string" - attribute "dehumidifierRuntimeInPeriod", "string" - attribute "humidifierRuntimeInPeriod", "string" - attribute "ventilatorRuntimeInPeriod", "string" - attribute "fanRuntimeInPeriod", "string" - - attribute "auxHeat1RuntimeDaily", "string" - attribute "auxHeat2RuntimeDaily", "string" - attribute "auxHeat3RuntimeDaily", "string" - attribute "compCool1RuntimeDaily", "string" - attribute "compCool2RuntimeDaily", "string" - attribute "dehumidifierRuntimeDaily", "string" - attribute "humidifierRuntimeDaily", "string" - attribute "ventilatorRuntimeDaily", "string" - attribute "fanRuntimeDaily", "string" - attribute "reportData", "string" - - // Report Sensor Data & Stats - attribute "reportSensorMetadata", "string" - attribute "reportSensorData", "string" - attribute "reportSensorAvgInPeriod", "string" - attribute "reportSensorMinInPeriod", "string" - attribute "reportSensorMaxInPeriod", "string" - attribute "reportSensorTotalInPeriod", "string" - - // Remote Sensor Data & Stats - attribute "remoteSensorData", "string" - attribute "remoteSensorTmpData", "string" - attribute "remoteSensorHumData", "string" - attribute "remoteSensorOccData", "string" - attribute "remoteSensorAvgTemp", "string" - attribute "remoteSensorAvgHumidity", "string" - attribute "remoteSensorMinTemp", "string" - attribute "remoteSensorMinHumidity", "string" - attribute "remoteSensorMaxTemp", "string" - attribute "remoteSensorMaxHumidity", "string" - */ + } + simulator { } + tiles(scale: 2) { + + multiAttributeTile(name:"tempSummaryBlack", type:"thermostat", width:6, height:4) { + tileAttribute("device.temperature", key: "PRIMARY_CONTROL") { + attributeState("default", label:'${currentValue}', unit:"dF") + } - /* - command "setFanMinOnTime" - command "setCondensationAvoid" - command "createVacation" - command "deleteVacation" - command "getEcobeePinAndAuth" - command "getThermostatInfo" - command "getThermostatSummary" - command "iterateCreateVacation" - command "iterateDeleteVacation" - command "iterateResumeProgram" - command "iterateSetHold" - command "resumeProgram" - command "resumeThisTstat" - command "setAuthTokens" - command "setHold" - command "setHoldExtraParams" - command "heatLevelUp" - command "heatLevelDown" - command "coolLevelUp" - command "coolLevelDown" - command "auxHeatOnly" - command "setThermostatFanMode" - command "dehumidifierOff" - command "dehumidifierOn" - command "humidifierOff" - command "humidifierAuto" - command "humidifierManual" - command "setHumidifierLevel" - command "setDehumidifierLevel" - command "updateGroup" - command "getGroups" - command "iterateUpdateGroup" - command "createGroup" - command "deleteGroup" - command "updateClimate" - command "iterateUpdateClimate" - command "createClimate" - command "deleteClimate" - command "setClimate" - command "iterateSetClimate" - command "controlPlug" - command "ventilatorOn" - command "ventilatorAuto" - command "ventilatorOff" - command "ventilatorAuto" - command "setVentilatorMinOnTime" - command "awake" - command "away" - command "present" - command "home" - command "asleep" - command "quickSave" - command "setThisTstatClimate" - command "setThermostatSettings" - command "iterateSetThermostatSettings" - command "getEquipmentStatus" - command "refreshChildTokens" - command "autoAway" - command "followMeComfort" - command "getReportData" - command "generateReportRuntimeEvents" - command "generateReportSensorStatsEvents" - command "getThermostatRevision" - command "generateRemoteSensorEvents" - */ - + tileAttribute("device.temperature", key: "VALUE_CONTROL") { + attributeState("default", action: "setTemperature") + } + tileAttribute("device.humidity", key: "SECONDARY_CONTROL") { + attributeState("default", label:'${currentValue}%', unit:"%") + } - } + + // Use this one if you want ALL BLACK + tileAttribute("device.thermostatOperatingState", key: "OPERATING_STATE") { + // TODO: Change this to a preference so the use can select green over grey from within the app + // Uncomment the below if you prefer green for idle + attributeState("idle", backgroundColor:"#000000") + // Or uncomment this one if you prefer grey for idle + // attributeState("idle", backgroundColor:"#C0C0C0") + attributeState("heating", backgroundColor:"#000000") + attributeState("cooling", backgroundColor:"#000000") + } - simulator { } - tiles(scale: 2) { + tileAttribute("device.thermostatMode", key: "THERMOSTAT_MODE") { + attributeState("off", label:'${name}') + attributeState("heat", label:'${name}') + attributeState("cool", label:'${name}') + attributeState("auto", label:'${name}') + } + tileAttribute("device.heatingSetpoint", key: "HEATING_SETPOINT") { + attributeState("default", label:'${currentValue}', unit:"F") + } + tileAttribute("device.coolingSetpoint", key: "COOLING_SETPOINT") { + attributeState("default", label:'${currentValue}', unit:"F") + } - - multiAttributeTile(name:"iOSsummary", type:"thermostat", width:6, height:4) { + } // End multiAttributeTile + + multiAttributeTile(name:"tempSummary", type:"thermostat", width:6, height:4) { tileAttribute("device.temperature", key: "PRIMARY_CONTROL") { attributeState("default", label:'${currentValue}', unit:"dF") } @@ -269,6 +127,7 @@ metadata { attributeState("default", label:'${currentValue}%', unit:"%") } + // Use this one if you want colors for the multiAttributeTile tileAttribute("device.thermostatOperatingState", key: "OPERATING_STATE") { // TODO: Change this to a preference so the use can select green over grey from within the app // Uncomment the below if you prefer green for idle @@ -278,7 +137,6 @@ metadata { attributeState("heating", backgroundColor:"#ffa81e") attributeState("cooling", backgroundColor:"#269bd2") } - tileAttribute("device.thermostatMode", key: "THERMOSTAT_MODE") { attributeState("off", label:'${name}') attributeState("heat", label:'${name}') @@ -348,18 +206,18 @@ metadata { ) } standardTile("mode", "device.thermostatMode", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { - state "off", action:"switchMode", nextState: "updating", icon: "st.thermostat.heating-cooling-off" - state "heat", action:"switchMode", nextState: "updating", icon: "st.thermostat.heat" - state "cool", action:"switchMode", nextState: "updating", icon: "st.thermostat.cool" - state "auto", action:"switchMode", nextState: "updating", icon: "st.thermostat.auto" - state "auxHeatOnly", action:"switchMode", icon: "st.thermostat.emergency-heat" + state "off", action:"thermostat.heat", nextState: "updating", icon: "st.thermostat.heating-cooling-off" + state "heat", action:"thermostat.cool", nextState: "updating", icon: "st.thermostat.heat" + state "cool", action:"thermostat.auto", nextState: "updating", icon: "st.thermostat.cool" + state "auto", action:"thermostat.off", nextState: "updating", icon: "st.thermostat.auto" + // Not included in the button loop, but if already in "auxHeatOnly" pressing button will go to "auto" + state "auxHeatOnly", action:"thermostat.auto", icon: "st.thermostat.emergency-heat" state "updating", label:"Working", icon: "st.secondary.secondary" } standardTile("fanMode", "device.thermostatFanMode", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { - state "auto", label:'Fan: ${currentValue}', action:"thermostat.fanOn", nextState: "updating", icon: "st.Appliances.appliances11" state "on", label:'Fan: ${currentValue}', action:"thermostat.fanAuto", nextState: "updating", icon: "st.Appliances.appliances11" - // state "off", label:'Fan: ${currentValue}', action:"switchFanMode", nextState: "updating", icon: "st.Appliances.appliances11" - // state "circulate", label:'Fan: ${currentValue}', action:"switchFanMode", nextState: "updating", icon: "st.Appliances.appliances11" + state "auto", label:'Fan: ${currentValue}', action:"thermostat.fanCirculate", nextState: "updating", icon: "st.Appliances.appliances11" + state "circulate", label:'Fan: ${currentValue}', action:"thermostat.fanOn", nextState: "updating", icon: "st.Appliances.appliances11" state "updating", label:"Working", icon: "st.secondary.secondary" } standardTile("upButtonControl", "device.thermostatSetpoint", width: 2, height: 1, inactiveLabel: false, decoration: "flat") { @@ -389,10 +247,16 @@ metadata { standardTile("refresh", "device.thermostatMode", width: 2, height: 2,inactiveLabel: false, decoration: "flat") { state "default", action:"refresh.refresh", icon:"st.secondary.refresh" } + standardTile("resumeProgram", "device.resumeProgram", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { - state "resume", action:"resumeProgram", nextState: "updating", label:'Resume Schedule', icon:"st.Office.office7" + state "resume", action:"resumeProgram", nextState: "updating", label:'Resume Program', icon:"st.Office.office7" state "updating", label:"Working", icon: "st.samsung.da.oven_ic_send" } + + standardTile("resumeProgramBlack", "device.resumeProgram", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { + state "resume", action:"resumeProgram", nextState: "updating", label:'Resume Program', icon:"https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_sched_square.png" + state "updating", label:"Working...", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_blank_square.png" + } valueTile("currentProgram", "device.currentProgramName", height: 2, width: 4, inactiveLabel: false, decoration: "flat") { state "default", label:'Comfort Setting:\n${currentValue}' } @@ -412,6 +276,15 @@ metadata { state "updating", label:"Working...", icon: "st.samsung.da.oven_ic_send" } + standardTile("operatingStateBlack", "device.thermostatOperatingState", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { + state "idle", label: "Idle", backgroundColor:"#c0c0c0", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_idle_square.png" + state "fan only", backgroundColor:"#87ceeb", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_fanonly_square.png" + state "heating", backgroundColor:"#ffa81e", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_heat_square.png" + state "cooling", backgroundColor:"#269bd2", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_cool_square.png" + // Issue reported that the label overlaps. Need to remove the icon + state "default", label: '${currentValue}', backgroundColor:"c0c0c0", icon: "st.nest.empty" + } + standardTile("operatingState", "device.thermostatOperatingState", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { //state "idle", label: "Idle", backgroundColor:"#44b621", icon: "st.nest.empty" state "idle", label: "Idle", backgroundColor:"#c0c0c0", icon: "st.nest.empty" @@ -427,12 +300,19 @@ metadata { } + standardTile("motionBlack", "device.motion", width: 2, height: 2) { + state("active", label:'motion', backgroundColor:"#53a7c0", icon:"https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_sensor_motion.png") + state("inactive", label:'no motion', backgroundColor:"#ffffff", icon:"https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_sensor_nomotion.png") + } + + standardTile("motion", "device.motion", width: 2, height: 2) { state("active", label:'motion', icon:"st.motion.motion.active", backgroundColor:"#53a7c0") state("inactive", label:'no motion', icon:"st.motion.motion.inactive", backgroundColor:"#ffffff") } + // Additional tiles based on Yves Racine's device type // Weather Tiles and other Forecast related tiles standardTile("weatherIcon", "device.weatherSymbol", inactiveLabel: false, width: 2, height: 2, @@ -441,7 +321,7 @@ metadata { state "0", label: 'Sunny', icon: "st.Weather.weather14" state "1", label: 'Few Clouds', icon: "st.Weather.weather11" state "2", label: 'Partly Cloudy', icon: "st.Weather.weather11" - state "3", label: 'Mostly Cloudy', icon: "st.Weather.weather13" + state "3", label: 'Mostly Cloudy', icon: "st.Weather.weather11" state "4", label: 'Overcast', icon: "st.Weather.weather13" state "5", label: 'Drizzle', icon: "st.Weather.weather10" state "6", label: 'Rain', icon: "st.Weather.weather10" @@ -461,53 +341,65 @@ metadata { state "20", label: 'Smoke', icon: "st.Weather.weather13" state "21", label: 'Dust', icon: "st.Weather.weather13" } - valueTile("weatherDateTime", "device.weatherDateTime", inactiveLabel: false, - width: 3, height: 2, decoration: "flat") { - state "default", label: '${currentValue}' + + standardTile("weatherIconBlack", "device.weatherSymbol", inactiveLabel: false, width: 2, height: 2, + decoration: "flat") { + state "-2", label: 'updating...', icon: "st.unknown.unknown.unknown" + state "0", label: '', icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/e3_weather_0.png" + state "1", label: 'Few Clouds', icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/light/e3_weather_1.png" + state "2", label: 'Partly Cloudy', icon: "st.Weather.weather11" + state "3", label: 'Mostly Cloudy', icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/e3_weather_3.png" + state "4", label: 'Overcast', icon: "st.Weather.weather13" + state "5", label: 'Drizzle', icon: "st.Weather.weather10" + state "6", label: 'Rain', icon: "st.Weather.weather10" + state "7", label: 'Freezing Rain', icon: "st.Weather.weather6" + state "8", label: 'Showers', icon: "st.Weather.weather10" + state "9", label: 'Hail', icon: "st.custom.wuk.sleet" + state "10", label: 'Snow', icon: "st.Weather.weather6" + state "11", label: 'Flurries', icon: "st.Weather.weather6" + state "12", label: 'Sleet', icon: "st.Weather.weather6" + state "13", label: 'Blizzard', icon: "st.Weather.weather7" + state "14", label: 'Pellets', icon: "st.custom.wuk.sleet" + state "15", label: 'Thunder Storms',icon: "st.custom.wuk.tstorms" + state "16", label: 'Windy', icon: "st.Transportation.transportation5" + state "17", label: 'Tornado', icon: "st.Weather.weather1" + state "18", label: 'Fog', icon: "st.Weather.weather13" + state "19", label: 'Hazy', icon: "st.Weather.weather13" + state "20", label: 'Smoke', icon: "st.Weather.weather13" + state "21", label: 'Dust', icon: "st.Weather.weather13" } - valueTile("weatherConditions", "device.weatherCondition", - inactiveLabel: false, width: 3, height: 2, decoration: "flat") { - state "default", label: 'Forecast\n${currentValue}' + + standardTile("weatherTemperatureBlack", "device.weatherTemperature", width: 2, height: 2, decoration: "flat") { + state "default", label: 'Outside: ${currentValue}°', unit: "dF", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_thermometer_square.png" } - standardTile("weatherTemperature", "device.weatherTemperature", inactiveLabel: - false, width: 2, height: 2, decoration: "flat") { + + standardTile("weatherTemperature", "device.weatherTemperature",width: 2, height: 2, decoration: "flat") { state "default", label: 'Outside: ${currentValue}°', unit: "dF", icon: "st.Weather.weather2" } - valueTile("weatherRelativeHumidity", "device.weatherRelativeHumidity", - inactiveLabel: false, width: 2, height: 2,decoration: "flat") { - state "default", label: 'Out Hum\n${currentValue}%', unit: "humidity" - } - valueTile("weatherTempHigh", "device.weatherTempHigh", inactiveLabel: false, - width: 2, height: 2, decoration: "flat") { - state "default", label: 'ForecastH\n${currentValue}°', unit: "dF" - } - valueTile("weatherTempLow", "device.weatherTempLow", inactiveLabel: false, - width: 2, height: 2, decoration: "flat") { - state "default", label: 'ForecastL\n${currentValue}°', unit: "dF" - } - valueTile("weatherPressure", "device.weatherPressure", inactiveLabel: false, - width: 2, height: 2, decoration: "flat") { - state "default", label: 'Pressure\n${currentValue}', unit: "hpa" - } - valueTile("weatherWindDirection", "device.weatherWindDirection", - inactiveLabel: false, width: 2, height: 2, decoration: "flat") { - state "default", label: 'W.Dir\n${currentValue}' - } - valueTile("weatherWindSpeed", "device.weatherWindSpeed", inactiveLabel: false, - width: 2, height: 2, decoration: "flat") { - state "default", label: 'W.Speed\n${currentValue}' - } - valueTile("weatherPop", "device.weatherPop", inactiveLabel: false, width: 2, - height: 2, decoration: "flat") { - state "default", label: 'PoP\n${currentValue}%', unit: "%" + + + standardTile("ecoLogo", "device.motion", width: 2, height: 2) { + state "default", icon:"https://s3.amazonaws.com/smartapp-icons/MiscHacking/ecobee-smartapp-icn@2x.png" } - main(["temperature", "summary"]) + + main(["temperature", "tempSummary"]) // details(["summary","temperature", "upButtonControl", "thermostatSetpoint", "currentStatus", "downButtonControl", "mode", "weatherIcon", "resumeProgram", "refresh"]) // details(["summary","apiStatus", "upButtonControl", "thermostatSetpoint", "currentStatus", "downButtonControl", "mode", "weatherIcon", "resumeProgram", "refresh"]) - details(["summary", + /* details(["tempSummaryBlack", + "operatingStateBlack", "weatherIconBlack", "weatherTemperatureBlack", + "motion", "resumeProgram", "mode", + "coolSliderControl", "coolingSetpoint", + "heatSliderControl", "heatingSetpoint", + "currentStatus", "apiStatus", + "currentProgram", "fanMode", + "setHome", "setAway", "setSleep", + "refresh", "ecoLogo" + ]) +*/ + details(["tempSummary", "operatingState", "weatherIcon", "weatherTemperature", "motion", "resumeProgram", "mode", "coolSliderControl", "coolingSetpoint", @@ -515,8 +407,10 @@ metadata { "currentStatus", "apiStatus", "currentProgram", "fanMode", "setHome", "setAway", "setSleep", - "refresh" + "refresh", "ecoLogo" ]) + + } preferences { @@ -791,15 +685,16 @@ def modes() { } def fanModes() { - ["on", "auto", "circulate"] + ["off", "on", "auto", "circulate"] } // TODO Add a delay (like in the setTemperature case) to capture multiple clicks on the UI +/* def switchMode() { LOG("in switchMode()", 5) def currentMode = device.currentState("thermostatMode")?.value - def lastTriedMode = state.lastTriedMode ?: currentMode ?: "off" + def lastTriedMode = state.lastTriedMode ?: currentMode ?: "auto" def modeOrder = modes() def next = { modeOrder[modeOrder.indexOf(it) + 1] ?: modeOrder[0] } def nextMode = next(lastTriedMode) @@ -816,6 +711,7 @@ def switchToMode(nextMode) { } } + def switchFanMode() { LOG("switchFanMode()", 5) def currentFanMode = device.currentState("thermostatFanMode")?.value @@ -863,14 +759,18 @@ def switchToFanMode(nextMode) { } returnCommand } +*/ + def getDataByName(String name) { state[name] ?: device.getDataValue(name) } +def generateQuickEvent(name, value) { + generateQuickEvent(name, value, 0) +} - -def generateQuickEvent(name, value, pollIn=0) { +def generateQuickEvent(name, value, pollIn) { sendEvent(name: name, value: value, displayed: true) if (pollIn > 0) { runIn(pollIn, "poll") } } @@ -885,16 +785,18 @@ def generateOperatingStateEvent(operatingState) { } -def setThermostatMode(String value, holdType=null) { +def setThermostatMode(String value) { // "emergencyHeat" "heat" "cool" "off" "auto" if (value=="emergency" || value=="emergencyHeat") { value = "auxHeatOnly" } LOG("setThermostatMode(${value})", 5) - + generateQuickEvent("thermostatMode", value) + + def deviceId = getDeviceId() - if (parent.setMode(this, value, deviceId, holdType)) - generateQuickEvent("thermostatMode", value, 15) - else { + if (parent.setMode(this, value, deviceId)) { + // generateQuickEvent("thermostatMode", value, 15) + } else { LOG("Error setting new mode to ${value}.", 1, null, "error") def currentMode = device.currentState("thermostatMode")?.value generateQuickEvent("thermostatMode", currentMode) // reset the tile back @@ -1006,8 +908,22 @@ def generateProgramEvent(program, failedProgram=null) { def setThermostatFanMode(value, holdType=null) { LOG("setThermostatFanMode(${value})", 4) // "auto" "on" "circulate" "off" + + // This is to work around a bug in some SmartApps that are using fanOn and fanAuto as inputs here, which is wrong + if (value == "fanOn" || value == "on" ) { value = "on" } + else if (value == "fanAuto" || value == "auto" ) { value = "auto" } + else if (value == "fanCirculate" || value == "circulate") { value == "circulate" } + else if (value == "fanOff" || value == "off") { value = "off" } + else { + LOG("setThermostatFanMode() - Unrecognized Fan Mode: ${value}. Setting to 'auto'", 1, null, "error") + value = "auto" + } + + // Change the state now to quickly refresh the UI + generateQuickEvent("thermostatFanMode", value, 0) + if ( parent.setFanMode(this, value, getDeviceId()) ) { - generateQuickEvent("thermostatFanMode", value, 15) + LOG("parent.setFanMode() returned successfully!", 5) } else { generateQuickEvent("thermostatFanMode", device.currentValue("thermostatFanMode")) } @@ -1017,7 +933,7 @@ def setThermostatFanMode(value, holdType=null) { } def fanOn() { - LOG("fanON()", 5) + LOG("fanOn()", 5) setThermostatFanMode("on") } @@ -1038,17 +954,15 @@ def fanOff() { } def generateSetpointEvent() { - LOG("Generate SetPoint Event", 5) def mode = device.currentValue("thermostatMode") def heatingSetpoint = device.currentValue("heatingSetpoint") def coolingSetpoint = device.currentValue("coolingSetpoint") - LOG("Current Mode = ${mode}") - LOG("Heating Setpoint = ${heatingSetpoint}") - LOG("Cooling Setpoint = ${coolingSetpoint}") - + LOG("Current Mode = ${mode}") + LOG("Heating Setpoint = ${heatingSetpoint}") + LOG("Cooling Setpoint = ${coolingSetpoint}") if (mode == "heat") { sendEvent("name":"thermostatSetpoint", "value":heatingSetpoint.toString()) @@ -1107,13 +1021,12 @@ void lowerSetpoint() { def targetvalue if (mode == "off" || (mode == "auto" && !usingSmartAuto() )) { - log.warn "lowerSetpoint(): this mode: $mode does not allow lowerSetpoint" + LOG("lowerSetpoint(): this mode: $mode does not allow lowerSetpoint", 2, null, "warn") } else { - def heatingSetpoint = device.currentValue("heatingSetpoint") def coolingSetpoint = device.currentValue("coolingSetpoint") def thermostatSetpoint = device.currentValue("thermostatSetpoint").toDouble() - log.debug "lowerSetpoint() mode = ${mode}, heatingSetpoint: ${heatingSetpoint}, coolingSetpoint:${coolingSetpoint}, thermostatSetpoint:${thermostatSetpoint}" + LOG("lowerSetpoint() mode = ${mode}, heatingSetpoint: ${heatingSetpoint}, coolingSetpoint:${coolingSetpoint}, thermostatSetpoint:${thermostatSetpoint}", 4) if (thermostatSetpoint) { targetvalue = thermostatSetpoint @@ -1138,7 +1051,6 @@ void lowerSetpoint() { //called by raiseSetpoint() and lowerSetpoint() void alterSetpoint(temp) { - def mode = device.currentValue("thermostatMode") def heatingSetpoint = device.currentValue("heatingSetpoint") def coolingSetpoint = device.currentValue("coolingSetpoint") @@ -1201,7 +1113,7 @@ void alterSetpoint(temp) { sendEvent("name": "thermostatSetpoint", "value": temp.value.toString(), displayed: false) sendEvent("name": "heatingSetpoint", "value": targetHeatingSetpoint) sendEvent("name": "coolingSetpoint", "value": targetCoolingSetpoint) - log.debug "alterSetpoint in mode $mode succeed change setpoint to= ${temp.value}" + LOG("alterSetpoint in mode $mode succeed change setpoint to= ${temp.value}", 4) } else { LOG("WARN: alterSetpoint() - setHold failed. Could be an intermittent problem.", 1, null, "error") sendEvent("name": "thermostatSetpoint", "value": saveThermostatSetpoint.toString(), displayed: false) @@ -1218,32 +1130,25 @@ def generateStatusEvent() { def coolingSetpoint = device.currentValue("coolingSetpoint") def temperature = device.currentValue("temperature") - def statusText - - - LOG("Generate Status Event for Mode = ${mode}") - LOG("Temperature = ${temperature}") - LOG("Heating set point = ${heatingSetpoint}") - LOG("Cooling set point = ${coolingSetpoint}") - LOG("HVAC Mode = ${mode}") - + def statusText + LOG("Generate Status Event for Mode = ${mode}", 4) + LOG("Temperature = ${temperature}", 4) + LOG("Heating setpoint = ${heatingSetpoint}", 4) + LOG("Cooling setpoint = ${coolingSetpoint}", 4) + LOG("HVAC Mode = ${mode}", 4) if (mode == "heat") { - if (temperature >= heatingSetpoint) { statusText = "Right Now: Idle" } else { statusText = "Heating to ${heatingSetpoint}°" } - } else if (mode == "cool") { - if (temperature <= coolingSetpoint) { statusText = "Right Now: Idle" } else { statusText = "Cooling to ${coolingSetpoint}°" } - } else if (mode == "auto") { statusText = "Right Now: Auto (Heat: ${heatingSetpoint}/Cool: ${coolingSetpoint})" } else if (mode == "off") { @@ -1286,6 +1191,10 @@ private def milesToKm(distance) { private def get_URI_ROOT() { return "https://api.ecobee.com" } + +private def getImageURLRoot() { + return "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/" +} // Maximum tstat batch size (25 thermostats max may be processed in batch) private def get_MAX_TSTAT_BATCH() { return 25 @@ -1293,7 +1202,9 @@ private def get_MAX_TSTAT_BATCH() { private def getDeviceId() { - return device.deviceNetworkId.split(/\./).last() + def deviceId = device.deviceNetworkId.split(/\./).last() + LOG("getDeviceId() returning ${deviceId}", 4) + return deviceId } private def usingSmartAuto() { diff --git a/smartapp-icons/ecobee/ecobee_cool_square.png b/smartapp-icons/ecobee/ecobee_cool_square.png new file mode 100644 index 00000000000..6410c0b0f9b Binary files /dev/null and b/smartapp-icons/ecobee/ecobee_cool_square.png differ diff --git a/smartapp-icons/ecobee/ecobee_heat_circle.png b/smartapp-icons/ecobee/ecobee_heat_circle.png new file mode 100644 index 00000000000..ef75375f6bb Binary files /dev/null and b/smartapp-icons/ecobee/ecobee_heat_circle.png differ diff --git a/smartapp-icons/ecobee/ecobee_heat_square.png b/smartapp-icons/ecobee/ecobee_heat_square.png new file mode 100644 index 00000000000..218841c489a Binary files /dev/null and b/smartapp-icons/ecobee/ecobee_heat_square.png differ diff --git a/smartapp-icons/ecobee/ecobee_motion.png b/smartapp-icons/ecobee/ecobee_motion.png new file mode 100644 index 00000000000..c4a96881512 Binary files /dev/null and b/smartapp-icons/ecobee/ecobee_motion.png differ diff --git a/smartapp-icons/ecobee/ecobee_nomotion.png b/smartapp-icons/ecobee/ecobee_nomotion.png new file mode 100644 index 00000000000..85687f18c43 Binary files /dev/null and b/smartapp-icons/ecobee/ecobee_nomotion.png differ diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index 82cfe738feb..9c7a4a51641 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -21,15 +21,23 @@ * 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 * - * Current Version: 0.8.0 - * See separate Changelog for change history + * See Changelog for change history * - */ + */ +def getVersionNum() { return "0.9.0" } +private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC6" } +private def getHelperSmartApps() { + return [ + [name: "ecobeeRoutinesChild", appName: "ecobee Routines", + namespace: "smartthings", multiple: true, + title: "Create new Routines Handler..."] + ] +} definition( name: "Ecobee (Connect)", namespace: "smartthings", - author: "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", @@ -40,9 +48,16 @@ definition( } preferences { - page(name: "auth", title: "ecobee3 Auth", nextPage: "therms", content: "authPage", uninstall: true) - page(name: "therms", title: "Select Thermostats", nextPage: "sensors", content: "thermsPage") - page(name: "sensors", title: "Select Sensors", nextPage: "", content: "sensorsPage", install:true) + page(name: "mainPage") + page(name: "removePage") + page(name: "authPage") + page(name: "thermsPage") + page(name: "sensorsPage") + page(name: "preferencesPage") + page(name: "helperSmartAppsPage") + // Part of debug Dashboard + page(name: "debugDashboardPage") + page(name: "pollChildrenPage") } mappings { @@ -52,110 +67,320 @@ mappings { // Begin Preference Pages +def mainPage() { + + def deviceHandlersInstalled + def readyToInstall + + // Only create the dummy devices if we aren't initialized yet + if (!state.initialized) { + deviceHandlersInstalled = testForDeviceHandlers() + readyToInstall = deviceHandlersInstalled + } + if (state.initialized) { readyToInstall = true } + + dynamicPage(name: "mainPage", title: "Welcome to ecobee (Connect)", install: readyToInstall, uninstall: false, submitOnChange: true) { + def ecoAuthDesc = (state.authToken != null) ? "[Connected]\n" :"[Not Connected]\n" + + // If not device Handlers we cannot proceed + if(!state.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(state.initialized && !state.authToken) { + section() { + paragraph "WARNING!\n\nYou are no longer connected to the ecobee API. Please re-Authorize below." + } + } + + if(state.authToken != null && state.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(state.authToken != null && state.initialized) { + if (settings.thermostats?.size() > 0 && state.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 = state.numAvailTherms ?: "?" + def howManySensors = state.numAvailSensors ?: "?" + + // Thermostats + state.settingsCurrentTherms = settings.thermostats ?: [] + href ("thermsPage", title: "Thermostats", description: "Tap to select Thermostats [${howManyThermsSel}/${howManyTherms}]") + + // Sensors + if (settings.thermostats?.size() > 0) { + state.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") + } + 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)") + } + } // End if(state.authToken) + + // Setup our API Tokens + section("Ecobee Authentication") { + href ("authPage", title: "ecobee Authorization", description: "${ecoAuthDesc}Tap for ecobee Credentials") + } + + 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() + if(!state.accessToken) { //this is an access token for the 3rd party to make a call to the connect app + state.accessToken = createAccessToken() } - def description = "Click to enter Ecobee Credentials" + def description = "Click to enter ecobee Credentials" def uninstallAllowed = false def oauthTokenProvided = false - if(atomicState.authToken) { - description = "You are connected. Click Next above." + if(state.authToken) { + description = "You are connected. Tap Done above." uninstallAllowed = true oauthTokenProvided = true + apiRestored() } else { - description = "Click to enter Ecobee Credentials" + description = "Tap to enter ecobee Credentials" } - def redirectUrl = buildRedirectUrl //"${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}" + def redirectUrl = buildRedirectUrl //"${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.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: "auth", title: "ecobee Setup", nextPage: "", uninstall: uninstallAllowed) { + 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 scroll down on page 2 and press the 'Allow' button." - href url:redirectUrl, style:"embedded", required:true, title: "ecobee Account Login", description:description + 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: "auth", title: "ecobee Setup", nextPage: "therms", uninstall: uninstallAllowed) { + LOG("authPage() --> in else for oauthTokenProvided - ${state.authToken}.") + return dynamicPage(name: "authPage", title: "ecobee Setup", nextPage: "mainPage", uninstall: uninstallAllowed) { section() { - paragraph "Continue on to select thermostats." - href url:redirectUrl, style: "embedded", state: "complete", title: "ecobee Account Login", description: description + paragraph "Return to main menu." + href url:redirectUrl, style: "embedded", state: "complete", title: "ecobee Account Authorization", description: description } } } } -def thermsPage() { +// Select which Thermostats are to be used +def thermsPage(params) { LOG("=====> thermsPage() entered", 5) + state.thermsPageVisited = true def stats = getEcobeeThermostats() - LOG("thermsPage() -> thermostat list: $stats") + LOG("thermsPage() -> thermostat list: ${stats}") LOG("thermsPage() starting settings: ${settings}") - - dynamicPage(name: "therms", title: "Select Thermostats", nextPage: "sensors", content: "thermsPage", uninstall: true) { + 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("thersPage(): state.settingsCurrentTherms=${state.settingsCurrentTherms} settings.thermostats=${settings.thermostats}", 4, null, "trace") + if (state.settingsCurrentTherms != settings.thermostats) { + LOG("state.settingsCurrentTherms != settings.thermostats determined!!!", 4, null, "trace") + } else { LOG("state.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:true, multiple:true, description: "Tap to choose", metadata:[values:stats]) - } - section("Optional Settings") { - input(name: "holdType", title:"Select Hold Type", type: "enum", required:false, multiple:false, description: "Until I Change", metadata:[values:["Until I Change", "Until Next Program"]]) - input(name: "smartAuto", title:"Use Smart Auto Temperature Adjust?", type: "bool", required:false, description: false) - input(name: "pollingInterval", title:"Polling Interval (in Minutes)", type: "enum", required:false, multiple:false, description: "5", options:["5", "10", "15", "30"]) - input(name: "debugLevel", title:"Debugging Level (higher # is more data reported)", type: "enum", required:false, multiple:false, description: "3", metadata:[values:["5", "4", "3", "2", "1", "0"]]) - } - } + 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) + state.sensorsPageVisited = true def options = getEcobeeSensors() ?: [] def numFound = options.size() ?: 0 - + LOG("options = getEcobeeSensors == ${options}") - dynamicPage(name: "sensors", title: "Select Sensors", nextPage: "") { + dynamicPage(name: "sensorsPage", title: "Select Sensors", nextPage: "") { if (numFound > 0) { - section(""){ + section("Select Sensors"){ + LOG("sensorsPage(): state.settingsCurrentSensors=${state.settingsCurrentSensors} settings.ecobeesensors=${settings.ecobeesensors}", 4, null, "trace") + if (state.settingsCurrentSensors != settings.ecobeesensors) { + LOG("state.settingsCurrentSensors != settings.ecobeesensors determined!!!", 4, null, "trace") + } else { LOG("state.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 { - // Must not have any sensors associated with this Thermostat + // 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 preferencesPage() { + LOG("=====> preferencesPage() entered. settings: ${settings}", 5) + + dynamicPage(name: "preferencesPage", title: "Update SmartApp Preferences", nextPage: "") { + section("SmartApp Preferences") { + input(name: "holdType", title:"Select Hold Type", type: "enum", required:false, multiple:false, 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, description: false) + input(name: "pollingInterval", title:"Polling Interval (in Minutes)", type: "enum", required:false, multiple:false, description: "5", options:["5", "10", "15", "30"]) + input(name: "debugLevel", title:"Debugging Level (higher # for more information)", type: "enum", required:false, multiple:false, description: "3", metadata:[values:["5", "4", "3", "2", "1", "0"]]) + paragraph "Showing a Thermostat as a Remote 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 Remote Sensor?", type: "bool", required:false, description: false) + } + } +} + +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 command: pollChildren()") + } + section("Settings Information") { + paragraph "debugLevel: ${settings.debugLevel} (default=3 if null)" + paragraph "holdType: ${settings.holdType} (default='Until I Change' if null)" + paragraph "pollingInterval: ${settings.pollingInterval} (default=5 if null)" + paragraph "showThermsAsSensor: ${settings.showThermsAsSensor} (default=false if null)" + paragraph "smartAuto: ${settings.smartAuto} (default=false if null)" + paragraph "Selected Thermostats: ${settings.thermostats}" + } + 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) + state.forcePoll = true // Reset to force the poll to happen + pollChildren(null) + + dynamicPage(name: "pollChildrenPage", title: "") { + section() { + paragraph "pollChildren() 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) { + 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) + //app(name: "${oneApp.name.value}", appName: "ecobee Routines", namespace: "smartthings", title: "Create new ecobee Routine Handler...", multiple: true) + } + } } } // End Prefernce Pages + +// Preference Pages Helpers +private def Boolean testForDeviceHandlers() { + if (state.runTestOnce != null) { return state.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"]) + d2 = addChildDevice(app.namespace, getChildSensorName(), "dummySensorDNI-${DNIAdder}", null, ["label":"Ecobee Sensor:TestingForInstall"]) + } 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 + } + + state.runTestOnce = success + + if (d1) deleteChildDevice("dummyThermDNI-${DNIAdder}") + if (d2) deleteChildDevice("dummySensorDNI-${DNIAdder}") + + return success +} + +// End Preference Pages Helpers + // OAuth Init URL def oauthInitUrl() { LOG("oauthInitUrl with callback: ${callbackUrl}", 5) - - atomicState.oauthInitState = UUID.randomUUID().toString() + state.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 + state: state.oauthInitState ] LOG("oauthInitUrl - Before redirect: location: ${apiEndpoint}/authorize?${toQueryString(oauthParams)}", 4) @@ -164,13 +389,12 @@ def oauthInitUrl() { // OAuth Callback URL and helpers def callback() { - LOG("callback()>> params: $params, params.code ${params.code}, params.state ${params.state}, atomicState.oauthInitState ${atomicState.oauthInitState}", 4) - + LOG("callback()>> params: $params, params.code ${params.code}, params.state ${params.state}, state.oauthInitState ${state.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){ + //verify oauthState == state.oauthInitState, so the callback corresponds to the authentication request + if (oauthState == state.oauthInitState){ LOG("callback() --> States matched!", 4) def tokenParams = [ grant_type: "authorization_code", @@ -184,22 +408,22 @@ def callback() { LOG("callback()-->tokenURL: ${tokenUrl}", 2) httpPost(uri: tokenUrl) { resp -> - atomicState.refreshToken = resp.data.refresh_token - atomicState.authToken = resp.data.access_token + state.refreshToken = resp.data.refresh_token + state.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) + state.authTokenExpires = now() + (resp.data.expires_in * 1000) + LOG("swapped token: $resp.data; state.refreshToken: ${state.refreshToken}; state.authToken: ${state.authToken}", 2) } - if (atomicState.authToken) { - success() + if (state.authToken) { + success() } else { fail() } } else { - LOG("callback() failed oauthState != atomicState.oauthInitState", 1) + LOG("callback() failed oauthState != state.oauthInitState", 1, null, "warn") } } @@ -294,29 +518,27 @@ def connectionStatus(message, redirectUrl = null) { // Get the list of Ecobee Thermostats for use in the settings pages def getEcobeeThermostats() { - LOG("====> getEcobeeThermostats() entered", 5) - + LOG("====> getEcobeeThermostats() entered", 5) def requestBody = '{"selection":{"selectionType":"registered","selectionMatch":"","includeRuntime":true,"includeSensors":true,"includeProgram":true}}' - def deviceListParams = [ uri: apiEndpoint, path: "/1/thermostat", - headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${state.authToken}"], query: [format: 'json', body: requestBody] ] def stats = [:] try { httpGet(deviceListParams) { resp -> - LOG("httpGet() response: ${resp.data}") // Initialize the Thermostat Data. Will reuse for the Sensor List intialization - atomicState.thermostatData = resp.data - + state.thermostatData = resp.data if (resp.status == 200) { LOG("httpGet() in 200 Response") + state.numAvailTherms = resp.data.thermostatList?.size() ?: 0 + resp.data.thermostatList.each { stat -> def dni = [app.id, stat.identifier].join('.') stats[dni] = getThermostatDisplayName(stat) @@ -326,9 +548,9 @@ def getEcobeeThermostats() { //refresh the auth token if (resp.status == 500 && resp.data.status.code == 14) { LOG("Storing the failed action to try later") - atomicState.action = "getEcobeeThermostats" + state.action = "getEcobeeThermostats" LOG("Refreshing your auth_token!", 1) - refreshAuthToken() + refreshAuthToken(true) } else { LOG("Other error. Status: ${resp.status} Response data: ${resp.data} ", 1) } @@ -336,24 +558,29 @@ def getEcobeeThermostats() { } } catch(Exception e) { LOG("___exception getEcobeeThermostats(): ${e}", 1, null, "error") - refreshAuthToken() + refreshAuthToken(true) } - atomicState.thermostatsWithNames = stats - LOG("atomicState.thermostatsWithNames == ${atomicState.thermostatsWithNames}", 4) + state.thermostatsWithNames = stats + LOG("state.thermostatsWithNames == ${state.thermostatsWithNames}", 4) 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 = [:] + state.remoteSensors = [:] - atomicState.thermostatData.thermostatList.each { singleStat -> - LOG("thermostat loop: singleStat == ${singleStat} singleStat.identifier == ${singleStat.identifier}", 4) + // Need to query to get full list of Thermostats (need to pull this here as we can call getEcobeeSensors out of sequence after initial setup + // TODO: Check on possible race conditions. Leave to update and initialize procedures to call in sequence? + // getEcobeeThermostats() + + state.thermostatData.thermostatList.each { singleStat -> + LOG("thermostat loop: singleStat.identifier == ${singleStat.identifier} -- singleStat.remoteSensors == ${singleStat.remoteSensors} ", 4) if (!settings.thermostats.findAll{ it.contains(singleStat.identifier) } ) { // We can skip this thermostat as it was not selected by the user @@ -361,31 +588,38 @@ Map getEcobeeSensors() { } else { LOG("getEcobeeSensors() --> Entering the else... we found a match. singleStat == ${singleStat.name}", 4) - atomicState.remoteSensors = atomicState.remoteSensors ? (atomicState.remoteSensors + singleStat.remoteSensors) : singleStat.remoteSensors - LOG("After atomicState.remoteSensors setup...", 5) + state.remoteSensors = state.remoteSensors ? (state.remoteSensors + singleStat.remoteSensors) : singleStat.remoteSensors + LOG("After state.remoteSensors setup...", 5) - // 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 - // TODO: Check to see if this is still needed. Seem to use elibleSensors now instead - // singleStat.remoteSensors.each { tempSensor -> - // tempSensor.thermDNI = "${thermostat}" - // atomicState.remoteSensors = atomicState.remoteSensors + tempSensor - //} LOG("getEcobeeSensors() - singleStat.remoteSensors: ${singleStat.remoteSensors}", 4) - LOG("getEcobeeSensors() - atomicState.remoteSensors: ${atomicState.remoteSensors}", 4) + LOG("getEcobeeSensors() - state.remoteSensors: ${state.remoteSensors}", 4) } - atomicState.remoteSensors.each { - if (it.type != "thermostat") { + // 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: ${state.remoteSensors}", 5, null, "trace") + state.remoteSensors.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 { + 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 + state.eligibleSensors = sensorMap + state.numAvailSensors = sensorMap.size() ?: 0 return sensorMap } @@ -402,41 +636,127 @@ def getThermostatTypeName(stat) { } def installed() { - LOG("Installed with settings: ${settings}", 5) + LOG("Installed with settings: ${settings}", 4) initialize() } def updated() { - LOG("Updated with settings: ${settings}", 4) - unsubscribe() - initialize() + LOG("Updated with settings: ${settings}", 4) + initialize() } def initialize() { LOG("=====> initialize()", 4) - atomicState.connected = "full" - unschedule() - atomicState.reAttempt = 0 + state.connected = "full" + state.reAttempt = 0 + + try { + unsubscribe() + unschedule() // reset all the schedules + } catch (Exception e) { + LOG("updated() - Exception encountered trying to unschedule(). Exception: ${e}", 2, null, "error") + } + + def nowTime = now() + def nowDate = getTimestamp() + + // Initialize several variables + state.lastScheduledPoll = nowTime + state.lastScheduledPollDate = nowDate + state.lastScheduledTokenRefresh = nowTime + state.lastScheduledTokenRefreshDate = nowDate + state.lastScheduledWatchdog = nowTime + state.lastScheduledWatchdogDate = nowDate + state.lastPoll = nowTime + state.lastPollDate = nowDate + + // Setup initial polling and determine polling intervals + state.pollingInterval = getPollingInterval() + state.tokenGrace = 18 // Anything more than this then we have a possible failed + state.watchdogInterval = 15 + + if (state.initialized) { + // refresh Thermostats and Sensor full lists + getEcobeeThermostats() + getEcobeeSensors() + } + + // 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) { if (canSchedule()) { runIn(10, pollInit) } else { pollInit() } } + if (settings.thermostats?.size() > 0) { pollInit() } + + // Add subscriptions as little "daemons" that will check on our health + subscribe(location, "routineExecuted", scheduleWatchdog) + subscribe(location, "sunset", scheduleWatchdog) + subscribe(location, "sunrise", scheduleWatchdog) + + // Schedule the various handlers + if (settings.thermostats?.size() > 0) { spawnDaemon("poll") } + spawnDaemon("watchdog") + spawnDaemon("auth") + // TODO: Add ability to add additional physical (or virtual) items to subscribe to that have events generated that could heal our app + + //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) + state.timeSendPush = null + if (!state.initialized) { + state.initialized = true + state.initializedEpic = nowTime + state.initializedDate = nowDate + } + + return aOK +} + +private def createChildrenThermostats() { + LOG("createChildrenThermostats() entered: thermostats=${settings.thermostats}", 5) // Create the child Thermostat Devices - def devices = thermostats.collect { dni -> + def devices = settings.thermostats.collect { dni -> def d = getChildDevice(dni) if(!d) { - d = addChildDevice(app.namespace, getChildThermostatName(), dni, null, ["label":"Ecobee Thermostat:${atomicState.thermostatsWithNames[dni]}"]) + // TODO: Place in a try block and check for this exception: physicalgraph.app.exception.UnknownDeviceTypeException + try { + d = addChildDevice(app.namespace, getChildThermostatName(), dni, null, ["label":"EcoTherm: ${state.thermostatsWithNames[dni]}"]) + } 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) - + LOG("found ${d.displayName} with id $dni already exists", 4) } return d - } + } + + LOG("Created/Updated ${devices.size()} thermostats") + return true +} - // Create the child Ecobee Sensor Devices +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) { - d = addChildDevice(app.namespace, getChildSensorName(), dni, null, ["label":"Ecobee Sensor:${atomicState.eligibleSensors[dni]}"]) + // TODO: Place in a try block and check for this exception: physicalgraph.app.exception.UnknownDeviceTypeException + try { + d = addChildDevice(app.namespace, getChildSensorName(), dni, null, ["label":"EcoSensor: ${state.eligibleSensors[dni]}"]) + } 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) @@ -444,98 +764,283 @@ def initialize() { return d } - LOG("created ${devices.size()} thermostats and ${sensors.size()} sensors.") + LOG("Created/Updated ${sensors.size()} sensors.") + return true +} +// NOTE: For this to work effectively 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 ?: []) + (state.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) + } +} + - // WORKAROUND: settings.ecobeesensors may contain leftover sensors in the dynamic enum bug scenario, use info in atomicState.eligibleSensors instead - // TODO: Need to deal with individual sensors from remaining thermostats that might be excluded... - // TODO: Cleanup this code now that it is working with workaround - def sensorList = atomicState.eligibleSensors.keySet() +def scheduleWatchdog(evt=null, local=false) { + def results = true + LOG("scheduleWhatdog() called with: evt (${evt}) & 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) ) { + state.lastScheduledWatchdog = now() + state.lastScheduledWatchdogDate = getTimestamp() + } - // atomicState.eligibleSensorsAsList = sensorList + state.lastWatchdog = now() + state.lastWatchdogDate = getTimestamp() - def reducedSensorList = settings.ecobeesensors.findAll { sensorList.contains(it) } - LOG("**** reducedSensorList = ${reducedSensorList} *****", 4, null, "warn") - atomicState.activeSensors = reducedSensorList + LOG("After watchdog tagging") + if(apiConnected() == "lost") { + // Possibly a false alarm? Check if we can update the token with one last fleeting try... + if( refreshAuthToken(true) ) { + // We are back in business! + LOG("scheduleWatchdog() - Was able to recover the lost connection. Please ignore any notifications received.", 1, null, "error") + } else { + LOG("scheduleWatchdog() - Unable toschedule handlers do to loss of API Connection. Please ensure you are authorized.", 1, null, "error") + return false + } + } + + def pollAlive = isDaemonAlive("poll") + def authAlive = isDaemonAlive("auth") + def watchdogAlive = isDaemonAlive("watchdog") + + LOG("scheduleWatchdog() --> pollAlive==${pollAlive} authAlive==${authAlive} watchdogAlive==${watchdogAlive}", 4, null, "debug") + + // Reschedule polling if it has been a while since the previous poll + if (!pollAlive) { spawnDaemon("poll") } + if (!authAlive) { spawnDaemon("auth") } + if (!watchdogAlive) { spawnDaemon("watchdog") } + + return +} + +// Watchdog Handler +private def Boolean isDaemonAlive(daemon="all") { + // Daemon options: "poll", "auth", "watchdog", "all" + def daemonList = ["poll", "auth", "watchdog", "all"] + + daemon = daemon.toLowerCase() + def result = true + + def timeSinceLastScheduledPoll = (state.lastScheduledPoll == 0 || state.lastScheduledPoll == null) ? 0 : ((now() - state.lastScheduledPoll) / 1000 / 60) // TODO: Removed toDouble() will this impact? + def timeSinceLastScheduledRefresh = (state.lastScheduledTokenRefresh == 0 || state.lastScheduledTokenRefresh == null) ? 0 : ((now() - state.lastScheduledTokenRefresh) / 1000 / 60) + def timeSinceLastScheduledWatchdog = (state.lastScheduledWatchdog == 0 || state.lastScheduledWatchdog == null) ? 0 : ((now() - state.lastScheduledWatchdog) / 1000 / 60) + def timeBeforeExpiry = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 + + LOG("isDaemonAlive() - now() == ${now()} for daemon (${daemon})", 5, null, "trace") + LOG("isDaemonAlive() - Time since last poll? ${timeSinceLastScheduledPoll} -- state.lastScheduledPoll == ${state.lastScheduledPoll}", 4, null, "info") + LOG("isDaemonAlive() - Time since last token refresh? ${timeSinceLastScheduledRefresh} -- state.lastScheduledTokenRefresh == ${state.lastScheduledTokenRefresh}", 4, null, "info") + LOG("isDaemonAlive() - Time since watchdog activation? ${timeSinceLastScheduledWatchdog} -- state.lastScheduledWatchdog == ${state.lastScheduledWatchdog}", 4, null, "info") + LOG("isDaemonAlive() - Time left (timeBeforeExpiry) until expiry (in min): ${timeBeforeExpiry}", 4, null, "info") - LOG("sensorList based on keys: ${sensorList} from atomicState.sensors: ${atomicState.eligibleSensors}", 4) + if (daemon == "poll" || daemon == "all") { + LOG("isDaemonAlive() - Checking daemon (${daemon}) in 'poll'", 4, null, "trace") + def maxInterval = state.pollingInterval + 3 + // if ( (timeSinceLastScheduledPoll == 0) || (timeSinceLastScheduledPoll >= maxInterval) || (state.initialized != true) ) { result = false } + if ( timeSinceLastScheduledPoll >= maxInterval ) { result = false } + } - def combined = settings.thermostats + atomicState.activeSensors - LOG("Combined devices == ${combined}", 4) + if (daemon == "auth" || daemon == "all") { + LOG("isDaemonAlive() - Checking daemon (${daemon}) in 'auth'", 4, null, "trace") + // if ( (timeSinceLastScheduledRefresh == 0) || (timeSinceLastScheduledRefresh >= state.tokenGrace) || (state.initialized != true) ) { result = false } + if ( timeSinceLastScheduledRefresh >= state.tokenGrace ) { result = false } + } - // Delete any that are no longer in settings - def delete + if (daemon == "watchdog" || daemon == "all") { + LOG("isDaemonAlive() - Checking daemon (${daemon}) in 'watchdog'", 4, null, "trace") + def maxInterval = state.watchdogInterval + 6 + //if ( (timeSinceLastScheduledWatchdog == 0) || (timeSinceLastScheduledWatchdog >= (maxInterval)) || (state.initialized != true) ) { result = false } + LOG("isDaemonAlive(watchdog) - timeSinceLastScheduledWatchdog=(${timeSinceLastScheduledWatchdog}) Timestamps: (${state.lastScheduledWatchdogDate}) (epic: ${state.lastScheduledWatchdog}) now-(${now()})", 4, null, "trace") + if ( timeSinceLastScheduledWatchdog >= maxInterval ) { result = false } + } - if (combined) { - delete = getChildDevices().findAll { !combined.contains(it.deviceNetworkId) } - } else { - delete = getAllChildDevices() // inherits from SmartApp (data-management) + 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 + } + LOG("isDaemonAlive() - result is ${result}", 4, null, "trace") + return result +} + +private def Boolean spawnDaemon(daemon="all") { + // Daemon options: "poll", "auth", "watchdog", "all" + def daemonList = ["poll", "auth", "watchdog", "all"] + + daemon = daemon.toLowerCase() + def result = true + + if (daemon == "poll" || daemon == "all") { + LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'poll'", 4, null, "trace") + // Reschedule the daemon + try { + // result = result && unschedule("pollScheduled") + if ( canSchedule() ) { + "runEvery${state.pollingInterval}Minutes"("pollScheduled") + // if ( canSchedule() ) { runIn(30, "pollScheduled") } // This will wipe out the existing scheduler! + // Web Services taking too long. Go ahead and only schedule here for now + + result = result && pollScheduled() + } else { + LOG("canSchedule() is NOT allowed! Unable to schedule daemon!", 1, null, "error") + result = false + } + } catch (Exception e) { + LOG("spawnDaemon() - Exception when performing unschedule() of ${daemon}. Exception: ${e}", 1, null, "error") + result = result && false + } } - LOG("delete: ${delete}, deleting ${delete.size()} thermostat(s) and/or sensor(s)", 4, null, "warn") - delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) + if (daemon == "auth" || daemon == "all") { + LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'auth'", 4, null, "trace") + // Reschedule the daemon + try { + // result = result && unschedule("refreshAuthTokenScheduled") + if ( canSchedule() ) { + runEvery15Minutes("refreshAuthTokenScheduled") + // if ( canSchedule() ) { runIn(30, "refreshAuthTokenScheduled") } // Don't count this against the results + // Web Services taking too long. Go ahead and only schedule here for now + + result = result && refreshAuthTokenScheduled() + } else { + LOG("canSchedule() is NOT allowed! Unable to schedule daemon!", 1, null, "error") + result = false + } + } catch (Exception e) { + LOG("spawnDaemon() - Exception when performing unschedule() of ${daemon}. Exception: ${e}", 1, null, "error") + result = result && false + } + } + + if (daemon == "watchdog" || daemon == "all") { + LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'watchdog'", 4, null, "trace") + // Reschedule the daemon + try { + // result = result && unschedule("scheduleWatchdog") + if ( canSchedule() ) { + runEvery15Minutes("scheduleWatchdog") + result = result && true + } else { + LOG("canSchedule() is NOT allowed! Unable to schedule daemon!", 1, null, "error") + result = false + } + } catch (Exception e) { + LOG("spawnDaemon() - Exception when performing unschedule() of ${daemon}. Exception: ${e}", 1, null, "error") + result = 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 + } + return result +} - atomicState.thermostatData = [:] //reset Map to store thermostat data - //send activity feeds to tell that device is connected - def notificationMessage = "is connected to SmartThings" - sendActivityFeeds(notificationMessage) - atomicState.timeSendPush = null +def updateLastPoll(Boolean isScheduled=false) { + if (isScheduled) { + state.lastScheduledPoll = now() + state.lastScheduledPollDate = getTimestamp() + } else { + state.lastPoll = now() + state.lastPollDate = getTimestamp() + } +} + +// Called by scheduled() event handler +def pollScheduled() { + updateLastPoll(true) + LOG("pollScheduled() - Running at ${state.lastScheduledPollDate} (epic: ${state.lastScheduledPoll})", 3, null, "trace") + return poll() +} - pollHandler() //first time polling data from thermostat - //automatically update devices status every 5 mins - def interval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 - "runEvery${interval}Minutes"("poll") - // runEvery5Minutes("poll") +def updateLastTokenRefresh(isScheduled=false) { + if (isScheduled) { + state.lastScheduledTokenRefresh = now() + state.lastScheduledTokenRefreshDate = getTimestamp() + LOG("updateLastTokenRefresh(true) - Updated timestamps: state.lastScheduledTokenRefreshDate=(${state.lastScheduledTokenRefreshDate}) state.lastScheduledTokenRefresh=${state.lastScheduledTokenRefresh} ", 5, null, "trace") + } else { + state.lastTokenRefresh = now() + state.lastTokenRefreshDate = getTimestamp() + } +} - // Auth Token expires every hour - // Run as part of the poll() procedure since it runs every 5 minutes. Only runs if the time is close enough though to avoid API calls - runEvery15Minutes("refreshAuthToken") +// Called by scheduled() event handler +def refreshAuthTokenScheduled() { + updateLastTokenRefresh(true) + LOG("refreshAuthTokenScheduled() - Running at ${state.lastScheduledTokenRefreshDate} (epic: ${state.lastScheduledTokenRefresh})", 3, null, "trace") + + def result = refreshAuthToken() + return result } // Called during initialization to get the inital poll -def pollHandler() { - LOG("pollHandler()", 5) - atomicState.lastPoll = 0 // Initialize the variable and force a poll even if there was one recently +def pollInit() { + LOG("pollInit()", 5) + state.forcePoll = true // Initialize the variable and force a poll even if there was one recently pollChildren(null) // Hit the ecobee API for update on all thermostats } def pollChildren(child = null) { - LOG("=====> pollChildren()", 4) + def results = true - if (apiConnected() == "lost") { - LOG("pollChildren() - Unable to pollChildren() due to API not being connected", 1, child) - return - } + LOG("=====> pollChildren()", 4) + 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("pollChildren() - Was able to recover the lost connection. Please ignore any notifications received.", 1, child, "error") + } else { + LOG("pollChildren() - Unable toschedule handlers do to loss of API Connection. Please ensure you are authorized.", 1, child, "error") + return false + } + } - // Check to see if it is time to do an full poll to the Ecobee servers. If so, execute the API call and update ALL children - def timeSinceLastPoll = (atomicState.lastPoll == 0) ? 0 : ((now() - atomicState.lastPoll?.toDouble()) / 1000 / 60) - LOG("Time since last poll? ${timeSinceLastPoll} -- atomicState.lastPoll == ${atomicState.lastPoll}", 3, child, "info") + // 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, child, "warn") + return true + } - // Reschedule polling if it has been a while since the previous poll - def interval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 - if ( timeSinceLastPoll >= (interval * 2) ) { - // automatically update devices status every ${interval} mins - // re-establish polling - LOG("pollChildren() - Rescheduling handlers due to delays!", 1, child, "warn") - unschedule() - "runEvery${interval}Minutes"("poll") - runEvery15Minutes("refreshAuthToken") - } - if ( (atomicState.lastPoll == 0) || ( timeSinceLastPoll > getMinMinBtwPolls().toDouble() ) ) { + // Check to see if it is time to do an full poll to the Ecobee servers. If so, execute the API call and update ALL children + def timeSinceLastPoll = (state.forcePoll == true) ? 0 : ((now() - state.lastPoll?.toDouble()) / 1000 / 60) + LOG("Time since last poll? ${timeSinceLastPoll} -- state.lastPoll == ${state.lastPoll}", 3, child, "info") + + if ( (state.forcePoll == true) || ( timeSinceLastPoll > getMinMinBtwPolls().toDouble() ) ) { // It has been longer than the minimum delay LOG("Calling the Ecobee API to fetch the latest data...", 4, child) - pollEcobeeAPI(getChildThermostatDeviceIdsString()) // This will update the values saved in the atomicState which can then be used to send the updates + pollEcobeeAPI(getChildThermostatDeviceIdsString()) // This will update the values saved in the state which can then be used to send the updates } else { LOG("pollChildren() - Not time to call the API yet. It has been ${timeSinceLastPoll} minutes since last full poll.", 4, child) generateEventLocalParams() // Update any local parameters and send } - // Iterate over all the children def d = getChildDevices() @@ -545,13 +1050,14 @@ def pollChildren(child = null) { if( oneChild.hasCapability("Thermostat") ) { // We found a Thermostat, send all of its events LOG("pollChildren() - We found a Thermostat!", 5) - oneChild.generateEvent(atomicState.thermostats[oneChild.device.deviceNetworkId].data) + oneChild.generateEvent(state.thermostats[oneChild.device.deviceNetworkId]?.data) } else { // We must have a remote sensor - LOG("pollChildren() - Updating sensor data: ${oneChild.device.deviceNetworkId} data: ${atomicState.remoteSensorsData[oneChild.device.deviceNetworkId].data}", 4) - oneChild.generateEvent(atomicState.remoteSensorsData[oneChild.device.deviceNetworkId].data) + LOG("pollChildren() - Updating sensor data for ${oneChild}: ${oneChild.device.deviceNetworkId} data: ${state.remoteSensorsData[oneChild.device.deviceNetworkId]?.data}", 4) + oneChild.generateEvent(state.remoteSensorsData[oneChild.device.deviceNetworkId]?.data) } } + return results } private def generateEventLocalParams() { @@ -568,7 +1074,7 @@ private def generateEventLocalParams() { apiConnected: apiConnected() ] - atomicState.thermostats[oneChild.device.deviceNetworkId].data.apiConnected = apiConnected() + state.thermostats[oneChild.device.deviceNetworkId].data?.apiConnected = apiConnected() oneChild.generateEvent(data) } else { // We must have a remote sensor @@ -581,6 +1087,7 @@ private def generateEventLocalParams() { private def pollEcobeeAPI(thermostatIdsString = "") { LOG("=====> pollEcobeeAPI() entered - thermostatIdsString = ${thermostatIdsString}", 2, null, "info") + state.forcePoll = false // TODO: Check on any running EVENTs on thermostat @@ -596,42 +1103,37 @@ private def pollEcobeeAPI(thermostatIdsString = "") { def pollParams = [ uri: apiEndpoint, path: "/1/thermostat", - headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${state.authToken}"], query: [format: 'json', body: jsonRequestBody] ] try{ httpGet(pollParams) { resp -> - -// if (resp.data) { -// debugEventFromParent(child, "pollChildren(child) >> resp.status = ${resp.status}, resp.data = ${resp.data}") -// } - if(resp.status == 200) { LOG("poll results returned resp.data ${resp.data}", 2) - atomicState.remoteSensors = resp.data.thermostatList.remoteSensors - atomicState.thermostatData = resp.data + state.remoteSensors = resp.data.thermostatList.remoteSensors + state.thermostatData = resp.data // Create the list of sensors and related data updateSensorData() // Create the list of thermostats and related data - updateThermostatData() - + updateThermostatData() result = true - if (atomicState.connected != "full") { - atomicState.connected = "full" + if (apiConnected() != "full") { + apiRestored() generateEventLocalParams() // Update the connection status } - atomicState.lastPoll = now(); - LOG("httpGet: updated ${atomicState.thermostats?.size()} stats: ${atomicState.thermostats}") + state.lastPoll = now(); + LOG("httpGet: updated ${state.thermostats?.size()} stats: ${state.thermostats}") } else { LOG("pollEcobeeAPI() - polling children & got http status ${resp.status}", 1, null, "error") //refresh the auth token if (resp.status == 500 && resp.data.status.code == 14) { LOG("Resp.status: ${resp.status} Status Code: ${resp.data.status.code}. Unable to recover", 1, null, "error") - // Not possible to recover from a code 14 + // Should not 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 { @@ -641,24 +1143,26 @@ private def pollEcobeeAPI(thermostatIdsString = "") { } } catch (groovyx.net.http.HttpResponseException e) { LOG("pollEcobeeAPI() >> HttpResponseException occured. Exception info: ${e} StatusCode: ${e.statusCode} response? data: ${e.getResponse()?.getData()}", 1, null, "error") + result = false def reAttemptPeriod = 45 // in sec if ( (e.statusCode == 500 && e.getResponse()?.data.status.code == 14) || (e.statusCode == 401 && e.getResponse()?.data.status.code == 14) ) { // Not possible to recover from status.code == 14 + // if ( refreshAuthToken() ) { LOG("We have recovered the token a from the code 14.", 2, null, "warn") } LOG("In HttpResponseException: Received data.stat.code of 14", 1, null, "error") apiLost("pollEcobeeAPI() - In HttpResponseException: Received data.stat.code of 14") } else if (e.statusCode != 401) { //this issue might comes from exceed 20sec app execution, connectivity issue etc. LOG("In HttpResponseException - statusCode != 401 (${e.statusCode})", 1, null, "warn") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - runIn(reAttemptPeriod, "pollEcobeeAPI") // retry to poll + if(canSchedule()) { runIn(reAttemptPeriod, "pollChildren") } else { pollChildren() } } else if (e.statusCode == 401) { // Status.code other than 14 - atomicState.reAttemptPoll = atomicState.reAttemptPoll + 1 - LOG("statusCode == 401: reAttempt refreshAuthToken to try = ${atomicState.reAttemptPoll}", 1, null, "warn") - if (atomicState.reAttemptPoll <= 3) { - atomicState.connected = "warn" + state.reAttemptPoll = state.reAttemptPoll + 1 + LOG("statusCode == 401: reAttempt refreshAuthToken to try = ${state.reAttemptPoll}", 1, null, "warn") + if (state.reAttemptPoll <= 3) { + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - runIn(reAttemptPeriod, "pollEcobeeAPI") + if(canSchedule()) { runIn(reAttemptPeriod, "pollChildren") } else { pollChildren() } } else { LOG("Unable to poll EcobeeAPI after three attempts. Will try to refresh authtoken.", 1, null, "error") debugEvent( "Unable to poll EcobeeAPI after three attempts. Will try to refresh authtoken." ) @@ -678,25 +1182,33 @@ private def pollEcobeeAPI(thermostatIdsString = "") { // poll() will be called on a regular interval using a runEveryX command -void poll() { - // def devices = getChildDevices() - // devices.each {pollChildren(it)} - // TODO: if ( readyForAuthRefresh() ) { refreshAuthToken() } // Use runIn to make this feasible? - +def poll() { + LOG("poll() - Running at ${state.lastPollDate} (epic: ${state.lastPoll})", 3, null, "trace") + // Check to see if we are connected to the API or not if (apiConnected() == "lost") { - LOG("poll() - Skipping poll() due to lost API Connection", 1, null, "warn") - } else { - LOG("poll() - Polling children with pollChildren(null)", 4) - pollChildren(null) // Poll ALL the children at the same time for efficiency + LOG("poll() - Attempting to recover poll() due to lost API Connection", 1, null, "warn") + scheduleWatchdog(null, true) + if ( refreshAuthToken() ) { + // We were able to recover + apiRestored() + LOG("poll() - we were able to recover the connection!", 1, null, "info") + } else { + LOG("poll() - we were unable to recover the connection.", 2, null, "error") + return false + } } + + state.lastPoll = now() + state.lastPollDate = getTimestamp() + LOG("poll() - Polling children with pollChildren(null)", 4) + return pollChildren(null) // Poll ALL the children at the same time for efficiency } -def availableModes(child) { - - def tData = atomicState.thermostats[child.device.deviceNetworkId] - LOG("atomicState.thermostats = ${atomicState.thermostats}", 3, child) +def availableModes(child) { + def tData = state.thermostats[child.device.deviceNetworkId] + LOG("state.thermostats = ${state.thermostats}", 3, child) LOG("Child DNI = ${child.device.deviceNetworkId}", 3, child) LOG("Data = ${tData}", 3, child) @@ -725,8 +1237,8 @@ def availableModes(child) { } def currentMode(child) { - def tData = atomicState.thermostats[child.device.deviceNetworkId] - LOG("atomicState.thermostats = ${atomicState.thermostats}", 3, child) + def tData = state.thermostats[child.device.deviceNetworkId] + LOG("state.thermostats = ${state.thermostats}", 3, child) LOG("Child DNI = ${child.device.deviceNetworkId}", 3, child) LOG("Data = ${tData}", 3, child) @@ -749,11 +1261,17 @@ def updateSensorData() { LOG("Entered updateSensorData() ", 5) def sensorCollector = [:] - atomicState.remoteSensors.each { + state.remoteSensors.each { it.each { - if ( it.type == "ecobee3_remote_sensor" ) { + if ( ( it.type == "ecobee3_remote_sensor" ) || ((it.type == "thermostat") && (settings.showThermsAsSensor)) ) { // Add this sensor to the list - def sensorDNI = "ecobee_sensor-" + it?.id + "-" + it?.code + def sensorDNI + if (it.type == "ecobee3_remote_sensor") { + sensorDNI = "ecobee_sensor-" + it?.id + "-" + it?.code + } else { + LOG("We have a Thermostat based Sensor! it=${it}", 4, null, "trace") + sensorDNI = "ecobee_sensor_thermostat-"+ it?.id + "-" + it?.name + } LOG("sensorDNI == ${sensorDNI}", 4) def temperature = "" @@ -795,32 +1313,37 @@ def updateSensorData() { sensorCollector[sensorDNI] = [data:sensorData] LOG("sensorCollector being updated with sensorData: ${sensorData}", 4) - } else if (it.type == "thermostat") { - // Extract the occupancy status + } else if ( (it.type == "thermostat") && (settings.showThermsAsSensor) ) { + // Also update the thermostat based Remote Sensor + } // end thermostat else if } // End it.each loop } // End remoteSensors.each loop - atomicState.remoteSensorsData = sensorCollector + state.remoteSensorsData = sensorCollector LOG("updateSensorData(): found these remoteSensors: ${sensorCollector}", 4) } def updateThermostatData() { // Create the list of thermostats and related data - atomicState.thermostats = atomicState.thermostatData.thermostatList.inject([:]) { collector, stat -> + state.thermostats = state.thermostatData.thermostatList.inject([:]) { collector, stat -> def dni = [ app.id, stat.identifier ].join('.') - LOG("Updating dni $dni, Got weather? ${stat.weather.forecasts[0].weatherSymbol.toString()}") + LOG("Updating dni $dni, Got weather? ${stat.weather.forecasts[0].weatherSymbol.toString()}", 4) + LOG("Climates available: ${stat.program?.climates}", 4) + // Extract Climates + def climateData = stat.program?.climates + // TODO: Put a wrapper here based on the thermostat brand def thermSensor = stat.remoteSensors.find { it.type == "thermostat" } LOG("updateThermostatData() - thermSensor == ${thermSensor}" ) - def occupancyCap = thermSensor.capability.find { it.type == "occupancy" } + def occupancyCap = thermSensor?.capability.find { it.type == "occupancy" } LOG("updateThermostatData() - occupancyCap = ${occupancyCap} value = ${occupancyCap.value}") - // Check to see if there is even a value - def occupancy = occupancyCap.value + // Check to see if there is even a value, not all types have a sensor + def occupancy = occupancyCap.value ?: "not support" LOG("Program data: ${stat.program} Current climate (ref): ${stat.program?.currentClimateRef}", 4) @@ -831,23 +1354,19 @@ def updateThermostatData() { runningEvent = stat.events.find { LOG("Checking event: ${it}", 5) it.running == true - } - - } - + } + } def usingMetric = wantMetric() // cache the value to save the function calls - def tempTemperature = myConvertTemperatureIfNeeded( (stat.runtime.actualTemperature / 10), "F", (usingMetric ? 1 : 0)) - def tempHeatingSetpoint = myConvertTemperatureIfNeeded( (stat.runtime.desiredHeat / 10), "F", (usingMetric ? 1 : 0)) - def tempCoolingSetpoint = myConvertTemperatureIfNeeded( (stat.runtime.desiredCool / 10), "F", (usingMetric ? 1 : 0)) - def tempWeatherTemperature = myConvertTemperatureIfNeeded( ((stat.weather.forecasts[0].temperature / 10)), "F", (usingMetric ? 1 : 0)) - - + def tempTemperature = myConvertTemperatureIfNeeded( (stat.runtime.actualTemperature.toDouble() / 10), "F", (usingMetric ? 1 : 0)) + def tempHeatingSetpoint = myConvertTemperatureIfNeeded( (stat.runtime.desiredHeat.toDouble() / 10), "F", (usingMetric ? 1 : 0)) + def tempCoolingSetpoint = myConvertTemperatureIfNeeded( (stat.runtime.desiredCool.toDouble() / 10), "F", (usingMetric ? 1 : 0)) + def tempWeatherTemperature = myConvertTemperatureIfNeeded( ((stat.weather.forecasts[0].temperature.toDouble() / 10)), "F", (usingMetric ? 1 : 0)) + def currentClimateName = "" def currentClimateId = "" def currentFanMode = "" - - + if (runningEvent) { LOG("Found a running Event: ${runningEvent}", 4) def tempClimateRef = runningEvent.holdClimateRef ?: "" @@ -878,7 +1397,7 @@ def updateThermostatData() { if (runningEvent) { - currentFanMode = runningEvent.fan + currentFanMode = circulateFanModeOn ? "circulate" : runningEvent.fan } else { currentFanMode = stat.runtime.desiredFanMode } @@ -913,12 +1432,13 @@ def updateThermostatData() { LOG("Event Data = ${data}", 4) - collector[dni] = [data:data] + collector[dni] = [data:data,climateData:climateData] return collector } } + def getThermostatOperatingState(stat) { def equipStatus = (stat.equipmentStatus.size() > 0) ? stat.equipmentStatus : 'Idle' @@ -934,42 +1454,46 @@ def getThermostatOperatingState(stat) { def getChildThermostatDeviceIdsString(singleStat = null) { if(!singleStat) { - LOG("getChildThermostatDeviceIdsString() - !singleStat", 2, null, "warn") + 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 def ecobeeDevId = singleStat.device.deviceNetworkID.split(/\./).last() - LOG("Received a single thermostat, returning the Ecobee Device ID as a String: ${ecobeeDevId}", 4) + LOG("Received a single thermostat, returning the Ecobee Device ID as a String: ${ecobeeDevId}", 4, null, "info") return ecobeeDevId } } +/* def toJson(Map m) { return new org.json.JSONObject(m).toString() } +*/ // Pending delete if not used anywhere def toQueryString(Map m) { return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") } -private refreshAuthToken() { - - if(!atomicState.refreshToken) { +private def Boolean refreshAuthToken(force=false) { + // Update the timestamp + updateLastTokenRefresh() + + if(!state.refreshToken) { LOG("refreshing auth token", 2) - LOG("refreshAuthToken() - There is no refreshToken stored! Unable to refresh OAuth token.", 1, null, "error") + LOG("refreshAuthToken() - There is no refreshToken stored! Unable to refresh OAuth token.", 1, null, "error") apiLost("refreshAuthToken() - No refreshToken") - return - } else if ( !readyForAuthRefresh() ) { + return false + } else if ( (force != true) && !readyForAuthRefresh() ) { // Not ready to refresh yet LOG("refreshAuthToken() - Not time to refresh yet, there is still time left before expiration.") - return + return true } else { def refreshParams = [ method: 'POST', uri : apiEndpoint, path : "/token", - query : [grant_type: 'refresh_token', code: "${atomicState.refreshToken}", client_id: smartThingsClientId], + query : [grant_type: 'refresh_token', code: "${state.refreshToken}", client_id: smartThingsClientId], ] LOG("refreshParams = ${refreshParams}") @@ -981,55 +1505,57 @@ private refreshAuthToken() { if(resp.status == 200) { LOG("refreshAuthToken() - 200 Response received - Extracting info." ) - jsonMap = resp.data // Needed to work around strange bug that wasn't updating atomicState when accessing resp.data directly + 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}") if(jsonMap) { LOG("resp.data == ${resp.data}, jsonMap == ${jsonMap}") - atomicState.refreshToken = jsonMap.refresh_token + state.refreshToken = jsonMap.refresh_token - // TODO - Platform BUG: This was not updating the atomicState values for some reason if we use resp.data directly??? + // 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}") - def oldAuthToken = atomicState.authToken - atomicState.authToken = jsonMap?.access_token - LOG("atomicState.authToken after: ${atomicState.authToken}") - if (oldAuthToken == atomicState.authToken) { - LOG("WARN: atomicState.authToken did NOT update properly! This is likely a transient problem.", 1, null, "warn") - atomicState.connected = "warn" + LOG("state.authToken before: ${state.authToken}") + def oldAuthToken = state.authToken + state.authToken = jsonMap?.access_token + LOG("state.authToken after: ${state.authToken}") + if (oldAuthToken == state.authToken) { + LOG("WARN: state.authToken did NOT update properly! This is likely a transient problem.", 1, null, "warn") + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices } // Save the expiry time to optimize the refresh LOG("Expires in ${resp?.data?.expires_in} seconds") - atomicState.authTokenExpires = (resp?.data?.expires_in * 1000) + now() + state.authTokenExpires = (resp?.data?.expires_in * 1000) + now() + LOG("Updated state.authTokenExpires = ${state.authTokenExpires}", 4, null, "trace") - LOG("Refresh Token = atomicState =${atomicState.refreshToken} == in: ${resp?.data?.refresh_token}") - LOG("OAUTH Token = atomicState ${atomicState.authToken} == in: ${resp?.data?.access_token}") + LOG("Refresh Token = state =${state.refreshToken} == in: ${resp?.data?.refresh_token}") + LOG("OAUTH Token = state ${state.authToken} == in: ${resp?.data?.access_token}") - if(atomicState.action && atomicState.action != "") { - LOG("Token refreshed. Executing next action: ${atomicState.action}") + if(state.action && state.action != "") { + LOG("Token refreshed. Executing next action: ${state.action}") - "${atomicState.action}"() + "${state.action}"() // Reset saved action - atomicState.action = "" + state.action = "" } } else { LOG("No jsonMap??? ${jsonMap}", 2) } - atomicState.action = "" - atomicState.connected = "full" + state.action = "" + apiRestored() generateEventLocalParams() // Update the connected state at the thermostat devices - + return true } else { LOG("Refresh failed ${resp.status} : ${resp.status.code}!", 1, null, "error") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices + return false } } } catch (groovyx.net.http.HttpResponseException e) { @@ -1038,31 +1564,44 @@ private refreshAuthToken() { if ( (e.statusCode == 500 && e.getResponse()?.data.status.code == 14) || (e.statusCode == 401 && e.getResponse()?.data.status.code == 14) ) { LOG("refreshAuthToken() - Received data.status.code = 14", 1, null, "error") apiLost("refreshAuthToken() - Received data.status.code = 14" ) + return false } else if (e.statusCode != 401) { //this issue might comes from exceed 20sec app execution, connectivity issue etc. LOG("refreshAuthToken() - e.statusCode: ${e.statusCode}", 1, null, "warn") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - runIn(reAttemptPeriod, "refreshAuthToken") + if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthTokens(true) } + return false } else if (e.statusCode == 401) { // status.code other than 14 - atomicState.reAttempt = atomicState.reAttempt + 1 - LOG("reAttempt refreshAuthToken to try = ${atomicState.reAttempt}", 1, null, "warn") - if (atomicState.reAttempt <= 3) { - atomicState.connected = "warn" + state.reAttempt = state.reAttempt + 1 + LOG("reAttempt refreshAuthToken to try = ${state.reAttempt}", 1, null, "warn") + if (state.reAttempt <= 3) { + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - runIn(reAttemptPeriod, "refreshAuthToken") + if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthToken(true) } + return false } else { // More than 3 attempts, time to give up and notify the end user LOG("More than 3 attempts to refresh tokens. Giving up", 1, null, "error") debugEvent("More than 3 attempts to refresh tokens. Giving up") apiLost("refreshAuthToken() - More than 3 attempts to refresh token. Have to give up") + return false } } } catch (java.util.concurrent.TimeoutException e) { LOG("refreshAuthToken(), TimeoutException: ${e}.", 1, null, "error") // Likely bad luck and network overload, move on and let it try again - runIn(300, "refreshAuthToken") + state.connected = "warn" + generateEventLocalParams() // Update the connected state at the thermostat devices + def reAttemptPeriod = 300 // in sec + if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthTokens(true) } + return false + } catch (groovy.lang.StringWriterIOException e) { + LOG("refreshAuthToken(), groovy.lang.StringWriterIOException encountered: ${e}", 1, null, "error") + apiLost("refreshAuthToken(), groovy.lang.StringWriterIOException encountered: ${e}") + return false } catch (Exception e) { LOG("refreshAuthToken(), General Exception: ${e}.", 1, null, "error") + return false } } } @@ -1081,23 +1620,26 @@ def resumeProgram(child, deviceId) { return result } -def setHold(child, heating, cooling, deviceId, sendHoldType=null, fanMode="") { +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(): setpoints____ - h: ${heating} - ${h}, c: ${cooling} - ${c}, setHoldType: ${sendHoldType}", 3, child) - def tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? - [coolHoldTemp:"${c}", heatHoldTemp: "${h}", holdType:"${sendHoldType}" - ] : - [coolHoldTemp:"${c}", heatHoldTemp: "${h}" - ] - - - if (fanMode != "") { tstatSettings << [fan:"${fanMode}"] } - - + def tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? + [coolHoldTemp:"${c}", heatHoldTemp: "${h}", holdType:"${sendHoldType}" + ] : + [coolHoldTemp:"${c}", heatHoldTemp: "${h}" + ] + + if (fanMode != "") { + tstatSettings << [fan:"${fanMode}"] + } + + if (extraParams != []) { + tstatSettings << extraParams + } //def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '","includeRuntime":true},"thermostat": {"settings":{"hvacMode":"'+"${mode}"+'"}}}' def jsonRequestBody = buildBodyRequest('setHold',null,deviceId,tstatSettings,null).toString() @@ -1126,7 +1668,7 @@ def setMode(child, mode, deviceId) { def result = sendJson(jsonRequestBody) LOG("setMode to ${mode} with result ${result}", 4, child) if (result) { - generateQuickEvent("thermostatMode", mode, 15) + child.generateQuickEvent("thermostatMode", mode, 15) } else { LOG("Unable to set new mode (${mode})", 1, child, "warn") } @@ -1134,34 +1676,39 @@ def setMode(child, mode, deviceId) { return result } - -def setFanMinOnTime(child, time, deviceId) { - LOG("setFanMinOnTime() - Not yet implemented!", 1, chile, "warn") - -} - def setFanMode(child, fanMode, deviceId, sendHoldType=null) { LOG("setFanMode() to ${fanMode} with DeviceID: ${deviceId}", 5, child) - - - def tstatSettings - // TODO: Handle Circulate as a special case? - if (fanMode == "circulate") { - fanMode = "auto" - // Add a minimum circulate time here + def extraParams = [isTemperatureRelative: "false", isTemperatureAbsolute: "false"] + // TODO: Set the fan mode to circulate in the events data sent to the device + if (fanMode == "circulate") { + fanMode = "auto" + LOG("fanMode == 'circulate'", 5, child, "trace") + // Add a minimum circulate time here + // NOTE: This is not currently honored by the Ecobee + extraParams << [fanMinOnTime:15] + state.circulateFanModeOn = true + } else if (fanMode == "off") { + state.circulateFanModeOn = false + fanMode = "auto" + // NOTE: This is not currently honored by the Ecobee + extraParams << [fanMinOnTime: "0"] + } else { + state.circulateFanModeOn = false } - + def currentHeatingSetpoint = child.device.currentValue("heatingSetpoint") def currentCoolingSetpoint = child.device.currentValue("coolingSetpoint") def holdType = sendHoldType ?: whatHoldType() - return setHold(child, currentHeatingSetpoint, currentCoolingSetpoint, deviceId, holdType, fanMode) + LOG("about to call setHold: ${currentHeatingSetpoint}, ${currentCoolingSetpoint}, ${deviceId}, ${holdType}, ${fanMode}, ${extraParams}", 5, child, "trace") + return setHold(child, currentHeatingSetpoint, currentCoolingSetpoint, deviceId, holdType, fanMode, extraParams) + } - def setProgram(child, program, deviceId, sendHoldType=null) { LOG("setProgram() to ${program} with DeviceID: ${deviceId}", 5, child) + program = program.toLowerCase() def tstatSettings tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? @@ -1171,12 +1718,7 @@ def setProgram(child, program, deviceId, sendHoldType=null) { ] def jsonRequestBody = buildBodyRequest('setHold',null,deviceId,tstatSettings,null).toString() - //def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '","includeRuntime":true},"functions": [{ "type": "setHold", "params": { "holdClimateRef": '+ program + '", "coolHoldTemp": '+c+',"heatHoldTemp": '+h+', "holdType": '+sendHoldType+' } } ]}' - // def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '","includeRuntime":true},"functions": [{ "type": "setHold", "params": { "holdClimateRef": "'+program+'", "holdType": '+sendHoldType+' } } ]}' - - LOG("about to sendJson with jsonRequestBody (${jsonRequestBody}", 4, child) - - + LOG("about to sendJson with jsonRequestBody (${jsonRequestBody}", 4, child) def result = sendJson(child, jsonRequestBody) LOG("setProgram with result ${result}", 3, child) dirtyPollData() @@ -1184,9 +1726,8 @@ def setProgram(child, program, deviceId, sendHoldType=null) { } - - -def sendJson(child = null, String jsonBody) { +// API Helper Functions +private def sendJson(child = null, String jsonBody) { // Reset the poll timer to allow for an immediate refresh dirtyPollData() @@ -1194,7 +1735,7 @@ def sendJson(child = null, String jsonBody) { def cmdParams = [ uri: apiEndpoint, path: "/1/thermostat", - headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${state.authToken}"], body: jsonBody ] @@ -1203,57 +1744,58 @@ def sendJson(child = null, String jsonBody) { int j=0 while ( (statusCode) && (j++ < 2) ) { // only retry once - httpPost(cmdParams) { resp -> - statusCode = resp.data.status.code - - LOG("sendJson() resp.status ${resp.status}, resp.data: ${resp.data}, statusCode: ${statusCode}", 2, child) - - // TODO: Perhaps add at least two tries incase the first one fails? - if(resp.status == 200) { - LOG("Updated ${resp.data}", 4) - returnStatus = resp.data.status.code - if (resp.data.status.code == 0) { - LOG("Successful call to ecobee API.", 2, child) - atomicState.connected = "full" - generateEventLocalParams() - statusCode=false - } else { - LOG("Error return code = ${resp.data.status.code}", 1, child, "error") - } - } else { - LOG("Sent Json & got http status ${resp.status} - ${resp.status.code}", 2, child, "warn") - - //refresh the auth token - if (resp.status == 500 && resp.status.code == 14) { - LOG("Refreshing your auth_token!") - refreshAuthToken() - return false // No way to recover from a status.code 14 + httpPost(cmdParams) { resp -> + statusCode = resp.data.status.code + + LOG("sendJson() resp.status ${resp.status}, resp.data: ${resp.data}, statusCode: ${statusCode}", 2, child) + + // TODO: Perhaps add at least two tries incase the first one fails? + if(resp.status == 200) { + LOG("Updated ${resp.data}", 4) + returnStatus = resp.data.status.code + if (resp.data.status.code == 0) { + LOG("Successful call to ecobee API.", 2, child) + apiRestored() + generateEventLocalParams() + statusCode=false + } else { + LOG("Error return code = ${resp.data.status.code}", 1, child, "error") + } } else { - LOG("Possible Authentication error, invalid authentication method, lack of credentials, etc. Status: ${resp.status} - ${resp.status.code} ", 2, child, "error") - atomicState.connected = "warn" - generateEventLocalParams() - if (j == 2) { // Go ahead and refresh on the second pass through - refreshAuthToken() - return false - } - - } - } // resp.status if/else - } // HttpPost + LOG("Sent Json & got http status ${resp.status} - ${resp.status.code}", 2, child, "warn") + + //refresh the auth token + if (resp.status == 500 && resp.status.code == 14) { + LOG("Refreshing your auth_token!") + if(refreshAuthToken(true)) { + LOG("Successfully performed a refreshAuthToken() after a Code 14!", 2, child, "warn") + } + return false // No way to recover from a status.code 14 + } else { + LOG("Possible Authentication error, invalid authentication method, lack of credentials, etc. Status: ${resp.status} - ${resp.status.code} ", 2, child, "error") + state.connected = "warn" + generateEventLocalParams() + if (j == 2) { // Go ahead and refresh on the second pass through + refreshAuthToken(true) + return false + } + } + } // resp.status if/else + } // HttpPost } // While loop } catch (groovyx.net.http.HttpResponseException e) { LOG("sendJson() >> HttpResponseException occured. Exception info: ${e} StatusCode: ${e.statusCode} response? data: ${e.getResponse()?.getData()}", 1, child, "error") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() - refreshAuthToken() + refreshAuthToken(true) return false } catch(Exception e) { // Might need to further break down LOG("sendJson() - Exception Sending Json: " + e, 1, child, "error") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() if (j == 2) { // Go ahead and refresh on the second pass through - refreshAuthToken() + refreshAuthToken(true) return false } } @@ -1264,16 +1806,16 @@ def sendJson(child = null, String jsonBody) { return false } -def getChildThermostatName() { return "Ecobee Thermostat" } -def getChildSensorName() { return "Ecobee Sensor" } -def getServerUrl() { return "https://graph.api.smartthings.com" } -def getShardUrl() { return getApiServerUrl() } -def getCallbackUrl() { return "${serverUrl}/oauth/callback" } -def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" } -def getApiEndpoint() { return "https://api.ecobee.com" } +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=${state.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 -def getSmartThingsClientId() { +private def getSmartThingsClientId() { if(!appSettings.clientId) { return "obvlTjUuuR2zKpHR6nZMxHWugoi5eVtS" } else { @@ -1282,21 +1824,27 @@ def getSmartThingsClientId() { } - -private def LOG(message, level=3, child=null, logType="debug", event=true, displayEvent=false) { +private def LOG(message, level=3, child=null, logType="debug", event=false, displayEvent=true) { def prefix = "" + def logTypes = ["error", "debug", "info", "trace"] + + if(!logTypes.contains(logType)) { + log.error "LOG() - Received logType ${logType} which is not in the list of allowed types." + if (event && child) { debugEventFromParent(child, "LOG() - Received logType ${logType} which is not in the list of allowed types.") } + logType = "debug" + } + + if ( logType == "error" ) { state.lastLOGerror = message } if ( settings.debugLevel?.toInteger() == 5 ) { prefix = "LOG: " } if ( debugLevel(level) ) { log."${logType}" "${prefix}${message}" - // log.debug message if (event) { debugEvent(message, displayEvent) } - if (event && child) { debugEventFromParent(child, message) } + if (child) { debugEventFromParent(child, message) } } } private def debugEvent(message, displayEvent = false) { - def results = [ name: "appdebug", descriptionText: message, @@ -1307,38 +1855,33 @@ private def debugEvent(message, displayEvent = false) { } private def debugEventFromParent(child, message) { - def data = [ debugEventFromParent: message ] if (child) { child.generateEvent(data) } - /* - if (child != null) { - child.sendEvent("name":"debugEventFromParent", "value":message, "description":message, displayed: true, isStateChange: true) - } - */ } +// TODO: Create a more generic push capability to send notifications //send both push notification and mobile activity feeds -def sendPushAndFeeds(notificationMessage) { +private def sendPushAndFeeds(notificationMessage) { LOG("sendPushAndFeeds >> notificationMessage: ${notificationMessage}", 1, null, "warn") - LOG("sendPushAndFeeds >> atomicState.timeSendPush: ${atomicState.timeSendPush}", 1, null, "warn") + LOG("sendPushAndFeeds >> state.timeSendPush: ${state.timeSendPush}", 1, null, "warn") - if (atomicState.timeSendPush) { - if ( (now() - atomicState.timeSendPush) >= (1000 * 60 * 60 * 1)){ // notification is sent to remind user no more than once per hour + if (state.timeSendPush) { + if ( (now() - state.timeSendPush) >= (1000 * 60 * 60 * 1)){ // notification is sent to remind user no more than once per hour sendPush("Your Ecobee thermostat " + notificationMessage) sendActivityFeeds(notificationMessage) - atomicState.timeSendPush = now() + state.timeSendPush = now() } } else { sendPush("Your Ecobee thermostat " + notificationMessage) sendActivityFeeds(notificationMessage) - atomicState.timeSendPush = now() + state.timeSendPush = now() } - // atomicState.authToken = null + // state.authToken = null } -def sendActivityFeeds(notificationMessage) { +private def sendActivityFeeds(notificationMessage) { def devices = getChildDevices() devices.each { child -> child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent @@ -1348,43 +1891,32 @@ def sendActivityFeeds(notificationMessage) { - - - - - - - - - -// Helper Apps - -// Built in functions from SmartThings for temperature unit handling? -// getTemperatureScale() -// fahrenheitToCelsius() -// celsiusToFahrenheit() -// convertTemperatureIfNeeded() - +// 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 - return scaledSensorValue - } else if (cmdScale == "F") { - return fToC(scaledSensorValue).round(precision) + returnSensorValue = scaledSensorValue.round(precision) + } else if (cmdScale == "F") { + returnSensorValue = fToC(scaledSensorValue).round(precision) } else { - return cToF(scaledSensorValue).round(precision) + returnSensorValue = cToF(scaledSensorValue).round(precision) } - + LOG("returnSensorValue == ${returnSensorValue}", 5, null, "trace", false) + return returnSensorValue } def wantMetric() { @@ -1411,46 +1943,81 @@ private def getMinMinBtwPolls() { return 1 } +private def getPollingInterval() { + return (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 +} + +private def String getTimestamp() { + return new Date().format("yyyy-MM-dd HH:mm:ss z", location.timeZone) +} + // Are we connected with the Ecobee service? private String apiConnected() { // values can be "full", "warn", "lost" - if (atomicState.connected == null) atomicState.connected = "lost" - return atomicState.connected?.toString() ?: "lost" + if (state.connected == null) state.connected = "warn" + return state.connected?.toString() ?: "lost" +} + + +private def apiRestored() { + state.connected = "full" + unschedule("notifyApiLost") +} + + +private def getDebugDump() { + def debugParams = [when:"${getTimestamp()}", whenEpic:"${now()}", + lastPollDate:"${state.lastPollDate}", lastScheduledPollDate:"${state.lastScheduledPollDate}", + lastScheduledTokenRefreshDate:"${state.lastScheduledTokenRefreshDate}", lastScheduledWatchdogDate:"${state.lastScheduledWatchdogDate}", + lastTokenRefreshDate:"${state.lastTokenRefreshDate}", initializedEpic:"${state.initializedEpic}", initializedDate:"${state.initializedDate}", + lastLOGerror:"${state.lastLOGerror}" + ] + return debugParams } private def apiLost(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", true, true) + 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") + // TODO: Add a state.apiLostDump variable and populate it with useful troubleshooting information to make it easier to grab everything in one place. Then also add this to the Debug Dashboard + state.apiLostDump = getDebugDump() + + // Has out token really expired yet? + if ( !readyForAuthRefresh() ) { + LOG("apiLost() - Still time left on expiry of Auth Token. Gonna wait until full expiry to actually declare the api as fully lost. Setting it to warn instead.", 1, null, "error") + state.connected = "warn" + generateEventLocalParams() + 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 + state.connected = "lost" + state.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", true, true) + 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} of loss", 0, child, "error", true, true) + LOG("apiLost() - notifying each child: ${oneChild} of loss", 0, child, "error") } } - unschedule("poll") - unschedule("refreshAuthToken") + // unschedule("pollScheduled") + // unschedule("refreshAuthTokenScheduled") 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" ) { + if ( state.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", true, true) + 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") @@ -1469,13 +2036,9 @@ private Boolean readyForAuthRefresh() { LOG("Entered readyForAuthRefresh() ", 5) def timeLeft - timeLeft = atomicState.authTokenExpires ? ((atomicState.authTokenExpires - now()) / 1000 / 60) : 0 + timeLeft = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 LOG("timeLeft until expiry (in min): ${timeLeft}", 3) - - // Since this runs as part of poll() we can be a bit more conservative on the time before renewing the token - // def pollInterval = settings.pollingInterval ?: 5 - // def ready = timeLeft <= ((pollInterval * 3) + 2) def ready = timeLeft <= 29 LOG("Ready for authRefresh? ${ready}", 4) return ready @@ -1499,7 +2062,7 @@ private debugLevel(level=3) { // Mark the poll data as "dirty" to allow a new API call to take place private def dirtyPollData() { LOG("dirtyPollData() called to reset poll state", 5) - atomicState.lastPoll = 0 + state.forcePoll = true } @@ -1513,7 +2076,7 @@ private def dirtyPollData() { // may be set to null if not relevant for the given method // thermostatId may be a list of serial# separated by ",", no spaces (ex. '123456789012,123456789013') private def buildBodyRequest(method, tstatType="registered", thermostatId, tstatParams = [], - tstatSettings = []) { + tstatSettings = [], tstatEvents = []) { LOG("Entered buildBodyRequest()", 5) def selectionJson = null @@ -1555,6 +2118,7 @@ private def buildBodyRequest(method, tstatType="registered", thermostatId, tstat selection = [selectionType: 'thermostats', selectionMatch: thermostatId] } selectionJson = new groovy.json.JsonBuilder(selection) + if ((method != 'setThermostatSettings') && (tstatSettings != null) && (tstatSettings != [])) { def function_clause = ((tstatParams != null) && (tsatParams != [])) ? [type:method, params: tstatParams] : @@ -1582,82 +2146,3 @@ private def buildBodyRequest(method, tstatType="registered", thermostatId, tstat } } - -// iterateSetThermostatSettings: iterate thru all the thermostats under a specific account and set the desired settings -// tstatType =managementSet or registered (no spaces). May also be set to a specific locationSet (ex./Toronto/Campus/BuildingA) -// settings can be anything supported by ecobee -// at https://www.ecobee.com/home/developer/api/documentation/v1/objects/Settings.shtml -void iterateSetThermostatSettings(tstatType, tstatSettings = []) { - Integer MAX_TSTAT_BATCH = get_MAX_TSTAT_BATCH() - def tstatlist = null - Integer nTstats = 0 - - def ecobeeType = determine_ecobee_type_or_location(tstatType) - getThermostatSummary(ecobeeType) - if (settings.trace) { - log.debug - "iterateSetThermostatSettings>ecobeeType=${ecobeeType},about to loop ${data.thermostatCount} thermostat(s)" - sendEvent name: "verboseTrace", value: - "iterateSetThermostatSettings>ecobeeType=${ecobeeType},about to loop ${data.thermostatCount} thermostat(s)" - } - for (i in 0..data.thermostatCount - 1) { - def thermostatDetails = data.revisionList[i].split(':') - def Id = thermostatDetails[0] - def thermostatName = thermostatDetails[1] - def connected = thermostatDetails[2] - if (connected == 'true') { - if (nTstats == 0) { - tstatlist = Id - nTstats = 1 - } - if ((nTstats > MAX_TSTAT_BATCH) || (i == (data.thermostatCount - 1))) { - // process a batch of maximum 25 thermostats according to API doc - if (settings.trace) { - sendEvent name: "verboseTrace", value: - "iterateSetThermostatSettings>about to call setThermostatSettings for ${tstatlist}" - log.debug "iterateSetThermostatSettings> about to call setThermostatSettings for ${tstatlist}" - } - setThermostatSettings("${tstatlist}",tstatSettings) - tstatlist = Id - nTstats = 1 - } else { - tstatlist = tstatlist + "," + Id - nTstats++ - } - } - } -} - -// thermostatId may be a list of serial# separated by ",", no spaces (ex. '123456789012,123456789013') -// if no thermostatId is provided, it is defaulted to the current thermostatId -// settings can be anything supported by ecobee at https://www.ecobee.com/home/developer/api/documentation/v1/objects/Settings.shtml -void setThermostatSettings(thermostatId,tstatSettings = []) { - thermostatId= determine_tstat_id(thermostatId) - if ( debugLevel(5) ) { - log.debug "setThermostatSettings>called with values ${tstatSettings} for ${thermostatId}" - } - - def bodyReq = buildBodyRequest('setThermostatSettings',null,thermostatId,null,tstatSettings) - def statusCode=true - int j=0 - while ((statusCode) && (j++ <2)) { // retries once if api call fails - apiHelper('setThermostatSettings', bodyReq) {resp -> - statusCode = resp.data.status.code - def message = resp.data.status.message - if (!statusCode) { - if ( debugLevel(3) ) { log.debug "setThermostatSettings() successful for ${thermostatId} with settings ${tstatSettings}" } - - } else { - if ( debugLevel(1) ) { - log.error "setThermostatSettings() error=${statusCode.toString()},message=${message} for ${thermostatId}" - debugEvent( "setThermostatSettings() error=${statusCode.toString()},message=${message} for ${thermostatId}" ) - } - - // introduce a 1 second delay before re-attempting any other command - def cmd= [] - cmd << "delay 1000" - cmd - } /* end if statusCode */ - } /* end api call */ - } /* end for */ -} diff --git a/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy b/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy new file mode 100644 index 00000000000..3abcca51f7d --- /dev/null +++ b/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy @@ -0,0 +1,161 @@ +/** + * ecobee Routines + * + * Copyright 2015 Sean Kendall Schneyer + * + * + * 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. + * + */ +def getVersionNum() { return "0.1.0" } +private def getVersionLabel() { return "ecobee Routines Version ${getVersionNum()}-RC5" } + + + +definition( + name: "ecobee Routines", + namespace: "smartthings", + author: "Sean Kendall Schneyer (smartthings at linuxbox dot org)", + description: "Support for changing ecobee Programs based on SmartThings Mode changes", + category: "Convenience", + parent: "smartthings:Ecobee (Connect)", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png", + singleInstance: false +) + +preferences { + page(name: "mainPage") +} + +// Preferences Pages +def mainPage() { + dynamicPage(name: "mainPage", title: "Setup Routines", uninstall: true, install: true) { + section(title: "Name for Routine Handler") { + label title: "Name this Routine Handler", required: true + + } + + section(title: "Select Conditions") { + if(settings.tempDisable == true) paragraph "WARNING: Temporarily Disabled as requested. Turn back on to activate handler." + input ("myThermostats", "capability.Thermostat", title: "Pick Ecobee Thermostat(s)", required: true, multiple: true, submitOnChange: true) + + if (myThermostats?.size() > 0) { + // Start defining which Routine(s) to allow the SmartApp to execute in + mode(title: "Select mode(s) to use: ", required: true) + input(name: "whichProgram", title: "Select Program to use: ", type: "enum", required: true, multiple:false, description: "Tap to choose...", metadata:[values:["Away", "Home", "Sleep", "Resume Program"]], submitOnChange: true) + input(name: "fanMode", title: "Select a Fan Mode to use\n(Optional) ", type: "enum", required: false, multiple: false, description: "Tap to choose...", metadata:[values:["On", "Auto", "default"]], submitOnChange: true) + if(settings.whichProgram != "Resume Program") input(name: "holdType", title: "Select the Hold Type to use\n(Optional) ", type: "enum", required: false, multiple: false, description: "Tap to choose...", metadata:[values:["Until I Change", "Until Next Program", "default"]], submitOnChange: true) + input(name: "useSunriseSunset", title: "Also at Sunrise or Sunset?\n(Optional) ", type: "enum", required: false, multiple: true, description: "Tap to choose...", metadata:[values:["Sunrise", "Sunset"]], submitOnChange: true) + } + input(name: "tempDisable", title: "Temporarily Disable Handler? ", type: "bool", required: false, description: false, submitOnChange: true) + } + + section (getVersionLabel()) + } +} + + +// Main functions +def installed() { + LOG("installed() entered", 5) + initialize() +} + +def updated() { + LOG("updated() entered", 5) + unsubscribe() + initialize() + +} + +def initialize() { + LOG("initialize() entered") + if(tempDisable == true) { + LOG("Teporarily Disapabled as per request.", 2, null, "warn") + return true + } + + subscribe(location, "mode", changeProgramHandler) + // subscribe(app, changeProgramHandler) + + if(useSunriseSunset?.size() > 0) { + // Setup subscriptions for sunrise and/or sunset as well + if( useSunriseSunset.contains("Sunrise") ) subscribe(location, "sunrise", changeProgramHandler) + if( useSunriseSunset.contains("Sunset") ) subscribe(location, "sunset", changeProgramHandler) + } + + // Normalize settings data + normalizeSettings() + LOG("initialize() exiting") +} + +private def normalizeSettings() { + // whichProgram + state.programParam = "" + if (whichProgram != null && whichProgram != "") { + if (whichProgram == "Resume Program") { + state.doResumeProgram = true + } else { + state.programParam = whichProgram.toLowerCase() + } + } + + // fanMode + state.fanCommand = "" + if (fanMode != null && fanMode != "") { + if (fanMode == "On") { + state.fanCommand = "fanOn" + } else if (fanMode == "Auto") { + state.fanCommand = "fanAuto" + } else { + state.fanCommand = "" + } + } + + // holdType + state.holdTypeParam = null + if (holdType != null && holdType != "") { + if (holdType == "Until I Change") { + state.holdTypeParam = "indefinite" + } else if (holdType == "Until Next Program") { + state.holdTypeParam = "nextTransition" + } else { + state.holdTypeParam = null + } + } +} + +def changeProgramHandler(evt) { + LOG("changeProgramHander() entered with evt: ${evt}", 5) + + settings.myThermostats.each { stat -> + LOG("In each loop: Working on stat: ${stat}", 4, null, "trace") + // First let's change the Thermostat Program + if(state.doResumeProgram == true) { + LOG("Resuming Program for ${stat}", 4, null, "trace") + stat.resumeProgram() + } else { + LOG("Setting Thermostat Program to programParam: ${state.programParam} and holdType: ${state.holdTypeParam}", 4, null, "trace") + stat.setThermostatProgram(state.programParam, state.holdTypeParam) + } + if (state.fanCommand != "" && state.fanCommand != null) stat."${state.fanCommand}"() + } + return true +} + + + +// Helper Functions +private def LOG(message, level=3, child=null, logType="debug", event=true, displayEvent=true) { + message = "${app.label} ${message}" + parent.LOG(message, level, child, logType, event, displayEvent) +} +