diff --git a/devicetypes/Evohome (Connect) 2.3 b/devicetypes/Evohome (Connect) 2.3 new file mode 100644 index 00000000000..eeb552762fd --- /dev/null +++ b/devicetypes/Evohome (Connect) 2.3 @@ -0,0 +1,1680 @@ +/** + * Copyright 2021 Andreas Christodoulou (Andremain) + * + * Name: Evohome (Connect) + * + * Author: Andreas Christodoulou (Andremain) + * + * Date: 28/01/2021 + * + * Version: 2.3 + * + * Description: + * - Connect your Honeywell Evohome System to SmartThings. + * - Requires the Evohome Heating Zone device handler. + * - For latest documentation see: https://github.com/andremain/EvohomeSmartthingsNew + + * Version History: + * + * 2021-02-16: v3.2 + * Added a clause to exit the loop when checking the integration for hot water, as if there was none, the connection would result in error. + * + * 2020-12-22: v2.1 Removed Deprecated set temperature option at the automation actions + * + * 2020-12-22: v2.0 First release of the new integration. + * + * 2020-12-20: v0.21 Removed the cooling option when creating an automation in the IF statement (DO BE DONE!!!) + * + * 2020-12-18: v0.20 Changed the links for the documentation and images to work. + * + * 2020-12-16: v0.19 Changed the owner of the smartapp and dht to Andremain (mine) + * + * 2020-12-14: v0.18 Managed to get all the modes to work with the new app's presentation + * + * 2020-12-13: v0.17 Managed to get the Auto, away, custom and off modes to work with the new app's presentation. Some list items appear wierd but this is a Smartthings issue + * + * 2020-12-11: v0.16 Managed to get the correct thermostat modes to show app in the modes list in the new app presentation + * + * 2020-12-9: v0.15 Changed the default value for window function temperature from 5.0 to 5 as it would throw a bad request error + * + * 2020-12-8: v0.14 Added an initialize function for the new thermostat capabilities + * + * 2020-12-6: v0.13 Removed the old custom and depricated capabilited from the old code + * + * 2020-12-3: v0.12 Generated the Presentation file required by the new smartthings app for correctly displaying the options for the inside the new app + * + * 2020-11-30: v0.11 Fixed the Checking Status on the dashboard tile for use with the new Smartthings app + * + * 2020-11-28: v0.10 Added The new capababilities for the depricated thermostat capability used by the new smartthings app + * + * 2020-11-02: v0.09 Fixed the API endpoint to be the new one that Honeywell Evohome uses. + * + * 2016-04-19: v0.10 + * - formatTemperature() - Improved error handling. + * - Mid-development of DHW zone support. + * - To Do: Add parsing of DHW schedules. - NEED SAMPLE DATA + * + * 2016-04-17: v0.09 + * - updateChildDevice() - Sends two new attribute values to child devices: + * thermostatModeMode: 'temporary' or 'permanent'. + * thermostatModeUntil: Contains date string if thermostatModeMode is temporary. + * + * 2016-04-05: v0.08 + * - New 'Update Refresh Time' setting to control polling after making an update. + * - poll() - If onlyZoneId is 0, this will force a status update for all zones. + * + * 2016-04-04: v0.07 + * - Additional info log messages. + * + * 2016-04-03: v0.06 + * - Initial Beta Release + * + * License: + * 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. + * + */ +definition( + name: "Evohome (Connect)", + namespace: "worldhouse47531", + author: "Andreas Christodoulou", + description: "Connect your Honeywell Evohome System to SmartThings.", + category: "My Apps", + iconUrl: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX2Url: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX3Url: "https://i.ibb.co/SwyYJWb/Evohome.png", + singleInstance: true +) + +preferences { + + section ("Evohome:") { + input "prefEvohomeUsername", "text", title: "Username", required: true, displayDuringSetup: true + input "prefEvohomePassword", "password", title: "Password", required: true, displayDuringSetup: true + input title: "Advanced Settings:", displayDuringSetup: true, type: "paragraph", element: "paragraph", description: "Change these only if needed" + input "prefEvohomeStatusPollInterval", "number", title: "Polling Interval (minutes)", range: "1..60", defaultValue: 5, required: true, displayDuringSetup: true, description: "Poll Evohome every n minutes" + input "prefEvohomeUpdateRefreshTime", "number", title: "Update Refresh Time (seconds)", range: "2..60", defaultValue: 3, required: true, displayDuringSetup: true, description: "Wait n seconds after an update before polling" + input "prefEvohomeWindowFuncTemp", "decimal", title: "Window Function Temperature", range: "0..100", defaultValue: 5, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input "prefEvohomeDHWTemp", "decimal", title: "Hot Water Target Temperature", range: "0..100", defaultValue: 55, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days' + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours' + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + + + + + + + + + + + + + + + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + **/ +def installed() { + + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" + +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + * + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + * + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://mytotalconnectcomfort.com/WebApi' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling. + + + // Thermostat Mode Durations: + atomicState.thermostatModeDuration = settings.prefThermostatModeDuration + atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration + + // Force Authentication: + authenticate() + + // Refresh Subscriptions and Schedules: + manageSubscriptions() + manageSchedules() + + // Refresh child device configuration: + getEvohomeConfig() + updateChildDeviceConfig() + + // Run a poll, but defer it so that updated() returns sooner: + runIn(5, "poll") + +} + + +/********************************************************************** + * Management Commands: + **********************************************************************/ + +/** + * manageSchedules() + * + * Check scheduled tasks have not stalled, and re-schedule if necessary. + * Generates a random offset (seconds) for each scheduled task. + * + * Schedules: + * - manageAuth() - every 5 mins. + * - poll() - every minute. + * + **/ +void manageSchedules() { + + if (atomicState.debug) log.debug "${app.label}: manageSchedules()" + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // manageAuth (every 5 mins): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()" + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/5 * * * ?", "manageAuth") + } + + // poll(): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()" + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/1 * * * ?", "poll") + } + +} + + +/** + * manageSubscriptions() + * + * Unsubscribe/Subscribe. + **/ +void manageSubscriptions() { + + if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()" + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + +} + + +/** + * manageAuth() + * + * Ensures authenication token is valid. + * Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold. + * Re-authenticates if Auth Token has expired completely. + * Otherwise, done nothing. + * + * Should be scheduled to run every 1-5 minutes. + **/ +void manageAuth() { + + if (atomicState.debug) log.debug "${app.label}: manageAuth()" + + // Check if Auth Token is valid, if not authenticate: + if (!atomicState.evohomeAuth.authToken) { + + log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..." + authenticate() + } + else if (atomicState.evohomeAuthFailed) { + + log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..." + authenticate() + } + else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) { + + log.info "${app.label}: manageAuth(): Auth Token has expired. Authenticating..." + authenticate() + } + else { + // Check if Auth Token should be refreshed: + def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100)) + + if (now() >= refreshAt) { + log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires." + refreshAuthToken() + } + else { + log.info "${app.label}: manageAuth(): Auth Token is okay." + } + } + +} + + +/** + * poll(onlyZoneId=-1) + * + * This is the main command that co-ordinates retrieval of information from the Evohome API + * and its dissemination to child devices. It should be scheduled to run every minute. + * + * Different types of information are collected on different schedules: + * - Zone status information is polled according to ${evohomeStatusPollInterval}. + * - Zone schedules are polled according to ${evohomeSchedulePollInterval}. + * + * poll() can be called by a child device when an update has been made, in which case + * onlyZoneId will be specified, and only that zone will be updated. + * + * If onlyZoneId is 0, this will force a status update for all zones, igonoring the poll + * interval. This should only be used after setThremostatMode() call. + * + * If onlyZoneId is not specified all zones are updated, but only if the relevent poll + * interval has been exceeded. + * + **/ +void poll(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})" + + // Check if there's been an authentication failure: + if (atomicState.evohomeAuthFailed) { + manageAuth() + } + + if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update): + getEvohomeStatus() + updateChildDevice() + } + else if (onlyZoneId != -1) { // A zoneId has been specified, so just get the status and update the relevent device: + getEvohomeStatus(onlyZoneId) + updateChildDevice(onlyZoneId) + } + else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded: + + // Adjust intervals to allow for poll() execution time: + def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30 + def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30 + + // Get zone status: + if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) { + getEvohomeStatus() + } + + // Get zone schedules: + if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) { + getEvohomeSchedules() + } + + // Update all child devices: + updateChildDevice() + } + +} + + +/********************************************************************** + * Event Handlers: + **********************************************************************/ + + +/** + * handleAppTouch(evt) + * + * App touch event handler. + * Used for testing and debugging. + * + **/ +void handleAppTouch(evt) { + + if (atomicState.debug) log.debug "${app.label}: handleAppTouch()" + + //manageAuth() + //manageSchedules() + + //getEvohomeConfig() + //updateChildDeviceConfig() + + getEvohomeSchedules() + + //poll() + +} + + +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * updateChildDeviceConfig() + * + * Add/Remove/Update child devices based on atomicState.evohomeConfig. + * + **/ +void updateChildDeviceConfig() { + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()" + + // Build list of active DNIs, any existing children with DNIs not in here will be deleted. + def activeDnis = [] + + // Iterate through evohomeConfig, adding new 'Evohome Heating Zone' and 'Evohome Hot Water Zone' devices where necessary. + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + // Heating Zones: + tcs.zones.each { zone -> + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating Zone R3.4", dni, null, values) //Change the name here to change the name of the device + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + + + // Hot Water Zone: + if (tcs.dhw) { + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, tcs.dhw.dhwId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'dhwTemperature': formatTemperature(settings.prefEvohomeDHWTemp), + 'zoneType': 'DHW', + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': tcs.dhw.dhwId + ] + + log.info "${app.label}: updateChildDeviceConfig(): Found a hot water zone! Values: ${values}" + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "Hot Water (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating Hot Water Zone: DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Hot Water Zone R3.4", dni, null, values) + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + } + } + } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}" + + // Delete Devices: + def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete." + + delete.each { + log.info "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}" + try { + deleteChildDevice(it.deviceNetworkId) + } + catch(e) { + log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}" + } + } +} + + + +/** + * updateChildDevice(onlyZoneId=-1) + * + * Update the attributes of a child device from atomicState.evohomeStatus + * and atomicState.evohomeSchedules. + * + * If onlyZoneId is not specified, then all zones are updated. + * + * Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime. + * + **/ +void updateChildDevice(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})" + + atomicState.evohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + // Heating Zones: + tcs.zones.each { zone -> + if (onlyZoneId == -1 || onlyZoneId == zone.zoneId) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId) + def d = getChildDevice(dni) + if(d) { + def schedule = atomicState.evohomeSchedules.find { it.dni == dni} + def currSw = getCurrentSwitchpoint(schedule.schedule) + def nextSw = getNextSwitchpoint(schedule.schedule) + + def values = [ + 'temperature': formatTemperature(zone?.temperatureStatus?.temperature), + //'isTemperatureAvailable': zone?.temperatureStatus?.isAvailable, + 'heatingSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpointMode': decapitalise(zone?.heatSetpointStatus?.setpointMode), + 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'thermostatModeMode': (tcs?.systemModeStatus?.isPermanent) ? 'permanent' : 'temporary', + 'thermostatModeUntil': tcs?.systemModeStatus?.timeUntil, + 'scheduledSetpoint': formatTemperature(currSw.temperature), + 'nextScheduledSetpoint': formatTemperature(nextSw.temperature), + 'nextScheduledTime': nextSw.time + ] + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}" + d.generateEvent(values) + } else { + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist." + } + } + } + + // Hot Water Zone: + if (tcs.dhw && (onlyZoneId == -1 || onlyZoneId == tcs.dhw.dhwId)) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, tcs.dhw.dhwId) + def d = getChildDevice(dni) + if(d) { + //def schedule = atomicState.evohomeSchedules.find { it.dni == dni} + //def currSw = getCurrentSwitchpoint(schedule.schedule) + //def nextSw = getNextSwitchpoint(schedule.schedule) + + def values = [ + 'temperature': formatTemperature(tcs.dhw?.temperatureStatus?.temperature), + //'isTemperatureAvailable': tcs.dhw?.temperatureStatus?.isAvailable, + 'switch': tcs.dhw?.stateStatus?.state.toLowerCase(), + 'switchStateMode': decapitalise(tcs.dhw?.stateStatus?.mode), + 'switchStateUntil': tcs.dhw?.stateStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'thermostatModeMode': (tcs?.systemModeStatus?.isPermanent) ? 'permanent' : 'temporary', + 'thermostatModeUntil': tcs?.systemModeStatus?.timeUntil + // 'scheduledSwitchState': ?? + // 'nextScheduledSwitchState': ?? + // 'nextScheduledTime': ?? + ] + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}" + d.generateEvent(values) + } else { + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist." + } + } + } + } + } +} + + +/********************************************************************** + * Evohome API Commands: + **********************************************************************/ + +/** + * authenticate() + * + * Authenticate to Evohome. + * + **/ +private authenticate() { + + if (atomicState.debug) log.debug "${app.label}: authenticate()" + + def requestParams = [ + method: 'POST', + uri: 'https://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'Username': settings.prefEvohomeUsername, + 'Password': settings.prefEvohomePassword + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: authenticate(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}" + atomicState.evohomeAuthFailed = true + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}" + atomicState.evohomeAuthFailed = true + } + +} + + +/** + * refreshAuthToken() + * + * Refresh Auth Token. + * If token refresh fails, then authenticate() is called. + * Request is simlar to authenticate, but with grant_type = 'refresh_token' and 'refresh_token'. + * + **/ +private refreshAuthToken() { + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()" + + def requestParams = [ + method: 'POST', + uri: 'https://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'refresh_token': atomicState.evohomeAuth.refreshToken + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}" + // If Unauthorized (401) then re-authenticate: + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + authenticate() + } + } + +} + + +/** + * getEvohomeUserAccount() + * + * Gets user account info and stores in atomicState.evohomeUserAccount. + * + **/ +private getEvohomeUserAccount() { + + log.info "${app.label}: getEvohomeUserAccount(): Getting user account information." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/userAccount', + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + atomicState.evohomeUserAccount = resp.data + if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Data: ${atomicState.evohomeUserAccount}" + } + else { + log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeUserAccount(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + } +} + + + +/** + * getEvohomeConfig() + * + * Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig. + * + **/ +private getEvohomeConfig() { + + log.info "${app.label}: getEvohomeConfig(): Getting configuration for all locations." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/location/installationInfo', + query: [ + 'userId': atomicState.evohomeUserAccount.userId, + 'includeTemperatureControlSystems': 'True' + ], + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Data: ${resp.data}" + atomicState.evohomeConfig = resp.data + atomicState.evohomeConfigUpdatedAt = now() + return null + } + else { + log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeConfig(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + + + + + +/** + * getEvohomeStatus(onlyZoneId=-1) + * + * Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus. + * If onlyZoneId is not specified, all zones in all locations are updated. + * + * Calls getEvohomeLocationStatus() and getEvohomeZoneStatus(). + * + **/ +private getEvohomeStatus(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})" + + def newEvohomeStatus = [] + + if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location): + + log.info "${app.label}: getEvohomeStatus(): Getting status for all zones." + + atomicState.evohomeConfig.each { loc -> + def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId) + if (locStatus) { + newEvohomeStatus << locStatus + } + } + + if (newEvohomeStatus) { + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + atomicState.evohomeStatusUpdatedAt = now() + } + } + else { // Only update the specified zone: + + log.info "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}" + + def newZoneStatus = getEvohomeZoneStatus(onlyZoneId) + if (newZoneStatus) { + // Get existing evohomeStatus and update only the specified zone, preserving data for other zones: + // Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate). + // If mutiple zones are requesting updates at the same time this could cause loss of new data, but + // the worst case is having out-of-date data for a few minutes... + newEvohomeStatus = atomicState.evohomeStatus + newEvohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == zone.zoneId) { + zone.activeFaults = newZoneStatus.activeFaults + zone.heatSetpointStatus = newZoneStatus.heatSetpointStatus + zone.temperatureStatus = newZoneStatus.temperatureStatus + } + } + } + } + } + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + // Note: atomicState.evohomeStatusUpdatedAt is NOT updated. + } + } +} + + +/** + * getEvohomeLocationStatus(locationId) + * + * Gets the status for a specific location and returns data as a map. + * + * Called by getEvohomeStatus(). + **/ +private getEvohomeLocationStatus(locationId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/location/${locationId}/status", + 'query': [ 'includeTemperatureControlSystems': 'True'], + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeLocationStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeLocationStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeZoneStatus(zoneId) + * + * Gets the status for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneStatus(zoneId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeSchedules() + * + * Gets the schedules for all hot water and temperature (heating) zones + * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules. + * and stores in atomicState.evohomeSchedules. + * + * Calls getEvohomeTempZoneSchedule() and getEvohomeDHWSchedule(). + * + **/ +private getEvohomeSchedules() { + + log.info "${app.label}: getEvohomeSchedules(): Getting schedules for all zones." + + def evohomeSchedules = [] + + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + // Heating Zones: + tcs.zones.each { zone -> + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + def schedule = getEvohomeTempZoneSchedule(zone.zoneId) + if (schedule) { + evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule] + } + } + // Hot Water Zone: + if (tcs.dhw) { + if (tcs.dhw.dhwId) { + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, tcs.dhw.dhwId ) + def schedule = getEvohomeDHWSchedule(tcs.dhw.dhwId) + if (schedule) { + evohomeSchedules << ['zoneId': tcs.dhw.dhwId, 'dni': dni, 'schedule': schedule] + } + } + } + } + } + } + + if (evohomeSchedules) { + // Write out complete schedules to state: + atomicState.evohomeSchedules = evohomeSchedules + atomicState.evohomeSchedulesUpdatedAt = now() + } + + return evohomeSchedules +} + + +/** + * getEvohomeTempZoneSchedule(zoneId) + * + * Gets the schedule for the specified temperature (heating) zone and returns data as a map. + * + **/ +private getEvohomeTempZoneSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeTempZoneSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeTempZoneSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeTempZoneSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeTempZoneSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeDHWSchedule(zoneId) + * + * Gets the schedule for the specified hot water zone and returns data as a map. + * + **/ +private getEvohomeDHWSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeDHWSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeDHWSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeDHWSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeDHWSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * setThermostatMode(systemId, mode, until) + * + * Set thermostat mode for specified controller, until specified time. + * + * systemId: SystemId of temperatureControlSystem. E.g.: 123456 + * + * mode: String. Either: "auto", "off", "economy", "away", "dayOff", "custom". + * + * until: (Optional) Time to apply mode until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * - Number: Duration in hours if mode is 'economy', or in days if mode is 'away'/'dayOff'/'custom'. + * Duration will be rounded down to align with Midnight in the local timezone + * (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent. + * If 'until' is not specified, a default value is used from the SmartApp settings. + * + * Notes: 'Auto' and 'Off' modes are always permanent. + * Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller). + * Therefore changing the thermostatMode will affect all zones associated with the same controller. + * + * + * Example usage: + * setThermostatMode(123456, 'auto') // Set auto mode permanently, for controller 123456. + * setThermostatMode(123456, 'away','2016-04-01T00:00:00Z') // Set away mode until 1st April, for controller 123456. + * setThermostatMode(123456, 'dayOff','permanent') // Set dayOff mode permanently, for controller 123456. + * setThermostatMode(123456, 'dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night), for controller 123456. + * setThermostatMode(123456, 'economy', 2) // Set economy mode for 2 hours, for controller 123456. + * + **/ +def setThermostatMode(systemId, mode, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}" + + // Clean mode (translate to index): + mode = mode.toLowerCase() + int modeIndex + switch (mode) { + case 'auto': + modeIndex = 0 + break + case 'off': + modeIndex = 1 + break + case 'eco': + modeIndex = 2 + break + case 'away': + modeIndex = 3 + break + case 'dayoff': + modeIndex = 4 + break + case 'custom': + modeIndex = 6 + break + default: + log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!" + modeIndex = 999 + break + } + + // Clean until: + def untilRes + + // until has not been specified, so determine behaviour from settings: + if (-1 == until && 'economy' == mode) { + until = atomicState.thermostatEconomyDuration ?: 0 // Use Default duration for economy mode (hours): + } + else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) { + until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days): + } + + // Convert to date (or 0): + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && 'economy' == mode) { // until is a duration in hours: + untilRes = new Date( now() + (Math.round(until) * 3600000) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { // until is a duration in days: + untilRes = new Date( now() + (Math.round(until) * 86400000) ).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) // Round down to midnight in the LOCAL timezone. + } + else { + log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently." + untilRes = 0 + } + + // If mode is away/dayOff/custom the date needs to be rounded down to midnight in the local timezone, then converted back to string again: + if (0 != untilRes && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilRes).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) + } + + // Build request: + def body + if (0 == untilRes || 'off' == mode || 'auto' == mode) { // Mode is permanent: + body = ['SystemMode': modeIndex, 'TimeUntil': null, 'Permanent': 'True'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True" + } + else { // Mode is temporary: + body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: False, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setThermostatMode(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setThermostatMode(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + + /** + * setHeatingSetpoint(zoneId, setpoint, until=-1) + * + * Set heatingSetpoint override for specified heating zone, until specified time. + * + * zoneId: Zone ID of zone, e.g.: "123456" + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * If not specified, setpoint will be applied permanently. + * + * Example usage: + * setHeatingSetpoint(123456, 21.0) // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, 'permanent') // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, '2016-04-01T00:00:00Z') // Set until specific time, for zone 123456. + * + **/ +def setHeatingSetpoint(zoneId, setpoint, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + + // Clean until: + def untilRes + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + def body + if (0 == untilRes) { // Permanent: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 1, 'TimeUntil': null] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent" + } + else { // Temporary: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearHeatingSetpoint(zoneId) + * + * Clear any override for the specified heating zone. + * zoneId: Zone ID of zone, e.g.: "123456" + * + **/ +def clearHeatingSetpoint(zoneId) { + + log.info "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': ['HeatSetpointValue': 0.0, 'SetpointMode': 0, 'TimeUntil': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * setDHWSwitchState(zoneId, switchState, until=-1) + * + * Set state override for specified hot water zone, until specified time. + * + * zoneId: Zone ID of hot water zone, e.g.: "123456" + * + * switchState: 'on' or 'off'. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * If not specified, setpoint will be applied permanently. + * + * Example usage: + * setDHWSwitchState(123456, 'on') // Turn on hot water zone (123456) permanently. + * setDHWSwitchState(123456, 'off', 'permanent') // Turn off hot water zone (123456) permanently. + * setDHWSwitchState(123456, 'on', '2016-04-01T00:00:00Z') // Turn on hot water zone (123456) until specific time. + * + **/ +def setDHWSwitchState(zoneId, switchState, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, switchState: ${switchState}, Until: ${until}" + + // Clean switchState: + def stateRes = ('on' == switchState.toLowerCase()) ? 1 : 0 + + // Clean until: + def untilRes + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else { + log.warn "${device.label}: setDHWSwitchState(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + // Note, DHW uses the parameter 'UntilTime' whereas heating zones use 'TimeUntil'. Go figure! + def body + if (0 == untilRes) { // Permanent: + body = ['State': stateRes, 'Mode': 1, 'UntilTime': null] + log.info "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, State: ${switchState}, Until: Permanent" + } + else { // Temporary: + body = ['State': stateRes, 'Mode': 2, 'UntilTime': untilRes] + log.info "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, State: ${switchState}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/state", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setDHWSwitchState(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setDHWSwitchState(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setDHWSwitchState(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearDHWSwitchState(zoneId) + * + * Clear any override for the specific hot water zone. + * zoneId: Zone ID of hot water zone, e.g.: "123456" + * + **/ +def clearDHWSwitchState(zoneId) { + + log.info "${app.label}: clearDHWSwitchState(): Hot Water Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/state", + 'body': ['State': 0, 'Mode': 0, 'UntilTime': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearDHWSwitchState(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearDHWSwitchState(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearDHWSwitchState(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + + /** + * pseudoSleep(ms) + * + * Substitute for sleep() command. + * + **/ +private pseudoSleep(ms) { + def start = now() + while (now() < start + ms) { + // Do nothing, just wait. + } +} + + +/** + * generateDni(locId,gatewayId,systemId,deviceId) + * + * Generate a device Network ID. + * Uses the same format as the official Evohome App, but with a prefix of "Evohome." + * + **/ +private generateDni(locId,gatewayId,systemId,deviceId) { + return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.') +} + + +/** + * formatTemperature(t) + * + * Format temperature value to one decimal place. + * t: can be string, float, bigdecimal... + * Returns string. + * + **/ +private formatTemperature(t) { + try { + return Float.parseFloat("${t}").round(1).toString() + } + catch (NumberFormatException e) { + log.warn "${app.label}: formatTemperature(): could not parse value: ${t}" + return null + } +} + + +/** + * decapitalise(string) + * + * + * Decapitalise first letter of string. + * + * + **/ +private decapitalise(string) { + + if ( string == null || 0 == string.length() ) { + return string + } + else { + return string[0].toLowerCase() + string.substring(1) + + } + +} + + +/** + * formatThermostatMode(mode) + * + * Translate Evohome thermostatMode values to SmartThings values. + * + **/ +private formatThermostatMode(mode) { + + switch (mode) { + case 'Auto': + mode = 'auto' + break + case 'HeatingOff': + mode = 'off' + break + case 'AutoWithEco': + mode = 'eco' + break + case 'Away': + mode = 'away' + break + case 'DayOff': + mode = 'dayoff' + break + case 'Custom': + mode = 'custom' + break + default: + log.warn "${app.label}: formatThermostatMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * getCurrentSwitchpoint(schedule) + * + * Returns the current active switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getCurrentSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + ScheduleToday.switchpoints.reverse(true) + def currentSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay < c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!currentSwitchPoint) { + // There are no current switchpoints today, so we must look for the last Switchpoint yesterday. + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): No current switchpoints today, so must look to yesterday's schedule." + c.add(Calendar.DATE, -1 ) // Subtract one DAY. + def ScheduleYesterday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleYesterday.switchpoints.sort {it.timeOfDay} + ScheduleYesterday.switchpoints.reverse(true) + currentSwitchPoint = ScheduleYesterday.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + currentSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + currentSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): Current Switchpoint: ${currentSwitchPoint}" + + return currentSwitchPoint +} + + +/** + * getNextSwitchpoint(schedule) + * + * Returns the next switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getNextSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + def nextSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay > c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!nextSwitchPoint) { + // There are no switchpoints left today, so we must look for the first Switchpoint tomorrow. + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): No more switchpoints today, so must look to tomorrow's schedule." + c.add(Calendar.DATE, 1 ) // Add one DAY. + def ScheduleTmrw = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleTmrw.switchpoints.sort {it.timeOfDay} + nextSwitchPoint = ScheduleTmrw.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + nextSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + nextSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): Next Switchpoint: ${nextSwitchPoint}" + + return nextSwitchPoint +} diff --git a/devicetypes/Fusion solar huawei b/devicetypes/Fusion solar huawei new file mode 100644 index 00000000000..621b293e4ae --- /dev/null +++ b/devicetypes/Fusion solar huawei @@ -0,0 +1,214 @@ +''' +Before running this source file make sure you follow the instructions +from https://forum.huawei.com/enterprise/en/communicate-with-fusionsolar-through-an-openapi-account/thread/591478-100027?page=3 + +You can't access Fusion Solar devices without an OpenAPI account previously created. +''' + +import requests +import json +import sys + +''' +OpenAPI URLs +@login_url : Login url for POST method. +@logout_url : Logout url for POST method. +@get_station_list_url : Get stations list url for POST method. +@real_time_data_url : Power station info url for POST method. +''' +login_url = 'https://eu5.fusionsolar.huawei.com/thirdData/login' +logout_url = 'https://eu5.fusionsolar.huawei.com/thirdData/logout' +get_station_list_url = 'https://eu5.fusionsolar.huawei.com/thirdData/getStationList' +real_time_data_url = 'https://eu5.fusionsolar.huawei.com/thirdData/getStationRealKpi' + + +''' +OpenAPI variables. + +@username : OpenAPI username. +@password : OpenAPI password. +@xsrf_token : Session token returned by login method. +@plant_name : Plant name to be interrogated. +@station_code : Station code returned by get statil list method +''' +username = '' +password = '' +xsrf_token = '' +plant_name = '' +station_code = '' + +def read_credentials(): + """ + Function to read OpenAPI credentials (username/password). + """ + global username + global password + + print ("Enter OpenAPI Credentials") + username = input("Username: ") + password = input("Password: ") + + +def openapi_login(): + """ + Perform login to OpenAPI account. + + Requires username and passowrd and return session token in response cookie. + """ + + global xsrf_token + login_obj = { + "userName" : username, + "systemCode" : password + } + + # Send login request + response = requests.post( + login_url, + json = login_obj, + cookies = {"web-auth" : "true", "Cookie_1" : "value"}, + timeout = 3600 + ) + + # Inspect login response + try: + json_status = json.loads(response.content) + if json_status['success'] == False: + print ("ERROR: Login Failed") + sys.exit() + + print ("INFO: Login Successfully") + + except ValueError: + print ("ERROR: Login unexpected response from server") + + # Get session cookie (xsrf-token) + cookies_dict = response.cookies.get_dict() + if "XSRF-TOKEN" not in cookies_dict: + print ("ERROR: XSRF-TOKEN not found in cookies") + sys.exit() + + xsrf_token = cookies_dict.get("XSRF-TOKEN") + print ("XSRF-TOKEN: %s" % xsrf_token) + +def openapi_get_station_list(): + """ + Read station list for current user. + + Require session token. + """ + global station_code + global plant_name + plant_obj = {} + + # Send get station list request + response = requests.post( + get_station_list_url, + json = plant_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token}, + timeout = 3600 + ) + + # Inspect response + try: + json_plant = json.loads(response.content) + if json_plant['success'] == False: + print ("ERROR: Get Station List Failed") + openapi_logout() + sys.exit() + + except ValueError: + openapi_logout() + print ("ERROR: Get station list unexpected response from server") + sys.exit() + + # read plant name + plant_name = input("Enter plant name: ") + + print ("INFO: Stations list:") + # plant name lookup inside plants list + for station in json_plant['data']: + if "stationName" not in station: + print ("ERROR: Unknown format in get station list response") + openapi_logout() + sys.exit() + + print ("INFO: Station name : %s; Station code : %s" % (station.get('stationName'), station.get('stationCode'))) + if station.get('stationName') == plant_name: + station_code = station.get('stationCode') + + if station_code == '': + print ("ERROR: Plant name %s not found in station list" % plant_name) + + +# OpenAPI Read Station Real TimeData +def openapi_real_time_data(): + rtime_obj = { "stationCodes" : station_code } + + # Send real time data request + response = requests.post( + real_time_data_url, + json = rtime_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token}, + timeout = 3600 + ) + + # Evaluate real time response + try: + json_rtime = json.loads(response.content) + if json_rtime['success'] == False: + print ("ERROR: Real Time Information Failed") + openapi_logout() + sys.exit() + + except ValueError: + openapi_logout() + print ("ERROR: Plant list unexpected response from server") + + + print ("INFO: Real time data for %s station:" % plant_name) + # Print values + for data_obj in json_rtime['data']: + map_obj = data_obj.get('dataItemMap') + print ("Day power : %s" % map_obj.get('day_power')) + print ("Month power : %s" % map_obj.get('month_power')) + print ("Total power : %s" % map_obj.get('total_power')) + print ("Health State : %s" % map_obj.get('real_health_state')) + + +def openapi_logout(): + """ + Perform logout to OpenAPI account. + + Requires session token. + """ + logout_obj = { "xsrfToken" : xsrf_token } + + # Send logout request + response = requests.post( + logout_url, + json = logout_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token} + ) + + # Inspect logout response + try: + json_status = json.loads(response.content) + if json_status['success'] == False: + print ("ERROR: Logout Failed") + sys.exit() + + print ("INFO: Logout Successfully") + except ValueError: + print ("ERROR: Logout unexpected response from server") + + +if __name__ == "__main__": + read_credentials() + openapi_login() + openapi_get_station_list() + openapi_real_time_data() + openapi_logout() diff --git a/devicetypes/enlighten-solar-system-1-2022_12_05_14_37_13.tar.gz b/devicetypes/enlighten-solar-system-1-2022_12_05_14_37_13.tar.gz new file mode 100644 index 00000000000..76e203c13fe Binary files /dev/null and b/devicetypes/enlighten-solar-system-1-2022_12_05_14_37_13.tar.gz differ diff --git a/devicetypes/evohome-connect.groovy b/devicetypes/evohome-connect.groovy new file mode 100644 index 00000000000..4e679a1dba6 --- /dev/null +++ b/devicetypes/evohome-connect.groovy @@ -0,0 +1,707 @@ +/** + * Copyright 2020 Andreas Christodoulou (Andremain) + * + * Name: Evohome Heating Zone + * + * Author: Andreas Christodoulou (Andremain) + * + * Date: 2020 + * + * Version: 2.1 + * + * Description: + * - This device handler is a child device for the Evohome (Connect) SmartApp. + * - For latest documentation see: https://github.com/andremain/EvohomeSmartthingsNew + * + * Version History: + * + * 2016-04-08: v0.09 + * - calculateOptimisations(): Fixed comparison of temperature values. + * + * 2016-04-05: v0.08 + * - New 'Update Refresh Time' setting from parent to control polling after making an update. + * - setThermostatMode(): Forces poll for all zones to ensure new thermostatMode is updated. + * + * 2016-04-04: v0.07 + * - generateEvent(): hides events if name or value are null. + * - generateEvent(): log.info message for new values. + * + * 2016-04-03: v0.06 + * - Initial Beta Release + * + * To Do: + * - Clean up device settings (preferences). Hide/Show prefSetpointDuration input dynamically depending on prefSetpointMode. - If supported for devices??? + * + * License: + * 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. + * + */ +metadata { + definition (name: "Evohome Heating R1.1", namespace: "Andremain", author: "Andreas Christodoulou", deviceTypeId:"Thermostat",ocfDeviceType:"oic.d.thermostat", vid: "3c7f0c66-08c2-37e5-b3f0-6c5199eb2701", mnmn:"SmartThingsCommunity") { + capability "Refresh" + capability "Temperature Measurement" + capability 'Health Check' + capability "thermostatOperatingState" + capability "Thermostat" + + //New Smartthings Capabilities + capability "Thermostat Heating Setpoint" + capability "Thermostat Setpoint" + capability "Thermostat Mode" + + + command "refresh" // Refresh + command "setHeatingSetpoint" // Thermostat + command "setThermostatMode" // Thermostat + command "off" // Thermostat + command "heat" // Thermostat + + + attribute "temperature","number" // Temperature Measurement + attribute "heatingSetpoint","number" // Thermostat + attribute "thermostatSetpoint","number" // Thermostat + attribute "thermostatSetpointUntil", "string" // Custom + attribute "thermostatSetpointStatus", "string" // Custom + attribute "thermostatMode", "string" // Thermostat + attribute "thermostatOperatingState", "string" // Thermostat + attribute "thermostatStatus", "string" // Custom + attribute "scheduledSetpoint", "number" // Custom + attribute "nextScheduledSetpoint", "number" // Custom + attribute "nextScheduledTime", "string" // Custom + attribute "optimisation", "string" // Custom + attribute "windowFunction", "string" // Custom + + } + + preferences { + section { // Setpoint Adjustments: + input title: "Setpoint Duration", description: "Configure how long setpoint adjustments are applied for.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefSetpointMode', 'enum', title: 'Until', description: '', options: ["Next Switchpoint", "Midday", "Midnight", "Duration", "Permanent"], defaultValue: "Next Switchpoint", required: true, displayDuringSetup: true + input 'prefSetpointDuration', 'number', title: 'Duration (minutes)', description: 'Apply setpoint for this many minutes', range: "1..1440", defaultValue: 60, required: true, displayDuringSetup: true + //input 'prefSetpointTime', 'time', title: 'Time', description: 'Apply setpoint until this time', required: true, displayDuringSetup: true + input title: "Setpoint Temperatures", description: "Configure preset temperatures for the 'Boost' and 'Suppress' buttons.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input "prefBoostTemperature", "string", title: "'Boost' Temperature", defaultValue: "21.5", required: true, displayDuringSetup: true // use of 'decimal' input type in devices is currently broken. + input "prefSuppressTemperature", "string", title: "'Suppress' Temperature", defaultValue: "15.0", required: true, displayDuringSetup: true // use of 'decimal' input type in devices is currently broken. + } + + } + +} + + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + * When a device is created by a SmartApp, settings are not populated + * with the defaultValues configured for each input. Therefore, we + * populate the corresponding state.* variables with the input defaultValues. + * + **/ +def installed() { + + initialize() + + log.debug "${app.label}: Installed with settings: ${settings}" + + state.installedAt = now() + + // These default values will be overwritten by the Evohome SmartApp almost immediately: + state.debug = false + state.updateRefreshTime = 5 // Wait this many seconds after an update before polling. + state.zoneType = 'RadiatorZone' + state.minHeatingSetpoint = formatTemperature(5.0) + state.maxHeatingSetpoint = formatTemperature(35.0) + state.temperatureResolution = formatTemperature(0.5) + state.windowFunctionTemperature = formatTemperature(5.0) + state.targetSetpoint = state.minHeatingSetpoint + + // Populate state.* with default values for each preference/input: + state.setpointMode = getInputDefaultValue('prefSetpointMode') + state.setpointDuration = getInputDefaultValue('prefSetpointDuration') + +} + + +/** + * updated() + * + * Runs when device settings are changed. + **/ +def updated() { + + if (state.debug) log.debug "${device.label}: Updating with settings: ${settings}" + + // Copy input values to state: + state.setpointMode = settings.prefSetpointMode + state.setpointDuration = settings.prefSetpointDuration + state.boostTemperature = formatTemperature(settings.prefBoostTemperature) + state.suppressTemperature = formatTemperature(settings.prefSuppressTemperature) + +} + +def initialize() { + sendEvent(name:"temperature", value:"5", unit:"C") + sendEvent(name:"heatingSetpoint", value:"5", unit:"C") + sendEvent(name:"setHeatingSetpoint", value:"5", unit:"C") + sendEvent(name:"thermostatMode", value:"auto") + sendEvent(name:"supportedThermostatModes", value:["auto","off","eco","away","dayoff","custom"]) +} +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * generateEvent(values) + * + * Called by parent to update the state of this child device. + * + **/ +void generateEvent(values) { + + log.info "${device.label}: generateEvent(): New values: ${values}" + + if(values) { + values.each { name, value -> + if ( name == 'minHeatingSetpoint' + || name == 'maxHeatingSetpoint' + || name == 'temperatureResolution' + || name == 'windowFunctionTemperature' + || name == 'zoneType' + || name == 'locationId' + || name == 'gatewayId' + || name == 'systemId' + || name == 'zoneId' + || name == 'schedule' + || name == 'debug' + || name == 'updateRefreshTime' + ) { + // Internal state only. + state."${name}" = value + } + else { // Attribute value, so generate an event: + if (name != null && value != null) { + if(name=='temperature'){ + //add unit for Temperature because it is needed in Dashboard View + sendEvent(name: name, value: value, unit:"C", displayed: true) + }else{ + sendEvent(name: name, value: value, displayed: true) + } + } + else { // If name or value is null, set displayed to false, + // otherwise the 'Recently' view on smartphone app clogs + // up with empty events. + sendEvent(name: name, value: value, displayed: false) + } + + // Reset targetSetpoint (used by raiseSetpoint/lowerSetpoint) if heatingSetpoint has changed: + if (name == 'heatingSetpoint') { + state.targetSetpoint = value + } + } + } + } + + // Calculate derived attributes (order is important here): + calculateThermostatOperatingState() + calculateOptimisations() + calculateThermostatStatus() + calculateThermostatSetpointStatus() + +} + +/********************************************************************** + * Capability-related Commands: + **********************************************************************/ + + +/** + * poll() + * + * Polls the device. Required for the "Polling" capability + **/ +void poll() { + + if (state.debug) log.debug "${device.label}: poll()" + parent.poll(state.zoneId) +} + + +/** + * refresh() + * + * Refreshes values from the device. Required for the "Refresh" capability. + **/ +void refresh() { + + if (state.debug) log.debug "${device.label}: refresh()" + sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) + parent.poll(state.zoneId) +} + + + +def setThermostatMode(String mode, until=-1) { + + log.info "${device.label}: setThermostatMode(Mode: ${mode}, Until: ${until})" + + // Send update via parent: + if (!parent.setThermostatMode(state.systemId, mode, until)) { + sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) + // Wait a few seconds as it takes a while for Evohome to update setpoints in response to a mode change. + pseudoSleep(state.updateRefreshTime * 1000) + parent.poll(0) // Force poll for all zones as thermostatMode is a property of the temperatureControlSystem. + return null + } + else { + log.error "${device.label}: setThermostatMode(): Error: Unable to set thermostat mode." + return 'error' + } +} + + +/** + * setHeatingSetpoint(setpoint, until=-1) + * + * Set heatingSetpoint until specified time. + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * If setpoint is outside allowed range (i.e. minHeatingSetpoint to + * maxHeatingSetpoint) it will be re-written to the appropriate limit. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'nextSwitchpoint', 'midnight', 'midday', or 'permanent'. + * - Number: duration in minutes (from now). 0 = permanent. + * If not specified, setpoint duration will default to the + * behaviour defined in the device settings. + * + * Example usage: + * setHeatingSetpoint(21.0) // Set until . + * setHeatingSetpoint(21.0, 'nextSwitchpoint') // Set until next scheduled switchpoint. + * setHeatingSetpoint(21.0, 'midnight') // Set until midnight. + * setHeatingSetpoint(21.0, 'permanent') // Set permanently. + * setHeatingSetpoint(21.0, 0) // Set permanently. + * setHeatingSetpoint(21.0, 6) // Set for 6 hours. + * setHeatingSetpoint(21.0, '2016-04-01T00:00:00Z') // Set until specific time. + * + **/ +def setHeatingSetpoint(setpoint, until=-1) { + + if (state.debug) log.debug "${device.label}: setHeatingSetpoint(Setpoint: ${setpoint}, Until: ${until})" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + if (Float.parseFloat(setpoint) < Float.parseFloat(state.minHeatingSetpoint)) { + log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is less than zone's minimum setpoint (${state.minHeatingSetpoint})." + setpoint = state.minHeatingSetpoint + } + else if (Float.parseFloat(setpoint) > Float.parseFloat(state.maxHeatingSetpoint)) { + log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is greater than zone's maximum setpoint (${state.maxHeatingSetpoint})." + setpoint = state.maxHeatingSetpoint + } + + // Clean and parse until value: + def untilRes + Calendar c = new GregorianCalendar() + def tzOffset = location.timeZone.getOffset(new Date().getTime()) // Timezone offset to UTC in milliseconds. + + // If until has not been specified, determine behaviour from device state.setpointMode: + if (-1 == until) { + switch (state.setpointMode) { + case 'Next Switchpoint': + until = 'nextSwitchpoint' + break + case 'Midday': + until = 'midday' + break + case 'Midnight': + until = 'midnight' + break + case 'Duration': + until = state.setpointDuration ?: 0 + break + case 'Time': + // TO DO : construct time, like we do for midnight. + // settings.prefSetpointTime appears to return an ISO dateformat string. + // However using an input of type "time" causes HTTP 500 errors in the IDE, so disabled for now. + // If time has passed, then need to make it the next day. + if (state.debug) log.debug "${device.label}: setHeatingSetpoint(): Time: ${state.SetpointTime}" + until = 'nextSwitchpoint' + break + case 'Permanent': + until = 'permanent' + break + default: + until = 'nextSwitchpoint' + break + } + } + + if ('permanent' == until || 0 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until + } + else if ('nextSwitchpoint' == until) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", device.currentValue('nextScheduledTime')) + } + else if ('midday' == until) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().format("yyyy-MM-dd'T'12:00:00XX", location.timeZone)) + } + else if ('midnight' == until) { + c.add(Calendar.DATE, 1 ) // Add one day to calendar and use to get midnight in local time: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", c.getTime().format("yyyy-MM-dd'T'00:00:00XX", location.timeZone)) + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string, so parse: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until) + } + else if (until.isNumber()) { // until is a duration in minutes, so construct date from now(): + // Evohome supposedly only accepts setpoints for up to 24 hours, so we should limit minutes to 1440. + // For now, just pass any duration and see if Evohome accepts it... + untilRes = new Date( now() + (Math.round(until) * 60000) ) + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + log.info "${device.label}: setHeatingSetpoint(): Setting setpoint to: ${setpoint} until: ${untilRes}" + + // Send update via parent: + if (!parent.setHeatingSetpoint(state.zoneId, setpoint, untilRes)) { + // Command was successful, but it takes a few seconds for the Evohome cloud service to update with new values. + // Meanwhile, we know the new setpoint and thermostatSetpointMode anyway: + sendEvent(name: 'heatingSetpoint', value: setpoint) + sendEvent(name: 'thermostatSetpoint', value: setpoint) + sendEvent(name: 'thermostatSetpointMode', value: (0 == untilRes) ? 'permanentOverride' : 'temporaryOverride' ) + sendEvent(name: 'thermostatSetpointUntil', value: (0 == untilRes) ? null : untilRes.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC'))) + calculateThermostatOperatingState() + calculateOptimisations() + calculateThermostatStatus() + sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) + pseudoSleep(state.updateRefreshTime * 1000) + parent.poll(state.zoneId) + return null + } + else { + log.error "${device.label}: setHeatingSetpoint(): Error: Unable to set heating setpoint." + return 'error' + } +} + + + +/** + * clearHeatingSetpoint() + * + * Clear the heatingSetpoint. Will return heatingSetpoint to scheduled value. + * thermostatSetpointMode should return to "followSchedule". + * + **/ +def clearHeatingSetpoint() { + + log.info "${device.label}: clearHeatingSetpoint()" + + // Send update via parent: + if (!parent.clearHeatingSetpoint(state.zoneId)) { + // Command was successful, but it takes a few seconds for the Evohome cloud service + // to update the zone status with the new heatingSetpoint. + // Meanwhile, we know the new thermostatSetpointMode is "followSchedule". + sendEvent(name: 'thermostatSetpointMode', value: 'followSchedule') + sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) + // sleep command is not allowed in SmartThings, so we use psuedoSleep(). + pseudoSleep(state.updateRefreshTime * 1000) + parent.poll(state.zoneId) + return null + } + else { + log.error "${device.label}: clearHeatingSetpoint(): Error: Unable to clear heating setpoint." + return 'error' + } +} + + +/** + * alterSetpoint() + * + * Proxy command called by raiseSetpoint and lowerSetpoint, as runIn + * cannot pass targetSetpoint diretly to setHeatingSetpoint. + * + **/ +private alterSetpoint() { + + if (state.debug) log.debug "${device.label}: alterSetpoint()" + + setHeatingSetpoint(state.targetSetpoint) +} + + +/********************************************************************** + * Convenience Commands: + * These commands alias other commands with preset parameters. + **********************************************************************/ + + +void heat() { + if (state.debug) log.debug "${device.label}: heat()" + setThermostatMode('auto') +} + +void off() { + if (state.debug) log.debug "${device.label}: off()" + setThermostatMode('off') +} + + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + +/** + * pseudoSleep(ms) + * + * Substitute for sleep() command. + * + **/ +private pseudoSleep(ms) { + def start = now() + while (now() < start + ms) { + // Do nothing, just wait. + } +} + + +/** + * getInputDefaultValue(inputName) + * + * Get the default value for the specified input. + * + **/ +private getInputDefaultValue(inputName) { + + if (state.debug) log.debug "${device.label}: getInputDefaultValue()" + + def returnValue + properties.preferences?.sections.each { section -> + section.input.each { input -> + if (input.name == inputName) { + returnValue = input.defaultValue + } + } + } + + return returnValue +} + + + +/** + * formatTemperature(t) + * + * Format temperature value to one decimal place. + * t: can be string, float, bigdecimal... + * Returns as string. + **/ +private formatTemperature(t) { + //return Float.parseFloat("${t}").round(1) + //return String.format("%.1f", Float.parseFloat("${t}").round(1)) + return Float.parseFloat("${t}").round(1).toString() +} + + +/** + * formatThermostatModeForDisp(mode) + * + * Translate SmartThings values to display values. + * + **/ +private formatThermostatModeForDisp(mode) { + + if (state.debug) log.debug "${device.label}: formatThermostatModeForDisp()" + + switch (mode) { + case 'off': + mode = 'Off' + break + default: + mode = 'Unknown' + break + } + + return mode + } + +/** + * calculateThermostatOperatingState() + * + * Calculates thermostatOperatingState and generates event accordingly. + * + **/ +private calculateThermostatOperatingState() { + + if (state.debug) log.debug "${device.label}: calculateThermostatOperatingState()" + + def tOS + if ('off' == device.currentValue('thermostatMode')) { + tOS = 'off' + } + else if (device.currentValue("temperature") < device.currentValue("thermostatSetpoint")) { + tOS = 'heating' + } + else { + tOS = 'idle' + } + + sendEvent(name: 'thermostatOperatingState', value: tOS) +} + + +/** + * calculateOptimisations() + * + * Calculates if optimisation and windowFunction are active + * and generates events accordingly. + * + * This isn't going to be 100% perfect, but is reasonably accurate. + * + **/ +private calculateOptimisations() { + + if (state.debug) log.debug "${device.label}: calculateOptimisations()" + + def newOptValue = 'inactive' + def newWdfValue = 'inactive' + + // Convert temp values to BigDecimals for comparison: + def heatingSp = new BigDecimal(device.currentValue('heatingSetpoint')) + def scheduledSp = new BigDecimal(device.currentValue('scheduledSetpoint')) + def nextScheduledSp = new BigDecimal(device.currentValue('nextScheduledSetpoint')) + def windowTemp = new BigDecimal(state.windowFunctionTemperature) + + if ('auto' != device.currentValue('thermostatMode')) { + // Optimisations cannot be active if thermostatMode is not 'auto'. + } + else if ('followSchedule' != device.currentValue('thermostatSetpointMode')) { + // Optimisations cannot be active if thermostatSetpointMode is not 'followSchedule'. + // There must be a manual override. + } + else if (heatingSp == scheduledSp) { + // heatingSetpoint is what it should be, so no reason to suspect that optimisations are active. + } + else if (heatingSp == nextScheduledSp) { + // heatingSetpoint is the nextScheduledSetpoint, so optimisation is likely active: + newOptValue = 'active' + } + else if (heatingSp == windowTemp) { + // heatingSetpoint is the windowFunctionTemp, so windowFunction is likely active: + newWdfValue = 'active' + } + + sendEvent(name: 'optimisation', value: newOptValue) + sendEvent(name: 'windowFunction', value: newWdfValue) + +} + + +/** + * calculateThermostatStatus() + * + * Calculates thermostatStatus and generates event accordingly. + * + * thermostatStatus is a text summary of thermostatMode and thermostatOperatingState. + * + **/ +private calculateThermostatStatus() { + + if (state.debug) log.debug "${device.label}: calculateThermostatStatus()" + + def newThermostatStatus = '' + def thermostatModeDisp = formatThermostatModeForDisp(device.currentValue('thermostatMode')) + def setpoint = device.currentValue('thermostatSetpoint') + + if ('Off' == thermostatModeDisp) { + newThermostatStatus = 'Off' + } + else if('heating' == device.currentValue('thermostatOperatingState')) { + newThermostatStatus = "Heating to ${setpoint}° (${thermostatModeDisp})" + } + else { + newThermostatStatus = "Idle (${thermostatModeDisp})" + } + + sendEvent(name: 'thermostatStatus', value: newThermostatStatus) +} + + + +/** + * calculateThermostatSetpointStatus() + * + * Calculates thermostatSetpointStatus and generates event accordingly. + * + * thermostatSetpointStatus is a text summary of thermostatSetpointMode and thermostatSetpointUntil. + * It also indicates if 'optimisation' or 'windowFunction' is active. + * + **/ +private calculateThermostatSetpointStatus() { + + if (state.debug) log.debug "${device.label}: calculateThermostatSetpointStatus()" + + def newThermostatSetpointStatus = '' + def setpointMode = device.currentValue('thermostatSetpointMode') + + if ('off' == device.currentValue('thermostatMode')) { + newThermostatSetpointStatus = 'Off' + } + else if ('active' == device.currentValue('optimisation')) { + newThermostatSetpointStatus = 'Optimisation Active' + } + else if ('active' == device.currentValue('windowFunction')) { + newThermostatSetpointStatus = 'Window Function Active' + } + else if ('followSchedule' == setpointMode) { + newThermostatSetpointStatus = 'Following Schedule' + } + else if ('permanentOverride' == setpointMode) { + newThermostatSetpointStatus = 'Permanent' + } + else { + def untilStr = device.currentValue('thermostatSetpointUntil') + if (untilStr) { + + //def nowDate = new Date() + + // thermostatSetpointUntil is an ISO-8601 date format in UTC, and parse() seems to assume date is in UTC. + def untilDate = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilStr) + def untilDisp = '' + + if (untilDate.format("u") == new Date().format("u")) { // Compare day of week to current day of week (today). + untilDisp = untilDate.format("HH:mm", location.timeZone) // Same day, so just show time. + } + else { + untilDisp = untilDate.format("HH:mm 'on' EEEE", location.timeZone) // Different day, so include name of day. + } + newThermostatSetpointStatus = "Temporary Until ${untilDisp}" + } + else { + newThermostatSetpointStatus = "Temporary" + } + } + + sendEvent(name: 'thermostatSetpointStatus', value: newThermostatSetpointStatus) +} diff --git a/devicetypes/fusion_solar_py-0.0.7.tar.gz b/devicetypes/fusion_solar_py-0.0.7.tar.gz new file mode 100644 index 00000000000..83173eb49c5 Binary files /dev/null and b/devicetypes/fusion_solar_py-0.0.7.tar.gz differ diff --git a/devicetypes/smartthings/hue-bulb.src/hue-bulb.groovy b/devicetypes/smartthings/hue-bulb.src/hue-bulb.groovy index 6cb751357fc..59daf9b16d6 100644 --- a/devicetypes/smartthings/hue-bulb.src/hue-bulb.groovy +++ b/devicetypes/smartthings/hue-bulb.src/hue-bulb.groovy @@ -1,13 +1,8 @@ -//DEPRECATED. INTEGRATION MOVED TO SUPER LAN CONNECT - /** * Hue Bulb * - * Philips Hue Type "Extended Color Light" - * * Author: SmartThings */ - // for the UI metadata { // Automatically generated. Make future change here. @@ -15,15 +10,12 @@ metadata { capability "Switch Level" capability "Actuator" capability "Color Control" - capability "Color Temperature" capability "Switch" capability "Refresh" capability "Sensor" - capability "Health Check" - capability "Light" command "setAdjustedColor" - command "reset" + command "reset" command "refresh" } @@ -32,67 +24,43 @@ metadata { } tiles (scale: 2){ - multiAttributeTile(name:"rich-control", type: "lighting", width: 6, height: 4, canChangeIcon: true){ + multiAttributeTile(name:"switch", type: "lighting", width: 6, height: 4, canChangeIcon: true){ tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { - attributeState "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#00A0DC", nextState:"turningOff" + attributeState "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff" attributeState "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" - attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#00A0DC", nextState:"turningOff" + attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff" attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" } tileAttribute ("device.level", key: "SLIDER_CONTROL") { - attributeState "level", action:"switch level.setLevel", range:"(0..100)" - } + attributeState "level", action:"switch level.setLevel" + } tileAttribute ("device.color", key: "COLOR_CONTROL") { attributeState "color", action:"setAdjustedColor" } } - controlTile("colorTempSliderControl", "device.colorTemperature", "slider", width: 4, height: 2, inactiveLabel: false, range:"(2000..6500)") { - state "colorTemperature", action:"color temperature.setColorTemperature" - } - - valueTile("colorTemp", "device.colorTemperature", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { - state "colorTemperature", label: 'WHITES' - } - - standardTile("reset", "device.reset", height: 2, width: 2, inactiveLabel: false, decoration: "flat") { - state "default", label:"Reset To White", action:"reset", icon:"st.lights.philips.hue-single" + standardTile("reset", "device.reset", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", label:"Reset Color", action:"reset", icon:"st.lights.philips.hue-single" } - - standardTile("refresh", "device.refresh", height: 2, width: 2, inactiveLabel: false, decoration: "flat") { + standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" } - - main(["rich-control"]) - details(["rich-control", "colorTempSliderControl", "colorTemp", "reset", "refresh"]) } -} -def initialize() { - sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\", \"hubHardwareId\": \"${device.hub.hardwareID}\"}", displayed: false) -} + main(["switch"]) + details(["switch", "levelSliderControl", "rgbSelector", "refresh", "reset"]) -void installed() { - log.debug "installed()" - initialize() -} - -def updated() { - log.debug "updated()" - initialize() } // parse events into attributes def parse(description) { log.debug "parse() - $description" def results = [] - def map = description if (description instanceof String) { log.debug "Hue Bulb stringToMap - ${map}" map = stringToMap(description) } - if (map?.name && map?.value) { results << createEvent(name: "${map?.name}", value: "${map?.value}") } @@ -100,104 +68,91 @@ def parse(description) { } // handle commands -void on() { - log.trace parent.on(this) +def on(transition = "4") { + log.trace parent.on(this,transition) + sendEvent(name: "switch", value: "on") } -void off() { - log.trace parent.off(this) +def off(transition = "4") { + log.trace parent.off(this,transition) + sendEvent(name: "switch", value: "off") } -void setLevel(percent, rate = null) { - log.debug "Executing 'setLevel'" - if (verifyPercent(percent)) { - log.trace parent.setLevel(this, percent) - } +def nextLevel() { + def level = device.latestValue("level") as Integer ?: 0 + if (level <= 100) { + level = Math.min(25 * (Math.round(level / 25) + 1), 100) as Integer + } + else { + level = 25 + } + setLevel(level) } -void setSaturation(percent) { - log.debug "Executing 'setSaturation'" - if (verifyPercent(percent)) { - log.trace parent.setSaturation(this, percent) - } +def setLevel(percent) { + log.debug "Executing 'setLevel'" + parent.setLevel(this, percent) + sendEvent(name: "level", value: percent) } -void setHue(percent) { - log.debug "Executing 'setHue'" - if (verifyPercent(percent)) { - log.trace parent.setHue(this, percent) - } +def setSaturation(percent) { + log.debug "Executing 'setSaturation'" + parent.setSaturation(this, percent) + sendEvent(name: "saturation", value: percent) } -void setColor(value) { - def events = [] - def validValues = [:] +def setHue(percent) { + log.debug "Executing 'setHue'" + parent.setHue(this, percent) + sendEvent(name: "hue", value: percent) +} - if (verifyPercent(value.hue)) { - validValues.hue = value.hue - } - if (verifyPercent(value.saturation)) { - validValues.saturation = value.saturation - } - if (value.hex != null) { - if (value.hex ==~ /^\#([A-Fa-f0-9]){6}$/) { - validValues.hex = value.hex - } else { - log.warn "$value.hex is not a valid color" - } - } - if (verifyPercent(value.level)) { - validValues.level = value.level - } - if (value.switch == "off" || (value.level != null && value.level <= 0)) { - validValues.switch = "off" - } else { - validValues.switch = "on" - } - if (!validValues.isEmpty()) { - log.trace parent.setColor(this, validValues) - } +def setColor(value,alert = "none",transition = 4) { + log.debug "setColor: ${value}, $this" + parent.setColor(this, value, alert, transition) + if (value.hue) { sendEvent(name: "hue", value: value.hue)} + if (value.saturation) { sendEvent(name: "saturation", value: value.saturation)} + if (value.hex) { sendEvent(name: "color", value: value.hex)} + if (value.level) { sendEvent(name: "level", value: value.level)} + if (value.switch) { sendEvent(name: "switch", value: value.switch)} } -void reset() { - log.debug "Executing 'reset'" - setColorTemperature(4000) +def reset() { + log.debug "Executing 'reset'" + def value = [level:100, hex:"#90C638", saturation:56, hue:23] + setAdjustedColor(value) + parent.poll() } -void setAdjustedColor(value) { - if (value) { +def setAdjustedColor(value) { + if (value) { log.trace "setAdjustedColor: ${value}" def adjusted = value + [:] + adjusted.hue = adjustOutgoingHue(value.hue) // Needed because color picker always sends 100 - adjusted.level = null - setColor(adjusted) - } else { - log.warn "Invalid color input $value" + adjusted.level = null + setColor(adjusted) } } -void setColorTemperature(value) { - if (value) { - log.trace "setColorTemperature: ${value}k" - log.trace parent.setColorTemperature(this, value) - } else { - log.warn "Invalid color temperature $value" - } -} - -void refresh() { - log.debug "Executing 'refresh'" - parent?.manualRefresh() -} - -def verifyPercent(percent) { - if (percent == null) - return false - else if (percent >= 0 && percent <= 100) { - return true - } else { - log.warn "$percent is not 0-100" - return false - } +def refresh() { + log.debug "Executing 'refresh'" + parent.manualRefresh() } +def adjustOutgoingHue(percent) { + def adjusted = percent + if (percent > 31) { + if (percent < 63.0) { + adjusted = percent + (7 * (percent -30 ) / 32) + } + else if (percent < 73.0) { + adjusted = 69 + (5 * (percent - 62) / 10) + } + else { + adjusted = percent + (2 * (100 - percent) / 28) + } + } + log.info "percent: $percent, adjusted: $adjusted" + adjusted +} \ No newline at end of file diff --git a/devicetypes/smartthings/hue-lux-bulb.src/hue-lux-bulb.groovy b/devicetypes/smartthings/hue-lux-bulb.src/hue-lux-bulb.groovy index f646480212d..b50d33ca380 100644 --- a/devicetypes/smartthings/hue-lux-bulb.src/hue-lux-bulb.groovy +++ b/devicetypes/smartthings/hue-lux-bulb.src/hue-lux-bulb.groovy @@ -1,10 +1,6 @@ -//DEPRECATED. INTEGRATION MOVED TO SUPER LAN CONNECT - /** * Hue Lux Bulb * - * Philips Hue Type "Dimmable Light" - * * Author: SmartThings */ // for the UI @@ -16,53 +12,31 @@ metadata { capability "Switch" capability "Refresh" capability "Sensor" - capability "Health Check" - capability "Light" - - command "refresh" + + command "refresh" } simulator { // TODO: define status and reply messages here } - tiles(scale: 2) { - multiAttributeTile(name:"rich-control", type: "lighting", canChangeIcon: true){ - tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { - attributeState "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#00A0DC", nextState:"turningOff" - attributeState "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" - attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#00A0DC", nextState:"turningOff" - attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" - } - tileAttribute ("device.level", key: "SLIDER_CONTROL") { - attributeState "level", action:"switch level.setLevel", range:"(0..100)" - } - } - - controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 2, inactiveLabel: false, range:"(0..100)") { - state "level", action:"switch level.setLevel" - } - - standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { - state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" - } - - main(["rich-control"]) - details(["rich-control", "refresh"]) - } -} - -def initialize() { - sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\", \"hubHardwareId\": \"${device.hub.hardwareID}\"}", displayed: false) -} + standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) { + state "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821" + state "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff" + } + standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 2, inactiveLabel: false, range:"(0..100)") { + state "level", action:"switch level.setLevel" + } + valueTile("level", "device.level", inactiveLabel: false, decoration: "flat") { + state "level", label: 'Level ${currentValue}%' + } -void installed() { - log.debug "installed()" - initialize() -} + main(["switch"]) + details(["switch", "levelSliderControl", "refresh"]) -def updated() { - initialize() } // parse events into attributes @@ -83,25 +57,23 @@ def parse(description) { } // handle commands -void on() { - log.trace parent.on(this) +def on() { + parent.on(this) + sendEvent(name: "switch", value: "on") } -void off() { - log.trace parent.off(this) +def off() { + parent.off(this) + sendEvent(name: "switch", value: "off") } -void setLevel(percent, rate = null) { +def setLevel(percent) { log.debug "Executing 'setLevel'" - if (percent != null && percent >= 0 && percent <= 100) { - parent.setLevel(this, percent) - } else { - log.warn "$percent is not 0-100" - } + parent.setLevel(this, percent) + sendEvent(name: "level", value: percent) } -void refresh() { +def refresh() { log.debug "Executing 'refresh'" parent.manualRefresh() -} - +} \ No newline at end of file diff --git a/devicetypes/usirsiwal/Huawei b/devicetypes/usirsiwal/Huawei new file mode 100644 index 00000000000..621b293e4ae --- /dev/null +++ b/devicetypes/usirsiwal/Huawei @@ -0,0 +1,214 @@ +''' +Before running this source file make sure you follow the instructions +from https://forum.huawei.com/enterprise/en/communicate-with-fusionsolar-through-an-openapi-account/thread/591478-100027?page=3 + +You can't access Fusion Solar devices without an OpenAPI account previously created. +''' + +import requests +import json +import sys + +''' +OpenAPI URLs +@login_url : Login url for POST method. +@logout_url : Logout url for POST method. +@get_station_list_url : Get stations list url for POST method. +@real_time_data_url : Power station info url for POST method. +''' +login_url = 'https://eu5.fusionsolar.huawei.com/thirdData/login' +logout_url = 'https://eu5.fusionsolar.huawei.com/thirdData/logout' +get_station_list_url = 'https://eu5.fusionsolar.huawei.com/thirdData/getStationList' +real_time_data_url = 'https://eu5.fusionsolar.huawei.com/thirdData/getStationRealKpi' + + +''' +OpenAPI variables. + +@username : OpenAPI username. +@password : OpenAPI password. +@xsrf_token : Session token returned by login method. +@plant_name : Plant name to be interrogated. +@station_code : Station code returned by get statil list method +''' +username = '' +password = '' +xsrf_token = '' +plant_name = '' +station_code = '' + +def read_credentials(): + """ + Function to read OpenAPI credentials (username/password). + """ + global username + global password + + print ("Enter OpenAPI Credentials") + username = input("Username: ") + password = input("Password: ") + + +def openapi_login(): + """ + Perform login to OpenAPI account. + + Requires username and passowrd and return session token in response cookie. + """ + + global xsrf_token + login_obj = { + "userName" : username, + "systemCode" : password + } + + # Send login request + response = requests.post( + login_url, + json = login_obj, + cookies = {"web-auth" : "true", "Cookie_1" : "value"}, + timeout = 3600 + ) + + # Inspect login response + try: + json_status = json.loads(response.content) + if json_status['success'] == False: + print ("ERROR: Login Failed") + sys.exit() + + print ("INFO: Login Successfully") + + except ValueError: + print ("ERROR: Login unexpected response from server") + + # Get session cookie (xsrf-token) + cookies_dict = response.cookies.get_dict() + if "XSRF-TOKEN" not in cookies_dict: + print ("ERROR: XSRF-TOKEN not found in cookies") + sys.exit() + + xsrf_token = cookies_dict.get("XSRF-TOKEN") + print ("XSRF-TOKEN: %s" % xsrf_token) + +def openapi_get_station_list(): + """ + Read station list for current user. + + Require session token. + """ + global station_code + global plant_name + plant_obj = {} + + # Send get station list request + response = requests.post( + get_station_list_url, + json = plant_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token}, + timeout = 3600 + ) + + # Inspect response + try: + json_plant = json.loads(response.content) + if json_plant['success'] == False: + print ("ERROR: Get Station List Failed") + openapi_logout() + sys.exit() + + except ValueError: + openapi_logout() + print ("ERROR: Get station list unexpected response from server") + sys.exit() + + # read plant name + plant_name = input("Enter plant name: ") + + print ("INFO: Stations list:") + # plant name lookup inside plants list + for station in json_plant['data']: + if "stationName" not in station: + print ("ERROR: Unknown format in get station list response") + openapi_logout() + sys.exit() + + print ("INFO: Station name : %s; Station code : %s" % (station.get('stationName'), station.get('stationCode'))) + if station.get('stationName') == plant_name: + station_code = station.get('stationCode') + + if station_code == '': + print ("ERROR: Plant name %s not found in station list" % plant_name) + + +# OpenAPI Read Station Real TimeData +def openapi_real_time_data(): + rtime_obj = { "stationCodes" : station_code } + + # Send real time data request + response = requests.post( + real_time_data_url, + json = rtime_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token}, + timeout = 3600 + ) + + # Evaluate real time response + try: + json_rtime = json.loads(response.content) + if json_rtime['success'] == False: + print ("ERROR: Real Time Information Failed") + openapi_logout() + sys.exit() + + except ValueError: + openapi_logout() + print ("ERROR: Plant list unexpected response from server") + + + print ("INFO: Real time data for %s station:" % plant_name) + # Print values + for data_obj in json_rtime['data']: + map_obj = data_obj.get('dataItemMap') + print ("Day power : %s" % map_obj.get('day_power')) + print ("Month power : %s" % map_obj.get('month_power')) + print ("Total power : %s" % map_obj.get('total_power')) + print ("Health State : %s" % map_obj.get('real_health_state')) + + +def openapi_logout(): + """ + Perform logout to OpenAPI account. + + Requires session token. + """ + logout_obj = { "xsrfToken" : xsrf_token } + + # Send logout request + response = requests.post( + logout_url, + json = logout_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token} + ) + + # Inspect logout response + try: + json_status = json.loads(response.content) + if json_status['success'] == False: + print ("ERROR: Logout Failed") + sys.exit() + + print ("INFO: Logout Successfully") + except ValueError: + print ("ERROR: Logout unexpected response from server") + + +if __name__ == "__main__": + read_credentials() + openapi_login() + openapi_get_station_list() + openapi_real_time_data() + openapi_logout() diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1-grid.src/enlighten-solar-system-1-grid.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1-grid.src/enlighten-solar-system-1-grid.groovy new file mode 100644 index 00000000000..6b5406530eb --- /dev/null +++ b/devicetypes/usirsiwal/enlighten-solar-system-1-grid.src/enlighten-solar-system-1-grid.groovy @@ -0,0 +1,213 @@ +/** + * Enlighten Solar System + * + * Copyright 2015 Umesh Sirsiwal with contribution from Ronald Gouldner + * + * 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. + * + */ + + +metadata { + definition (name: "Enlighten Solar System 1 grid", namespace: "usirsiwal", author: "Umesh Sirsiwal") { + capability "Power Meter" + capability "Refresh" + capability "Energy Meter" + capability "Polling" + + + attribute "energy_today", "STRING" + attribute "energy_life", "STRING" + + } + + simulator { + // TODO: define status and reply messages here + } + + tiles { + valueTile("energy", "device.energy", width: 1, height: 1, canChangeIcon: true) { + state("energy_today", label: '${currentValue}KWh', unit:"KWh", + //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", + backgroundColors: [ + [value: 2, color: "#bc2323"], + [value: 5, color: "#d04e00"], + [value: 10, color: "#f1d801"], + [value: 20, color: "#90d2a7"], + [value: 30, color: "#44b621"], + [value: 40, color: "#1e9cbb"], + [value: 50, color: "#153591"], + ], + ) + } + valueTile("energy_life", "device.energy_life", width: 1, height: 1, canChangeIcon: true) { + state("energy_life", label: '${currentValue}MWh', unit:"MWh", backgroundColors: [ + [value: 2, color: "#bc2323"], + [value: 5, color: "#d04e00"], + [value: 10, color: "#f1d801"], + [value: 20, color: "#90d2a7"], + [value: 30, color: "#44b621"], + [value: 40, color: "#1e9cbb"], + [value: 50, color: "#153591"], + ] + ) + } + valueTile("power", "device.power", width: 1, height: 1) { + state("power", label: '${currentValue}W', unit:"W", + //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", + backgroundColors: [ + [value: 600, color: "#bc2323"], + [value: 1200, color: "#d04e00"], + [value: 1800, color: "#1e9cbb"], + [value: 2900, color: "#153591"] + ], + ) + } + + chartTile(name: "powerChart", attribute: "power") + + standardTile("refresh", "device.energy", inactiveLabel: false, decoration: "flat") { + state "default", action:"polling.poll", icon:"st.secondary.refresh" + } + + main (["power"]) + details(["power", "energy", "energy_life", "refresh"]) + } + +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + +} +def installed() { + + log.debug "Installing Solaredge Monitoring..." + + refresh() + +} + +def updated() { + + log.debug "Executing 'updated'" + + unschedule() + + runEvery15Minutes(refresh) + + runIn(2, refresh) + +} + +def poll() { + refresh() +} + +def refresh() { + log.debug "Executing 'refresh'" + energyRefresh() +} + + +def energyRefresh() { + log.debug "Executing 'energyToday'" + + def cmd = "https://www.eu.solaxcloud.com:9443/proxy/api/getRealtimeInfo.do?tokenId=20210319151048132313611&sn=SWFJWDMRHU"; + log.debug "Sending request cmd[${cmd}]" + + httpGet(cmd) {resp -> + if (resp.data) { + log.debug "${resp.data}" + def energy = resp.data.result.consumeenergy + def energyLife = resp.data.result.yieldtotal + def currentPower = resp.data.result.feedinpower + def systemSize = resp.data.size_w + def systemId = resp.data.system_id + def now=new Date() + def tz = location.timeZone + def todayDay = now.format("dd",tz) + def today_max_day = device.currentValue("today_max_day") + def today_max_prod = device.currentValue("today_max_prod") + def todayMaxProd=today_max_prod + log.debug "todayMaxProd was ${todayMaxProd}" + + + log.debug "System Id ${system_id}" + log.debug "Energy today ${energy}" + log.debug "Energy life ${energyLife}" + log.debug "Current Power Level ${currentPower}" + log.debug "System Size ${systemSize}" + log.debug "Production Level ${currentPower}" + log.debug "todayDay ${todayDay}" + + // If day has changed set today_max_day to new value + if (today_max_day == null || today_max_day != todayDay) { + log.debug "Setting today_max_day=${todayDay}" + sendEvent(name: 'today_max_day', value: (todayDay)) + // New day reset todayMaxProd + todayMaxProd = productionLevel + } + + // String.format("%5.2f", energyToday) + delayBetween([sendEvent(name: 'energy', value: (energy)) + ,sendEvent(name: 'energy_life', value: (energyLife)) + ,sendEvent(name: 'power', value: (currentPower)) + ,sendEvent(name: 'production_level', value: (String.format("%5.2f",productionLevel))) + ,sendEvent(name: 'today_max_prod', value: (todayMaxProd)) + ,sendEvent(name: 'today_max_prod_str', value: (String.format("%5.2f",todayMaxProd))) + ,sendEvent(name: 'reported_id', value: (systemId)) + ]) + + + + +} + + + } +} + +def getVisualizationData(attribute) { + log.debug "getChartData for $attribute" + def keyBase = "measure.${attribute}" + log.debug "getChartData state = $state" + + def dateBuckets = state[keyBase] + + //convert to the right format + def results = dateBuckets?.sort{it.key}.collect {[ + date: Date.parse("yyyy-MM-dd", it.key), + average: it.value.average, + min: it.value.min, + max: it.value.max + ]} + + log.debug "getChartData results = $results" + results +} + +private getKeyFromDate(date = new Date()){ + date.format("yyyy-MM-dd") +} + +private storeData(attribute, value) { + log.debug "storeData initial state: $state" + def keyBase = "measure.${attribute} ${value}" + + // create bucket if it doesn't exist + if(!state[keyBase]) { + state[keyBase] = [:] + log.debug "storeData - attribute not found. New state: $state" + } + + log.debug "storeData after min/max calculations. New state: $state" +} \ No newline at end of file diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy new file mode 100644 index 00000000000..69e42740406 --- /dev/null +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -0,0 +1,213 @@ +/** + * Enlighten Solar System + * + * Copyright 2015 Umesh Sirsiwal with contribution from Ronald Gouldner + * + * 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. + * + */ + + +metadata { + definition (name: "Enlighten Solar System 1", namespace: "usirsiwal", author: "Umesh Sirsiwal", ocfDeviceType: "x.com.st.d.energymeter") { + capability "Power Meter" + capability "Refresh" + capability "Energy Meter" + capability "Polling" + + + attribute "energy_today", "STRING" + attribute "energy_life", "STRING" + + } + + simulator { + // TODO: define status and reply messages here + } + + tiles { + valueTile("energy", "device.energy", width: 1, height: 1, canChangeIcon: true) { + state("energy_today", label: '${currentValue}KWh', unit:"KWh", + //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", + backgroundColors: [ + [value: 2, color: "#bc2323"], + [value: 5, color: "#d04e00"], + [value: 10, color: "#f1d801"], + [value: 20, color: "#90d2a7"], + [value: 30, color: "#44b621"], + [value: 40, color: "#1e9cbb"], + [value: 50, color: "#153591"], + ], + ) + } + valueTile("energy_life", "device.energy_life", width: 1, height: 1, canChangeIcon: true) { + state("energy_life", label: '${currentValue}MWh', unit:"MWh", backgroundColors: [ + [value: 2, color: "#bc2323"], + [value: 5, color: "#d04e00"], + [value: 10, color: "#f1d801"], + [value: 20, color: "#90d2a7"], + [value: 30, color: "#44b621"], + [value: 40, color: "#1e9cbb"], + [value: 50, color: "#153591"], + ] + ) + } + valueTile("power", "device.power", width: 1, height: 1) { + state("power", label: '${currentValue}W', unit:"W", + //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", + backgroundColors: [ + [value: 600, color: "#bc2323"], + [value: 1200, color: "#d04e00"], + [value: 1800, color: "#1e9cbb"], + [value: 2900, color: "#153591"] + ], + ) + } + + chartTile(name: "powerChart", attribute: "power") + + standardTile("refresh", "device.energy", inactiveLabel: false, decoration: "flat") { + state "default", action:"polling.poll", icon:"st.secondary.refresh" + } + + main (["power"]) + details(["power", "energy", "energy_life", "refresh"]) + } + +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + +} +def installed() { + + log.debug "Installing Solaredge Monitoring..." + + refresh() + +} + +def updated() { + + log.debug "Executing 'updated'" + + unschedule() + + runEvery15Minutes(refresh) + + runIn(2, refresh) + +} + +def poll() { + refresh() +} + +def refresh() { + log.debug "Executing 'refresh'" + energyRefresh() +} + + +def energyRefresh() { + log.debug "Executing 'energyToday'" + + def cmd = "https://region03eu5.fusionsolar.huawei.com/pvmswebsite/nologin/assets/build/index.html#/kiosk?kk=Qz3HaPPObuzs49yHcvvjhBzBw6PK0ayD"; + log.debug "Sending request cmd[${cmd}]" + + httpGet(cmd) {resp -> + if (resp.data) { + log.debug "${resp.data}" + def energy = resp.data.result.yieldtoday + def energyLife = resp.data.result.yieldtotal + def currentPower = resp.data.result.realTimePower + def systemSize = resp.data.size_w + def systemId = resp.data.system_id + def now=new Date() + def tz = location.timeZone + def todayDay = now.format("dd",tz) + def today_max_day = device.currentValue("today_max_day") + def today_max_prod = device.currentValue("today_max_prod") + def todayMaxProd=today_max_prod + log.debug "todayMaxProd was ${todayMaxProd}" + + + log.debug "System Id ${system_id}" + log.debug "Energy today ${energy}" + log.debug "Energy life ${energyLife}" + log.debug "Current Power Level ${curPower}" + log.debug "System Size ${systemSize}" + log.debug "Production Level ${power}" + log.debug "todayDay ${todayDay}" + + // If day has changed set today_max_day to new value + if (today_max_day == null || today_max_day != todayDay) { + log.debug "Setting today_max_day=${todayDay}" + sendEvent(name: 'today_max_day', value: (todayDay)) + // New day reset todayMaxProd + todayMaxProd = productionLevel + } + + // String.format("%5.2f", energyToday) + delayBetween([sendEvent(name: 'energy', value: (energy)) + ,sendEvent(name: 'energy_life', value: (energyLife)) + ,sendEvent(name: 'power', value: (realTimePower)) + ,sendEvent(name: 'production_level', value: (String.format("%5.2f",productionLevel))) + ,sendEvent(name: 'today_max_prod', value: (todayMaxProd)) + ,sendEvent(name: 'today_max_prod_str', value: (String.format("%5.2f",todayMaxProd))) + ,sendEvent(name: 'reported_id', value: (systemId)) + ]) + + + + +} + + + } +} + +def getVisualizationData(attribute) { + log.debug "getChartData for $attribute" + def keyBase = "measure.${attribute}" + log.debug "getChartData state = $state" + + def dateBuckets = state[keyBase] + + //convert to the right format + def results = dateBuckets?.sort{it.key}.collect {[ + date: Date.parse("yyyy-MM-dd", it.key), + average: it.value.average, + min: it.value.min, + max: it.value.max + ]} + + log.debug "getChartData results = $results" + results +} + +private getKeyFromDate(date = new Date()){ + date.format("yyyy-MM-dd") +} + +private storeData(attribute, value) { + log.debug "storeData initial state: $state" + def keyBase = "measure.${attribute} ${value}" + + // create bucket if it doesn't exist + if(!state[keyBase]) { + state[keyBase] = [:] + log.debug "storeData - attribute not found. New state: $state" + } + + log.debug "storeData after min/max calculations. New state: $state" +} diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.groovy new file mode 100644 index 00000000000..83173eb49c5 Binary files /dev/null and b/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.groovy differ diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.tar.gz b/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.tar.gz new file mode 100644 index 00000000000..83173eb49c5 Binary files /dev/null and b/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.tar.gz differ diff --git a/smartapps/andremain/evohome-connect.src/evohome-connect.groovy b/smartapps/andremain/evohome-connect.src/evohome-connect.groovy new file mode 100644 index 00000000000..f00472dbc11 --- /dev/null +++ b/smartapps/andremain/evohome-connect.src/evohome-connect.groovy @@ -0,0 +1,1382 @@ +/** + * Copyright 2020 Andreas Christodoulou (Andremain) + * + * Name: Evohome (Connect) + * + * Author: Andreas Christodoulou (Andremain) + * + * Date: 18/12/2020 + * + * Version: 2.1 + * + * Description: + * - Connect your Honeywell Evohome System to SmartThings. + * - Requires the Evohome Heating Zone device handler. + * - For latest documentation see: https://github.com/andremain/EvohomeSmartthingsNew + + * Version History: + * + * 2020-12-22: v2.1 Removed Depricated set temperature option at the automation actions + * + * 2020-12-22: v2.0 First release of the new integration. + * + * 2020-12-20: v0.21 Removed the cooling option when creating an automation in the IF statement (DO BE DONE!!!) + * + * 2020-12-18: v0.20 Changed the links for the documentation and images to work. + * + * 2020-12-16: v0.19 Changed the owner of the smartapp and dht to Andremain (mine) + * + * 2020-12-14: v0.18 Managed to get all the modes to work with the new app's presentation + * + * 2020-12-13: v0.17 Managed to get the Auto, away, custom and off modes to work with the new app's presentation. Some list items appear wierd but this is a Smartthings issue + * + * 2020-12-11: v0.16 Managed to get the correct thermostat modes to show app in the modes list in the new app presentation + * + * 2020-12-9: v0.15 Changed the default value for window function temperature from 5.0 to 5 as it would throw a bad request error + * + * 2020-12-8: v0.14 Added an initialize function for the new thermostat capabilities + * + * 2020-12-6: v0.13 Removed the old custom and depricated capabilited from the old code + * + * 2020-12-3: v0.12 Generated the Presentation file required by the new smartthings app for correctly displaying the options for the inside the new app + * + * 2020-11-30: v0.11 Fixed the Checking Status on the dashboard tile for use with the new Smartthings app + * + * 2020-11-28: v0.10 Added The new capababilities for the depricated thermostat capability used by the new smartthings app + * + * 2020-11-02: v0.09 Fixed the API endpoint to be the new one that Honeywell Evohome uses. + * + * 2016-04-05: v0.08 + * - New 'Update Refresh Time' setting to control polling after making an update. + * - poll() - If onlyZoneId is 0, this will force a status update for all zones. + * + * 2016-04-04: v0.07 + * - Additional info log messages. + * + * 2016-04-03: v0.06 + * - Initial Beta Release + * + * License: + * 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. + * + */ +definition( + name: "Evohome (Connect)", + namespace: "Andremain", + author: "Andreas Christodoulou", + description: "Connect your Honeywell Evohome System to SmartThings.", + category: "My Apps", + iconUrl: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX2Url: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX3Url: "https://i.ibb.co/SwyYJWb/Evohome.png", + singleInstance: true +) + +preferences { + + section ("Evohome:") { + input "prefEvohomeUsername", "text", title: "Username", required: true, displayDuringSetup: true + input "prefEvohomePassword", "password", title: "Password", required: true, displayDuringSetup: true + input title: "Advanced Settings:", displayDuringSetup: true, type: "paragraph", element: "paragraph", description: "Change these only if needed" + input "prefEvohomeStatusPollInterval", "number", title: "Polling Interval (minutes)", range: "1..60", defaultValue: 5, required: true, displayDuringSetup: true, description: "Poll Evohome every n minutes" + input "prefEvohomeUpdateRefreshTime", "number", title: "Update Refresh Time (seconds)", range: "2..60", defaultValue: 3, required: true, displayDuringSetup: true, description: "Wait n seconds after an update before polling" + input "prefEvohomeWindowFuncTemp", "decimal", title: "Window Function Temperature", range: "0..100", defaultValue: 5, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days' + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours' + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + **/ +def installed() { + + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" + +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + * + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + * + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://mytotalconnectcomfort.com/WebApi' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling. + + + // Thermostat Mode Durations: + atomicState.thermostatModeDuration = settings.prefThermostatModeDuration + atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration + + // Force Authentication: + authenticate() + + // Refresh Subscriptions and Schedules: + manageSubscriptions() + manageSchedules() + + // Refresh child device configuration: + getEvohomeConfig() + updateChildDeviceConfig() + + // Run a poll, but defer it so that updated() returns sooner: + runIn(5, "poll") + +} + + +/********************************************************************** + * Management Commands: + **********************************************************************/ + +/** + * manageSchedules() + * + * Check scheduled tasks have not stalled, and re-schedule if necessary. + * Generates a random offset (seconds) for each scheduled task. + * + * Schedules: + * - manageAuth() - every 5 mins. + * - poll() - every minute. + * + **/ +void manageSchedules() { + + if (atomicState.debug) log.debug "${app.label}: manageSchedules()" + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // manageAuth (every 5 mins): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()" + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/5 * * * ?", "manageAuth") + } + + // poll(): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()" + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/1 * * * ?", "poll") + } + +} + + +/** + * manageSubscriptions() + * + * Unsubscribe/Subscribe. + **/ +void manageSubscriptions() { + + if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()" + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + +} + + +/** + * manageAuth() + * + * Ensures authenication token is valid. + * Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold. + * Re-authenticates if Auth Token has expired completely. + * Otherwise, done nothing. + * + * Should be scheduled to run every 1-5 minutes. + **/ +void manageAuth() { + + if (atomicState.debug) log.debug "${app.label}: manageAuth()" + + // Check if Auth Token is valid, if not authenticate: + if (!atomicState.evohomeAuth.authToken) { + + log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..." + authenticate() + } + else if (atomicState.evohomeAuthFailed) { + + log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..." + authenticate() + } + else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) { + + log.info "${app.label}: manageAuth(): Auth Token has expired. Authenticating..." + authenticate() + } + else { + // Check if Auth Token should be refreshed: + def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100)) + + if (now() >= refreshAt) { + log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires." + refreshAuthToken() + } + else { + log.info "${app.label}: manageAuth(): Auth Token is okay." + } + } + +} + + +/** + * poll(onlyZoneId=-1) + * + * This is the main command that co-ordinates retrieval of information from the Evohome API + * and its dissemination to child devices. It should be scheduled to run every minute. + * + * Different types of information are collected on different schedules: + * - Zone status information is polled according to ${evohomeStatusPollInterval}. + * - Zone schedules are polled according to ${evohomeSchedulePollInterval}. + * + * poll() can be called by a child device when an update has been made, in which case + * onlyZoneId will be specified, and only that zone will be updated. + * + * If onlyZoneId is 0, this will force a status update for all zones, igonoring the poll + * interval. This should only be used after setThremostatMode() call. + * + * If onlyZoneId is not specified all zones are updated, but only if the relevent poll + * interval has been exceeded. + * + **/ +void poll(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})" + + // Check if there's been an authentication failure: + if (atomicState.evohomeAuthFailed) { + manageAuth() + } + + if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update): + getEvohomeStatus() + updateChildDevice() + } + else if (onlyZoneId != -1) { // A zoneId has been specified, so just get the status and update the relevent device: + getEvohomeStatus(onlyZoneId) + updateChildDevice(onlyZoneId) + } + else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded: + + // Adjust intervals to allow for poll() execution time: + def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30 + def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30 + + // Get zone status: + if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) { + getEvohomeStatus() + } + + // Get zone schedules: + if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) { + getEvohomeSchedules() + } + + // Update all child devices: + updateChildDevice() + } + +} + + +/********************************************************************** + * Event Handlers: + **********************************************************************/ + + +/** + * handleAppTouch(evt) + * + * App touch event handler. + * Used for testing and debugging. + * + **/ +void handleAppTouch(evt) { + + if (atomicState.debug) log.debug "${app.label}: handleAppTouch()" + + //manageAuth() + //manageSchedules() + + //getEvohomeConfig() + //updateChildDeviceConfig() + + poll() + +} + + +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * updateChildDeviceConfig() + * + * Add/Remove/Update Child Devices based on atomicState.evohomeConfig + * and update their internal state. + * + **/ +void updateChildDeviceConfig() { + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()" + + // Build list of active DNIs, any existing children with DNIs not in here will be deleted. + def activeDnis = [] + + // Iterate through evohomeConfig, adding new Evohome Heating Zone devices where necessary. + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating R1.1", dni, null, values) //Chnage the name here to change the name of the device + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + } + } + } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}" + + // Delete Devices: + def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete." + + delete.each { + log.info "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}" + try { + deleteChildDevice(it.deviceNetworkId) + } + catch(e) { + log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}" + } + } +} + + + +/** + * updateChildDevice(onlyZoneId=-1) + * + * Update the attributes of a child device from atomicState.evohomeStatus + * and atomicState.evohomeSchedules. + * + * If onlyZoneId is not specified, then all zones are updated. + * + * Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime. + * + **/ +void updateChildDevice(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})" + + atomicState.evohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == -1 || onlyZoneId == zone.zoneId) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId) + def d = getChildDevice(dni) + if(d) { + def schedule = atomicState.evohomeSchedules.find { it.dni == dni} + def currSw = getCurrentSwitchpoint(schedule.schedule) + def nextSw = getNextSwitchpoint(schedule.schedule) + + def values = [ + 'temperature': formatTemperature(zone?.temperatureStatus?.temperature), + //'isTemperatureAvailable': zone?.temperatureStatus?.isAvailable, + 'heatingSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpointMode': formatSetpointMode(zone?.heatSetpointStatus?.setpointMode), + 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'scheduledSetpoint': formatTemperature(currSw.temperature), + 'nextScheduledSetpoint': formatTemperature(nextSw.temperature), + 'nextScheduledTime': nextSw.time + ] + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}" + d.generateEvent(values) + } else { + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist, so skipping status update." + } + } + } + } + } + } +} + + +/********************************************************************** + * Evohome API Commands: + **********************************************************************/ + +/** + * authenticate() + * + * Authenticate to Evohome. + * + **/ +private authenticate() { + + if (atomicState.debug) log.debug "${app.label}: authenticate()" + + def requestParams = [ + method: 'POST', + uri: 'https://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'Username': settings.prefEvohomeUsername, + 'Password': settings.prefEvohomePassword + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: authenticate(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}" + atomicState.evohomeAuthFailed = true + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}" + atomicState.evohomeAuthFailed = true + } + +} + + +/** + * refreshAuthToken() + * + * Refresh Auth Token. + * If token refresh fails, then authenticate() is called. + * Request is simlar to authenticate, but with grant_type = 'refresh_token' and 'refresh_token'. + * + **/ +private refreshAuthToken() { + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()" + + def requestParams = [ + method: 'POST', + uri: 'https://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'refresh_token': atomicState.evohomeAuth.refreshToken + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}" + // If Unauthorized (401) then re-authenticate: + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + authenticate() + } + } + +} + + +/** + * getEvohomeUserAccount() + * + * Gets user account info and stores in atomicState.evohomeUserAccount. + * + **/ +private getEvohomeUserAccount() { + + log.info "${app.label}: getEvohomeUserAccount(): Getting user account information." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/userAccount', + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + atomicState.evohomeUserAccount = resp.data + if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Data: ${atomicState.evohomeUserAccount}" + } + else { + log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeUserAccount(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + } +} + + + +/** + * getEvohomeConfig() + * + * Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig. + * + **/ +private getEvohomeConfig() { + + log.info "${app.label}: getEvohomeConfig(): Getting configuration for all locations." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/location/installationInfo', + query: [ + 'userId': atomicState.evohomeUserAccount.userId, + 'includeTemperatureControlSystems': 'True' + ], + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Data: ${resp.data}" + atomicState.evohomeConfig = resp.data + atomicState.evohomeConfigUpdatedAt = now() + return null + } + else { + log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeConfig(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * getEvohomeStatus(onlyZoneId=-1) + * + * Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus. + * If onlyZoneId is not specified, all zones are updated. + * + **/ +private getEvohomeStatus(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})" + + def newEvohomeStatus = [] + + if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location): + + log.info "${app.label}: getEvohomeStatus(): Getting status for all zones." + + atomicState.evohomeConfig.each { loc -> + def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId) + if (locStatus) { + newEvohomeStatus << locStatus + } + } + + if (newEvohomeStatus) { + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + atomicState.evohomeStatusUpdatedAt = now() + } + } + else { // Only update the specified zone: + + log.info "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}" + + def newZoneStatus = getEvohomeZoneStatus(onlyZoneId) + if (newZoneStatus) { + // Get existing evohomeStatus and update only the specified zone, preserving data for other zones: + // Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate). + // If mutiple zones are requesting updates at the same time this could cause loss of new data, but + // the worse case is having out-of-date data for a few minutes... + newEvohomeStatus = atomicState.evohomeStatus + newEvohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == zone.zoneId) { // This is the zone that must be updated: + zone.activeFaults = newZoneStatus.activeFaults + zone.heatSetpointStatus = newZoneStatus.heatSetpointStatus + zone.temperatureStatus = newZoneStatus.temperatureStatus + } + } + } + } + } + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + // Note: atomicState.evohomeStatusUpdatedAt is NOT updated. + } + } +} + + +/** + * getEvohomeLocationStatus(locationId) + * + * Gets the status for a specific location and returns data as a map. + * + * Called by getEvohomeStatus(). + **/ +private getEvohomeLocationStatus(locationId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/location/${locationId}/status", + 'query': [ 'includeTemperatureControlSystems': 'True'], + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeLocationStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeLocationStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeZoneStatus(zoneId) + * + * Gets the status for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneStatus(zoneId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeSchedules() + * + * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules. + * + **/ +private getEvohomeSchedules() { + + log.info "${app.label}: getEvohomeSchedules(): Getting schedules for all zones." + + def evohomeSchedules = [] + + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + def schedule = getEvohomeZoneSchedule(zone.zoneId) + if (schedule) { + evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule] + } + } + } + } + } + + if (evohomeSchedules) { + // Write out complete schedules to state: + atomicState.evohomeSchedules = evohomeSchedules + atomicState.evohomeSchedulesUpdatedAt = now() + } + + return evohomeSchedules +} + + +/** + * getEvohomeZoneSchedule(zoneId) + * + * Gets the schedule for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * setThermostatMode(systemId, mode, until) + * + * Set thermostat mode for specified controller, until specified time. + * + * systemId: SystemId of temperatureControlSystem. E.g.: 123456 + * + * mode: String. Either: "auto", "off", "economy", "away", "dayOff", "custom". + * + * until: (Optional) Time to apply mode until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * - Number: Duration in hours if mode is 'economy', or in days if mode is 'away'/'dayOff'/'custom'. + * Duration will be rounded down to align with Midnight in the local timezone + * (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent. + * If 'until' is not specified, a default value is used from the SmartApp settings. + * + * Notes: 'Auto' and 'Off' modes are always permanent. + * Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller). + * Therefore changing the thermostatMode will affect all zones associated with the same controller. + * + * + * Example usage: + * setThermostatMode(123456, 'auto') // Set auto mode permanently, for controller 123456. + * setThermostatMode(123456, 'away','2016-04-01T00:00:00Z') // Set away mode until 1st April, for controller 123456. + * setThermostatMode(123456, 'dayOff','permanent') // Set dayOff mode permanently, for controller 123456. + * setThermostatMode(123456, 'dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night), for controller 123456. + * setThermostatMode(123456, 'economy', 2) // Set economy mode for 2 hours, for controller 123456. + * + **/ +def setThermostatMode(systemId, mode, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}" + + // Clean mode (translate to index): + mode = mode.toLowerCase() + int modeIndex + switch (mode) { + case 'auto': + modeIndex = 0 + break + case 'off': + modeIndex = 1 + break + case 'eco': + modeIndex = 2 + break + case 'away': + modeIndex = 3 + break + case 'dayoff': + modeIndex = 4 + break + case 'custom': + modeIndex = 6 + break + default: + log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!" + modeIndex = 999 + break + } + + // Clean until: + def untilRes + + // until has not been specified, so determine behaviour from settings: + if (-1 == until && 'economy' == mode) { + until = atomicState.thermostatEconomyDuration ?: 0 // Use Default duration for economy mode (hours): + } + else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) { + until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days): + } + + // Convert to date (or 0): + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && 'economy' == mode) { // until is a duration in hours: + untilRes = new Date( now() + (Math.round(until) * 3600000) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { // until is a duration in days: + untilRes = new Date( now() + (Math.round(until) * 86400000) ).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) // Round down to midnight in the LOCAL timezone. + } + else { + log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently." + untilRes = 0 + } + + // If mode is away/dayOff/custom the date needs to be rounded down to midnight in the local timezone, then converted back to string again: + if (0 != untilRes && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilRes).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) + } + + // Build request: + def body + if (0 == untilRes || 'off' == mode || 'auto' == mode) { // Mode is permanent: + body = ['SystemMode': modeIndex, 'TimeUntil': null, 'Permanent': 'True'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True" + } + else { // Mode is temporary: + body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: False, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setThermostatMode(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setThermostatMode(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + + /** + * setHeatingSetpoint(zoneId, setpoint, until=-1) + * + * Set heatingSetpoint for specified zoneId, until specified time. + * + * zoneId: Zone ID of zone, e.g.: "123456" + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * If not specified, setpoint will be applied permanently. + * + * Example usage: + * setHeatingSetpoint(123456, 21.0) // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, 'permanent') // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, '2016-04-01T00:00:00Z') // Set until specific time, for zone 123456. + * + **/ +def setHeatingSetpoint(zoneId, setpoint, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + + // Clean until: + def untilRes + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + def body + if (0 == untilRes) { // Permanent: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 1, 'TimeUntil': null] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent" + } + else { // Temporary: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearHeatingSetpoint(zoneId) + * + * Clear the heatingSetpoint for specified zoneId. + * zoneId: Zone ID of zone, e.g.: "123456" + **/ +def clearHeatingSetpoint(zoneId) { + + log.info "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': ['HeatSetpointValue': 0.0, 'SetpointMode': 0, 'TimeUntil': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + + /** + * generateDni(locId,gatewayId,systemId,deviceId) + * + * Generate a device Network ID. + * Uses the same format as the official Evohome App, but with a prefix of "Evohome." + **/ +private generateDni(locId,gatewayId,systemId,deviceId) { + return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.') +} + + +/** + * formatTemperature(t) + * + * Format temperature value to one decimal place. + * t: can be string, float, bigdecimal... + * Returns as string. + **/ +private formatTemperature(t) { + return Float.parseFloat("${t}").round(1).toString() +} + + + /** + * formatSetpointMode(mode) + * + * Format Evohome setpointMode values to SmartThings values: + * + **/ +private formatSetpointMode(mode) { + + switch (mode) { + case 'FollowSchedule': + mode = 'followSchedule' + break + case 'PermanentOverride': + mode = 'permanentOverride' + break + case 'TemporaryOverride': + mode = 'temporaryOverride' + break + default: + log.error "${app.label}: formatSetpointMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * formatThermostatMode(mode) + * + * Translate Evohome thermostatMode values to SmartThings values. + * + **/ +private formatThermostatMode(mode) { + + switch (mode) { + case 'Auto': + mode = 'auto' + break + case 'HeatingOff': + mode = 'off' + break + case 'AutoWithEco': + mode = 'eco' + break + case 'Away': + mode = 'away' + break + case 'DayOff': + mode = 'dayoff' + break + case 'Custom': + mode = 'custom' + break + default: + log.error "${app.label}: formatThermostatMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * getCurrentSwitchpoint(schedule) + * + * Returns the current active switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getCurrentSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + ScheduleToday.switchpoints.reverse(true) + def currentSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay < c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!currentSwitchPoint) { + // There are no current switchpoints today, so we must look for the last Switchpoint yesterday. + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): No current switchpoints today, so must look to yesterday's schedule." + c.add(Calendar.DATE, -1 ) // Subtract one DAY. + def ScheduleYesterday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleYesterday.switchpoints.sort {it.timeOfDay} + ScheduleYesterday.switchpoints.reverse(true) + currentSwitchPoint = ScheduleYesterday.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + currentSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + currentSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): Current Switchpoint: ${currentSwitchPoint}" + + return currentSwitchPoint +} + + +/** + * getNextSwitchpoint(schedule) + * + * Returns the next switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getNextSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + def nextSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay > c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!nextSwitchPoint) { + // There are no switchpoints left today, so we must look for the first Switchpoint tomorrow. + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): No more switchpoints today, so must look to tomorrow's schedule." + c.add(Calendar.DATE, 1 ) // Add one DAY. + def ScheduleTmrw = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleTmrw.switchpoints.sort {it.timeOfDay} + nextSwitchPoint = ScheduleTmrw.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + nextSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + nextSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): Next Switchpoint: ${nextSwitchPoint}" + + return nextSwitchPoint +} \ No newline at end of file diff --git a/smartapps/andremain/evohome-connect.src/evohome-connect1.groovy b/smartapps/andremain/evohome-connect.src/evohome-connect1.groovy new file mode 100644 index 00000000000..eeb552762fd --- /dev/null +++ b/smartapps/andremain/evohome-connect.src/evohome-connect1.groovy @@ -0,0 +1,1680 @@ +/** + * Copyright 2021 Andreas Christodoulou (Andremain) + * + * Name: Evohome (Connect) + * + * Author: Andreas Christodoulou (Andremain) + * + * Date: 28/01/2021 + * + * Version: 2.3 + * + * Description: + * - Connect your Honeywell Evohome System to SmartThings. + * - Requires the Evohome Heating Zone device handler. + * - For latest documentation see: https://github.com/andremain/EvohomeSmartthingsNew + + * Version History: + * + * 2021-02-16: v3.2 + * Added a clause to exit the loop when checking the integration for hot water, as if there was none, the connection would result in error. + * + * 2020-12-22: v2.1 Removed Deprecated set temperature option at the automation actions + * + * 2020-12-22: v2.0 First release of the new integration. + * + * 2020-12-20: v0.21 Removed the cooling option when creating an automation in the IF statement (DO BE DONE!!!) + * + * 2020-12-18: v0.20 Changed the links for the documentation and images to work. + * + * 2020-12-16: v0.19 Changed the owner of the smartapp and dht to Andremain (mine) + * + * 2020-12-14: v0.18 Managed to get all the modes to work with the new app's presentation + * + * 2020-12-13: v0.17 Managed to get the Auto, away, custom and off modes to work with the new app's presentation. Some list items appear wierd but this is a Smartthings issue + * + * 2020-12-11: v0.16 Managed to get the correct thermostat modes to show app in the modes list in the new app presentation + * + * 2020-12-9: v0.15 Changed the default value for window function temperature from 5.0 to 5 as it would throw a bad request error + * + * 2020-12-8: v0.14 Added an initialize function for the new thermostat capabilities + * + * 2020-12-6: v0.13 Removed the old custom and depricated capabilited from the old code + * + * 2020-12-3: v0.12 Generated the Presentation file required by the new smartthings app for correctly displaying the options for the inside the new app + * + * 2020-11-30: v0.11 Fixed the Checking Status on the dashboard tile for use with the new Smartthings app + * + * 2020-11-28: v0.10 Added The new capababilities for the depricated thermostat capability used by the new smartthings app + * + * 2020-11-02: v0.09 Fixed the API endpoint to be the new one that Honeywell Evohome uses. + * + * 2016-04-19: v0.10 + * - formatTemperature() - Improved error handling. + * - Mid-development of DHW zone support. + * - To Do: Add parsing of DHW schedules. - NEED SAMPLE DATA + * + * 2016-04-17: v0.09 + * - updateChildDevice() - Sends two new attribute values to child devices: + * thermostatModeMode: 'temporary' or 'permanent'. + * thermostatModeUntil: Contains date string if thermostatModeMode is temporary. + * + * 2016-04-05: v0.08 + * - New 'Update Refresh Time' setting to control polling after making an update. + * - poll() - If onlyZoneId is 0, this will force a status update for all zones. + * + * 2016-04-04: v0.07 + * - Additional info log messages. + * + * 2016-04-03: v0.06 + * - Initial Beta Release + * + * License: + * 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. + * + */ +definition( + name: "Evohome (Connect)", + namespace: "worldhouse47531", + author: "Andreas Christodoulou", + description: "Connect your Honeywell Evohome System to SmartThings.", + category: "My Apps", + iconUrl: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX2Url: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX3Url: "https://i.ibb.co/SwyYJWb/Evohome.png", + singleInstance: true +) + +preferences { + + section ("Evohome:") { + input "prefEvohomeUsername", "text", title: "Username", required: true, displayDuringSetup: true + input "prefEvohomePassword", "password", title: "Password", required: true, displayDuringSetup: true + input title: "Advanced Settings:", displayDuringSetup: true, type: "paragraph", element: "paragraph", description: "Change these only if needed" + input "prefEvohomeStatusPollInterval", "number", title: "Polling Interval (minutes)", range: "1..60", defaultValue: 5, required: true, displayDuringSetup: true, description: "Poll Evohome every n minutes" + input "prefEvohomeUpdateRefreshTime", "number", title: "Update Refresh Time (seconds)", range: "2..60", defaultValue: 3, required: true, displayDuringSetup: true, description: "Wait n seconds after an update before polling" + input "prefEvohomeWindowFuncTemp", "decimal", title: "Window Function Temperature", range: "0..100", defaultValue: 5, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input "prefEvohomeDHWTemp", "decimal", title: "Hot Water Target Temperature", range: "0..100", defaultValue: 55, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days' + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours' + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + + + + + + + + + + + + + + + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + **/ +def installed() { + + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" + +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + * + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + * + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://mytotalconnectcomfort.com/WebApi' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling. + + + // Thermostat Mode Durations: + atomicState.thermostatModeDuration = settings.prefThermostatModeDuration + atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration + + // Force Authentication: + authenticate() + + // Refresh Subscriptions and Schedules: + manageSubscriptions() + manageSchedules() + + // Refresh child device configuration: + getEvohomeConfig() + updateChildDeviceConfig() + + // Run a poll, but defer it so that updated() returns sooner: + runIn(5, "poll") + +} + + +/********************************************************************** + * Management Commands: + **********************************************************************/ + +/** + * manageSchedules() + * + * Check scheduled tasks have not stalled, and re-schedule if necessary. + * Generates a random offset (seconds) for each scheduled task. + * + * Schedules: + * - manageAuth() - every 5 mins. + * - poll() - every minute. + * + **/ +void manageSchedules() { + + if (atomicState.debug) log.debug "${app.label}: manageSchedules()" + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // manageAuth (every 5 mins): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()" + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/5 * * * ?", "manageAuth") + } + + // poll(): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()" + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/1 * * * ?", "poll") + } + +} + + +/** + * manageSubscriptions() + * + * Unsubscribe/Subscribe. + **/ +void manageSubscriptions() { + + if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()" + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + +} + + +/** + * manageAuth() + * + * Ensures authenication token is valid. + * Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold. + * Re-authenticates if Auth Token has expired completely. + * Otherwise, done nothing. + * + * Should be scheduled to run every 1-5 minutes. + **/ +void manageAuth() { + + if (atomicState.debug) log.debug "${app.label}: manageAuth()" + + // Check if Auth Token is valid, if not authenticate: + if (!atomicState.evohomeAuth.authToken) { + + log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..." + authenticate() + } + else if (atomicState.evohomeAuthFailed) { + + log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..." + authenticate() + } + else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) { + + log.info "${app.label}: manageAuth(): Auth Token has expired. Authenticating..." + authenticate() + } + else { + // Check if Auth Token should be refreshed: + def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100)) + + if (now() >= refreshAt) { + log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires." + refreshAuthToken() + } + else { + log.info "${app.label}: manageAuth(): Auth Token is okay." + } + } + +} + + +/** + * poll(onlyZoneId=-1) + * + * This is the main command that co-ordinates retrieval of information from the Evohome API + * and its dissemination to child devices. It should be scheduled to run every minute. + * + * Different types of information are collected on different schedules: + * - Zone status information is polled according to ${evohomeStatusPollInterval}. + * - Zone schedules are polled according to ${evohomeSchedulePollInterval}. + * + * poll() can be called by a child device when an update has been made, in which case + * onlyZoneId will be specified, and only that zone will be updated. + * + * If onlyZoneId is 0, this will force a status update for all zones, igonoring the poll + * interval. This should only be used after setThremostatMode() call. + * + * If onlyZoneId is not specified all zones are updated, but only if the relevent poll + * interval has been exceeded. + * + **/ +void poll(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})" + + // Check if there's been an authentication failure: + if (atomicState.evohomeAuthFailed) { + manageAuth() + } + + if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update): + getEvohomeStatus() + updateChildDevice() + } + else if (onlyZoneId != -1) { // A zoneId has been specified, so just get the status and update the relevent device: + getEvohomeStatus(onlyZoneId) + updateChildDevice(onlyZoneId) + } + else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded: + + // Adjust intervals to allow for poll() execution time: + def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30 + def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30 + + // Get zone status: + if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) { + getEvohomeStatus() + } + + // Get zone schedules: + if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) { + getEvohomeSchedules() + } + + // Update all child devices: + updateChildDevice() + } + +} + + +/********************************************************************** + * Event Handlers: + **********************************************************************/ + + +/** + * handleAppTouch(evt) + * + * App touch event handler. + * Used for testing and debugging. + * + **/ +void handleAppTouch(evt) { + + if (atomicState.debug) log.debug "${app.label}: handleAppTouch()" + + //manageAuth() + //manageSchedules() + + //getEvohomeConfig() + //updateChildDeviceConfig() + + getEvohomeSchedules() + + //poll() + +} + + +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * updateChildDeviceConfig() + * + * Add/Remove/Update child devices based on atomicState.evohomeConfig. + * + **/ +void updateChildDeviceConfig() { + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()" + + // Build list of active DNIs, any existing children with DNIs not in here will be deleted. + def activeDnis = [] + + // Iterate through evohomeConfig, adding new 'Evohome Heating Zone' and 'Evohome Hot Water Zone' devices where necessary. + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + // Heating Zones: + tcs.zones.each { zone -> + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating Zone R3.4", dni, null, values) //Change the name here to change the name of the device + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + + + // Hot Water Zone: + if (tcs.dhw) { + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, tcs.dhw.dhwId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'dhwTemperature': formatTemperature(settings.prefEvohomeDHWTemp), + 'zoneType': 'DHW', + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': tcs.dhw.dhwId + ] + + log.info "${app.label}: updateChildDeviceConfig(): Found a hot water zone! Values: ${values}" + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "Hot Water (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating Hot Water Zone: DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Hot Water Zone R3.4", dni, null, values) + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + } + } + } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}" + + // Delete Devices: + def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete." + + delete.each { + log.info "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}" + try { + deleteChildDevice(it.deviceNetworkId) + } + catch(e) { + log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}" + } + } +} + + + +/** + * updateChildDevice(onlyZoneId=-1) + * + * Update the attributes of a child device from atomicState.evohomeStatus + * and atomicState.evohomeSchedules. + * + * If onlyZoneId is not specified, then all zones are updated. + * + * Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime. + * + **/ +void updateChildDevice(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})" + + atomicState.evohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + // Heating Zones: + tcs.zones.each { zone -> + if (onlyZoneId == -1 || onlyZoneId == zone.zoneId) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId) + def d = getChildDevice(dni) + if(d) { + def schedule = atomicState.evohomeSchedules.find { it.dni == dni} + def currSw = getCurrentSwitchpoint(schedule.schedule) + def nextSw = getNextSwitchpoint(schedule.schedule) + + def values = [ + 'temperature': formatTemperature(zone?.temperatureStatus?.temperature), + //'isTemperatureAvailable': zone?.temperatureStatus?.isAvailable, + 'heatingSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpointMode': decapitalise(zone?.heatSetpointStatus?.setpointMode), + 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'thermostatModeMode': (tcs?.systemModeStatus?.isPermanent) ? 'permanent' : 'temporary', + 'thermostatModeUntil': tcs?.systemModeStatus?.timeUntil, + 'scheduledSetpoint': formatTemperature(currSw.temperature), + 'nextScheduledSetpoint': formatTemperature(nextSw.temperature), + 'nextScheduledTime': nextSw.time + ] + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}" + d.generateEvent(values) + } else { + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist." + } + } + } + + // Hot Water Zone: + if (tcs.dhw && (onlyZoneId == -1 || onlyZoneId == tcs.dhw.dhwId)) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, tcs.dhw.dhwId) + def d = getChildDevice(dni) + if(d) { + //def schedule = atomicState.evohomeSchedules.find { it.dni == dni} + //def currSw = getCurrentSwitchpoint(schedule.schedule) + //def nextSw = getNextSwitchpoint(schedule.schedule) + + def values = [ + 'temperature': formatTemperature(tcs.dhw?.temperatureStatus?.temperature), + //'isTemperatureAvailable': tcs.dhw?.temperatureStatus?.isAvailable, + 'switch': tcs.dhw?.stateStatus?.state.toLowerCase(), + 'switchStateMode': decapitalise(tcs.dhw?.stateStatus?.mode), + 'switchStateUntil': tcs.dhw?.stateStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'thermostatModeMode': (tcs?.systemModeStatus?.isPermanent) ? 'permanent' : 'temporary', + 'thermostatModeUntil': tcs?.systemModeStatus?.timeUntil + // 'scheduledSwitchState': ?? + // 'nextScheduledSwitchState': ?? + // 'nextScheduledTime': ?? + ] + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}" + d.generateEvent(values) + } else { + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist." + } + } + } + } + } +} + + +/********************************************************************** + * Evohome API Commands: + **********************************************************************/ + +/** + * authenticate() + * + * Authenticate to Evohome. + * + **/ +private authenticate() { + + if (atomicState.debug) log.debug "${app.label}: authenticate()" + + def requestParams = [ + method: 'POST', + uri: 'https://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'Username': settings.prefEvohomeUsername, + 'Password': settings.prefEvohomePassword + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: authenticate(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}" + atomicState.evohomeAuthFailed = true + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}" + atomicState.evohomeAuthFailed = true + } + +} + + +/** + * refreshAuthToken() + * + * Refresh Auth Token. + * If token refresh fails, then authenticate() is called. + * Request is simlar to authenticate, but with grant_type = 'refresh_token' and 'refresh_token'. + * + **/ +private refreshAuthToken() { + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()" + + def requestParams = [ + method: 'POST', + uri: 'https://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'refresh_token': atomicState.evohomeAuth.refreshToken + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}" + // If Unauthorized (401) then re-authenticate: + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + authenticate() + } + } + +} + + +/** + * getEvohomeUserAccount() + * + * Gets user account info and stores in atomicState.evohomeUserAccount. + * + **/ +private getEvohomeUserAccount() { + + log.info "${app.label}: getEvohomeUserAccount(): Getting user account information." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/userAccount', + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + atomicState.evohomeUserAccount = resp.data + if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Data: ${atomicState.evohomeUserAccount}" + } + else { + log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeUserAccount(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + } +} + + + +/** + * getEvohomeConfig() + * + * Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig. + * + **/ +private getEvohomeConfig() { + + log.info "${app.label}: getEvohomeConfig(): Getting configuration for all locations." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/location/installationInfo', + query: [ + 'userId': atomicState.evohomeUserAccount.userId, + 'includeTemperatureControlSystems': 'True' + ], + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Data: ${resp.data}" + atomicState.evohomeConfig = resp.data + atomicState.evohomeConfigUpdatedAt = now() + return null + } + else { + log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeConfig(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + + + + + +/** + * getEvohomeStatus(onlyZoneId=-1) + * + * Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus. + * If onlyZoneId is not specified, all zones in all locations are updated. + * + * Calls getEvohomeLocationStatus() and getEvohomeZoneStatus(). + * + **/ +private getEvohomeStatus(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})" + + def newEvohomeStatus = [] + + if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location): + + log.info "${app.label}: getEvohomeStatus(): Getting status for all zones." + + atomicState.evohomeConfig.each { loc -> + def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId) + if (locStatus) { + newEvohomeStatus << locStatus + } + } + + if (newEvohomeStatus) { + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + atomicState.evohomeStatusUpdatedAt = now() + } + } + else { // Only update the specified zone: + + log.info "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}" + + def newZoneStatus = getEvohomeZoneStatus(onlyZoneId) + if (newZoneStatus) { + // Get existing evohomeStatus and update only the specified zone, preserving data for other zones: + // Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate). + // If mutiple zones are requesting updates at the same time this could cause loss of new data, but + // the worst case is having out-of-date data for a few minutes... + newEvohomeStatus = atomicState.evohomeStatus + newEvohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == zone.zoneId) { + zone.activeFaults = newZoneStatus.activeFaults + zone.heatSetpointStatus = newZoneStatus.heatSetpointStatus + zone.temperatureStatus = newZoneStatus.temperatureStatus + } + } + } + } + } + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + // Note: atomicState.evohomeStatusUpdatedAt is NOT updated. + } + } +} + + +/** + * getEvohomeLocationStatus(locationId) + * + * Gets the status for a specific location and returns data as a map. + * + * Called by getEvohomeStatus(). + **/ +private getEvohomeLocationStatus(locationId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/location/${locationId}/status", + 'query': [ 'includeTemperatureControlSystems': 'True'], + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeLocationStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeLocationStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeZoneStatus(zoneId) + * + * Gets the status for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneStatus(zoneId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeSchedules() + * + * Gets the schedules for all hot water and temperature (heating) zones + * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules. + * and stores in atomicState.evohomeSchedules. + * + * Calls getEvohomeTempZoneSchedule() and getEvohomeDHWSchedule(). + * + **/ +private getEvohomeSchedules() { + + log.info "${app.label}: getEvohomeSchedules(): Getting schedules for all zones." + + def evohomeSchedules = [] + + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + // Heating Zones: + tcs.zones.each { zone -> + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + def schedule = getEvohomeTempZoneSchedule(zone.zoneId) + if (schedule) { + evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule] + } + } + // Hot Water Zone: + if (tcs.dhw) { + if (tcs.dhw.dhwId) { + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, tcs.dhw.dhwId ) + def schedule = getEvohomeDHWSchedule(tcs.dhw.dhwId) + if (schedule) { + evohomeSchedules << ['zoneId': tcs.dhw.dhwId, 'dni': dni, 'schedule': schedule] + } + } + } + } + } + } + + if (evohomeSchedules) { + // Write out complete schedules to state: + atomicState.evohomeSchedules = evohomeSchedules + atomicState.evohomeSchedulesUpdatedAt = now() + } + + return evohomeSchedules +} + + +/** + * getEvohomeTempZoneSchedule(zoneId) + * + * Gets the schedule for the specified temperature (heating) zone and returns data as a map. + * + **/ +private getEvohomeTempZoneSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeTempZoneSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeTempZoneSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeTempZoneSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeTempZoneSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeDHWSchedule(zoneId) + * + * Gets the schedule for the specified hot water zone and returns data as a map. + * + **/ +private getEvohomeDHWSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeDHWSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeDHWSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeDHWSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeDHWSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * setThermostatMode(systemId, mode, until) + * + * Set thermostat mode for specified controller, until specified time. + * + * systemId: SystemId of temperatureControlSystem. E.g.: 123456 + * + * mode: String. Either: "auto", "off", "economy", "away", "dayOff", "custom". + * + * until: (Optional) Time to apply mode until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * - Number: Duration in hours if mode is 'economy', or in days if mode is 'away'/'dayOff'/'custom'. + * Duration will be rounded down to align with Midnight in the local timezone + * (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent. + * If 'until' is not specified, a default value is used from the SmartApp settings. + * + * Notes: 'Auto' and 'Off' modes are always permanent. + * Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller). + * Therefore changing the thermostatMode will affect all zones associated with the same controller. + * + * + * Example usage: + * setThermostatMode(123456, 'auto') // Set auto mode permanently, for controller 123456. + * setThermostatMode(123456, 'away','2016-04-01T00:00:00Z') // Set away mode until 1st April, for controller 123456. + * setThermostatMode(123456, 'dayOff','permanent') // Set dayOff mode permanently, for controller 123456. + * setThermostatMode(123456, 'dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night), for controller 123456. + * setThermostatMode(123456, 'economy', 2) // Set economy mode for 2 hours, for controller 123456. + * + **/ +def setThermostatMode(systemId, mode, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}" + + // Clean mode (translate to index): + mode = mode.toLowerCase() + int modeIndex + switch (mode) { + case 'auto': + modeIndex = 0 + break + case 'off': + modeIndex = 1 + break + case 'eco': + modeIndex = 2 + break + case 'away': + modeIndex = 3 + break + case 'dayoff': + modeIndex = 4 + break + case 'custom': + modeIndex = 6 + break + default: + log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!" + modeIndex = 999 + break + } + + // Clean until: + def untilRes + + // until has not been specified, so determine behaviour from settings: + if (-1 == until && 'economy' == mode) { + until = atomicState.thermostatEconomyDuration ?: 0 // Use Default duration for economy mode (hours): + } + else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) { + until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days): + } + + // Convert to date (or 0): + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && 'economy' == mode) { // until is a duration in hours: + untilRes = new Date( now() + (Math.round(until) * 3600000) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { // until is a duration in days: + untilRes = new Date( now() + (Math.round(until) * 86400000) ).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) // Round down to midnight in the LOCAL timezone. + } + else { + log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently." + untilRes = 0 + } + + // If mode is away/dayOff/custom the date needs to be rounded down to midnight in the local timezone, then converted back to string again: + if (0 != untilRes && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilRes).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) + } + + // Build request: + def body + if (0 == untilRes || 'off' == mode || 'auto' == mode) { // Mode is permanent: + body = ['SystemMode': modeIndex, 'TimeUntil': null, 'Permanent': 'True'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True" + } + else { // Mode is temporary: + body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: False, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setThermostatMode(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setThermostatMode(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + + /** + * setHeatingSetpoint(zoneId, setpoint, until=-1) + * + * Set heatingSetpoint override for specified heating zone, until specified time. + * + * zoneId: Zone ID of zone, e.g.: "123456" + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * If not specified, setpoint will be applied permanently. + * + * Example usage: + * setHeatingSetpoint(123456, 21.0) // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, 'permanent') // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, '2016-04-01T00:00:00Z') // Set until specific time, for zone 123456. + * + **/ +def setHeatingSetpoint(zoneId, setpoint, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + + // Clean until: + def untilRes + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + def body + if (0 == untilRes) { // Permanent: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 1, 'TimeUntil': null] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent" + } + else { // Temporary: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearHeatingSetpoint(zoneId) + * + * Clear any override for the specified heating zone. + * zoneId: Zone ID of zone, e.g.: "123456" + * + **/ +def clearHeatingSetpoint(zoneId) { + + log.info "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': ['HeatSetpointValue': 0.0, 'SetpointMode': 0, 'TimeUntil': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * setDHWSwitchState(zoneId, switchState, until=-1) + * + * Set state override for specified hot water zone, until specified time. + * + * zoneId: Zone ID of hot water zone, e.g.: "123456" + * + * switchState: 'on' or 'off'. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * If not specified, setpoint will be applied permanently. + * + * Example usage: + * setDHWSwitchState(123456, 'on') // Turn on hot water zone (123456) permanently. + * setDHWSwitchState(123456, 'off', 'permanent') // Turn off hot water zone (123456) permanently. + * setDHWSwitchState(123456, 'on', '2016-04-01T00:00:00Z') // Turn on hot water zone (123456) until specific time. + * + **/ +def setDHWSwitchState(zoneId, switchState, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, switchState: ${switchState}, Until: ${until}" + + // Clean switchState: + def stateRes = ('on' == switchState.toLowerCase()) ? 1 : 0 + + // Clean until: + def untilRes + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else { + log.warn "${device.label}: setDHWSwitchState(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + // Note, DHW uses the parameter 'UntilTime' whereas heating zones use 'TimeUntil'. Go figure! + def body + if (0 == untilRes) { // Permanent: + body = ['State': stateRes, 'Mode': 1, 'UntilTime': null] + log.info "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, State: ${switchState}, Until: Permanent" + } + else { // Temporary: + body = ['State': stateRes, 'Mode': 2, 'UntilTime': untilRes] + log.info "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, State: ${switchState}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/state", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setDHWSwitchState(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setDHWSwitchState(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setDHWSwitchState(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearDHWSwitchState(zoneId) + * + * Clear any override for the specific hot water zone. + * zoneId: Zone ID of hot water zone, e.g.: "123456" + * + **/ +def clearDHWSwitchState(zoneId) { + + log.info "${app.label}: clearDHWSwitchState(): Hot Water Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/state", + 'body': ['State': 0, 'Mode': 0, 'UntilTime': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearDHWSwitchState(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearDHWSwitchState(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearDHWSwitchState(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + + /** + * pseudoSleep(ms) + * + * Substitute for sleep() command. + * + **/ +private pseudoSleep(ms) { + def start = now() + while (now() < start + ms) { + // Do nothing, just wait. + } +} + + +/** + * generateDni(locId,gatewayId,systemId,deviceId) + * + * Generate a device Network ID. + * Uses the same format as the official Evohome App, but with a prefix of "Evohome." + * + **/ +private generateDni(locId,gatewayId,systemId,deviceId) { + return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.') +} + + +/** + * formatTemperature(t) + * + * Format temperature value to one decimal place. + * t: can be string, float, bigdecimal... + * Returns string. + * + **/ +private formatTemperature(t) { + try { + return Float.parseFloat("${t}").round(1).toString() + } + catch (NumberFormatException e) { + log.warn "${app.label}: formatTemperature(): could not parse value: ${t}" + return null + } +} + + +/** + * decapitalise(string) + * + * + * Decapitalise first letter of string. + * + * + **/ +private decapitalise(string) { + + if ( string == null || 0 == string.length() ) { + return string + } + else { + return string[0].toLowerCase() + string.substring(1) + + } + +} + + +/** + * formatThermostatMode(mode) + * + * Translate Evohome thermostatMode values to SmartThings values. + * + **/ +private formatThermostatMode(mode) { + + switch (mode) { + case 'Auto': + mode = 'auto' + break + case 'HeatingOff': + mode = 'off' + break + case 'AutoWithEco': + mode = 'eco' + break + case 'Away': + mode = 'away' + break + case 'DayOff': + mode = 'dayoff' + break + case 'Custom': + mode = 'custom' + break + default: + log.warn "${app.label}: formatThermostatMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * getCurrentSwitchpoint(schedule) + * + * Returns the current active switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getCurrentSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + ScheduleToday.switchpoints.reverse(true) + def currentSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay < c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!currentSwitchPoint) { + // There are no current switchpoints today, so we must look for the last Switchpoint yesterday. + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): No current switchpoints today, so must look to yesterday's schedule." + c.add(Calendar.DATE, -1 ) // Subtract one DAY. + def ScheduleYesterday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleYesterday.switchpoints.sort {it.timeOfDay} + ScheduleYesterday.switchpoints.reverse(true) + currentSwitchPoint = ScheduleYesterday.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + currentSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + currentSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): Current Switchpoint: ${currentSwitchPoint}" + + return currentSwitchPoint +} + + +/** + * getNextSwitchpoint(schedule) + * + * Returns the next switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getNextSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + def nextSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay > c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!nextSwitchPoint) { + // There are no switchpoints left today, so we must look for the first Switchpoint tomorrow. + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): No more switchpoints today, so must look to tomorrow's schedule." + c.add(Calendar.DATE, 1 ) // Add one DAY. + def ScheduleTmrw = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleTmrw.switchpoints.sort {it.timeOfDay} + nextSwitchPoint = ScheduleTmrw.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + nextSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + nextSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): Next Switchpoint: ${nextSwitchPoint}" + + return nextSwitchPoint +} diff --git a/smartapps/codersaur/evohome-connect.src/evohome-connect.groovy b/smartapps/codersaur/evohome-connect.src/evohome-connect.groovy new file mode 100644 index 00000000000..2b941f76707 --- /dev/null +++ b/smartapps/codersaur/evohome-connect.src/evohome-connect.groovy @@ -0,0 +1,1360 @@ +/** + * Copyright 2016 David Lomas (codersaur) + * + * Name: Evohome (Connect) + * + * Author: David Lomas (codersaur) + * + * Date: 2016-04-05 + * + * Version: 0.08 + * + * Description: + * - Connect your Honeywell Evohome System to SmartThings. + * - Requires the Evohome Heating Zone device handler. + * - For latest documentation see: https://github.com/codersaur/SmartThings + * + * Version History: + * + * 2016-04-05: v0.08 + * - New 'Update Refresh Time' setting to control polling after making an update. + * - poll() - If onlyZoneId is 0, this will force a status update for all zones. + * + * 2016-04-04: v0.07 + * - Additional info log messages. + * + * 2016-04-03: v0.06 + * - Initial Beta Release + * + * To Do: + * - Add support for hot water zones (new device handler). + * - Tidy up settings: See: http://docs.smartthings.com/en/latest/smartapp-developers-guide/preferences-and-settings.html + * - Allow Evohome zones to be (de)selected as part of the setup process. + * - Enable notifications if connection to Evohome cloud fails. + * - Expose whether thremoStatMode is permanent or temporary, and if temporary for how long. Get from 'tcs.systemModeStatus.*'. i.e thermostatModeUntil + * - Investigate if Evohome supports registing a callback so changes in Evohome are pushed to SmartThings instantly (instead of relying on polling). + * + * License: + * 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. + * + */ +definition( + name: "Evohome (Connect)", + namespace: "codersaur", + author: "David Lomas (codersaur)", + description: "Connect your Honeywell Evohome System to SmartThings.", + category: "My Apps", + iconUrl: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png", + iconX2Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png", + iconX3Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png", + singleInstance: true +) + +preferences { + + section ("Evohome:") { + input "prefEvohomeUsername", "text", title: "Username", required: true, displayDuringSetup: true + input "prefEvohomePassword", "password", title: "Password", required: true, displayDuringSetup: true + input title: "Advanced Settings:", displayDuringSetup: true, type: "paragraph", element: "paragraph", description: "Change these only if needed" + input "prefEvohomeStatusPollInterval", "number", title: "Polling Interval (minutes)", range: "1..60", defaultValue: 5, required: true, displayDuringSetup: true, description: "Poll Evohome every n minutes" + input "prefEvohomeUpdateRefreshTime", "number", title: "Update Refresh Time (seconds)", range: "2..60", defaultValue: 3, required: true, displayDuringSetup: true, description: "Wait n seconds after an update before polling" + input "prefEvohomeWindowFuncTemp", "decimal", title: "Window Function Temperature", range: "0..100", defaultValue: 5.0, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days' + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours' + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + **/ +def installed() { + + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" + +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + * + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + * + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://tccna.honeywell.com' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling. + + + // Thermostat Mode Durations: + atomicState.thermostatModeDuration = settings.prefThermostatModeDuration + atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration + + // Force Authentication: + authenticate() + + // Refresh Subscriptions and Schedules: + manageSubscriptions() + manageSchedules() + + // Refresh child device configuration: + getEvohomeConfig() + updateChildDeviceConfig() + + // Run a poll, but defer it so that updated() returns sooner: + runIn(5, "poll") + +} + + +/********************************************************************** + * Management Commands: + **********************************************************************/ + +/** + * manageSchedules() + * + * Check scheduled tasks have not stalled, and re-schedule if necessary. + * Generates a random offset (seconds) for each scheduled task. + * + * Schedules: + * - manageAuth() - every 5 mins. + * - poll() - every minute. + * + **/ +void manageSchedules() { + + if (atomicState.debug) log.debug "${app.label}: manageSchedules()" + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // manageAuth (every 5 mins): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()" + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/5 * * * ?", "manageAuth") + } + + // poll(): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()" + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/1 * * * ?", "poll") + } + +} + + +/** + * manageSubscriptions() + * + * Unsubscribe/Subscribe. + **/ +void manageSubscriptions() { + + if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()" + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + +} + + +/** + * manageAuth() + * + * Ensures authenication token is valid. + * Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold. + * Re-authenticates if Auth Token has expired completely. + * Otherwise, done nothing. + * + * Should be scheduled to run every 1-5 minutes. + **/ +void manageAuth() { + + if (atomicState.debug) log.debug "${app.label}: manageAuth()" + + // Check if Auth Token is valid, if not authenticate: + if (!atomicState.evohomeAuth.authToken) { + + log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..." + authenticate() + } + else if (atomicState.evohomeAuthFailed) { + + log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..." + authenticate() + } + else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) { + + log.info "${app.label}: manageAuth(): Auth Token has expired. Authenticating..." + authenticate() + } + else { + // Check if Auth Token should be refreshed: + def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100)) + + if (now() >= refreshAt) { + log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires." + refreshAuthToken() + } + else { + log.info "${app.label}: manageAuth(): Auth Token is okay." + } + } + +} + + +/** + * poll(onlyZoneId=-1) + * + * This is the main command that co-ordinates retrieval of information from the Evohome API + * and its dissemination to child devices. It should be scheduled to run every minute. + * + * Different types of information are collected on different schedules: + * - Zone status information is polled according to ${evohomeStatusPollInterval}. + * - Zone schedules are polled according to ${evohomeSchedulePollInterval}. + * + * poll() can be called by a child device when an update has been made, in which case + * onlyZoneId will be specified, and only that zone will be updated. + * + * If onlyZoneId is 0, this will force a status update for all zones, igonoring the poll + * interval. This should only be used after setThremostatMode() call. + * + * If onlyZoneId is not specified all zones are updated, but only if the relevent poll + * interval has been exceeded. + * + **/ +void poll(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})" + + // Check if there's been an authentication failure: + if (atomicState.evohomeAuthFailed) { + manageAuth() + } + + if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update): + getEvohomeStatus() + updateChildDevice() + } + else if (onlyZoneId != -1) { // A zoneId has been specified, so just get the status and update the relevent device: + getEvohomeStatus(onlyZoneId) + updateChildDevice(onlyZoneId) + } + else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded: + + // Adjust intervals to allow for poll() execution time: + def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30 + def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30 + + // Get zone status: + if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) { + getEvohomeStatus() + } + + // Get zone schedules: + if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) { + getEvohomeSchedules() + } + + // Update all child devices: + updateChildDevice() + } + +} + + +/********************************************************************** + * Event Handlers: + **********************************************************************/ + + +/** + * handleAppTouch(evt) + * + * App touch event handler. + * Used for testing and debugging. + * + **/ +void handleAppTouch(evt) { + + if (atomicState.debug) log.debug "${app.label}: handleAppTouch()" + + //manageAuth() + //manageSchedules() + + //getEvohomeConfig() + //updateChildDeviceConfig() + + poll() + +} + + +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * updateChildDeviceConfig() + * + * Add/Remove/Update Child Devices based on atomicState.evohomeConfig + * and update their internal state. + * + **/ +void updateChildDeviceConfig() { + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()" + + // Build list of active DNIs, any existing children with DNIs not in here will be deleted. + def activeDnis = [] + + // Iterate through evohomeConfig, adding new Evohome Heating Zone devices where necessary. + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating Zone", dni, null, values) + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + } + } + } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}" + + // Delete Devices: + def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete." + + delete.each { + log.info "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}" + try { + deleteChildDevice(it.deviceNetworkId) + } + catch(e) { + log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}" + } + } +} + + + +/** + * updateChildDevice(onlyZoneId=-1) + * + * Update the attributes of a child device from atomicState.evohomeStatus + * and atomicState.evohomeSchedules. + * + * If onlyZoneId is not specified, then all zones are updated. + * + * Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime. + * + **/ +void updateChildDevice(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})" + + atomicState.evohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == -1 || onlyZoneId == zone.zoneId) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId) + def d = getChildDevice(dni) + if(d) { + def schedule = atomicState.evohomeSchedules.find { it.dni == dni} + def currSw = getCurrentSwitchpoint(schedule.schedule) + def nextSw = getNextSwitchpoint(schedule.schedule) + + def values = [ + 'temperature': formatTemperature(zone?.temperatureStatus?.temperature), + //'isTemperatureAvailable': zone?.temperatureStatus?.isAvailable, + 'heatingSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpointMode': formatSetpointMode(zone?.heatSetpointStatus?.setpointMode), + 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'scheduledSetpoint': formatTemperature(currSw.temperature), + 'nextScheduledSetpoint': formatTemperature(nextSw.temperature), + 'nextScheduledTime': nextSw.time + ] + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}" + d.generateEvent(values) + } else { + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist, so skipping status update." + } + } + } + } + } + } +} + + +/********************************************************************** + * Evohome API Commands: + **********************************************************************/ + +/** + * authenticate() + * + * Authenticate to Evohome. + * + **/ +private authenticate() { + + if (atomicState.debug) log.debug "${app.label}: authenticate()" + + def requestParams = [ + method: 'POST', + uri: 'https://tccna.honeywell.com', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'Username': settings.prefEvohomeUsername, + 'Password': settings.prefEvohomePassword + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: authenticate(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}" + atomicState.evohomeAuthFailed = true + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}" + atomicState.evohomeAuthFailed = true + } + +} + + +/** + * refreshAuthToken() + * + * Refresh Auth Token. + * If token refresh fails, then authenticate() is called. + * Request is simlar to authenticate, but with grant_type = 'refresh_token' and 'refresh_token'. + * + **/ +private refreshAuthToken() { + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()" + + def requestParams = [ + method: 'POST', + uri: 'https://tccna.honeywell.com', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'refresh_token': atomicState.evohomeAuth.refreshToken + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}" + // If Unauthorized (401) then re-authenticate: + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + authenticate() + } + } + +} + + +/** + * getEvohomeUserAccount() + * + * Gets user account info and stores in atomicState.evohomeUserAccount. + * + **/ +private getEvohomeUserAccount() { + + log.info "${app.label}: getEvohomeUserAccount(): Getting user account information." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/userAccount', + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + atomicState.evohomeUserAccount = resp.data + if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Data: ${atomicState.evohomeUserAccount}" + } + else { + log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeUserAccount(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + } +} + + + +/** + * getEvohomeConfig() + * + * Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig. + * + **/ +private getEvohomeConfig() { + + log.info "${app.label}: getEvohomeConfig(): Getting configuration for all locations." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/location/installationInfo', + query: [ + 'userId': atomicState.evohomeUserAccount.userId, + 'includeTemperatureControlSystems': 'True' + ], + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Data: ${resp.data}" + atomicState.evohomeConfig = resp.data + atomicState.evohomeConfigUpdatedAt = now() + return null + } + else { + log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeConfig(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * getEvohomeStatus(onlyZoneId=-1) + * + * Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus. + * If onlyZoneId is not specified, all zones are updated. + * + **/ +private getEvohomeStatus(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})" + + def newEvohomeStatus = [] + + if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location): + + log.info "${app.label}: getEvohomeStatus(): Getting status for all zones." + + atomicState.evohomeConfig.each { loc -> + def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId) + if (locStatus) { + newEvohomeStatus << locStatus + } + } + + if (newEvohomeStatus) { + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + atomicState.evohomeStatusUpdatedAt = now() + } + } + else { // Only update the specified zone: + + log.info "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}" + + def newZoneStatus = getEvohomeZoneStatus(onlyZoneId) + if (newZoneStatus) { + // Get existing evohomeStatus and update only the specified zone, preserving data for other zones: + // Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate). + // If mutiple zones are requesting updates at the same time this could cause loss of new data, but + // the worse case is having out-of-date data for a few minutes... + newEvohomeStatus = atomicState.evohomeStatus + newEvohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == zone.zoneId) { // This is the zone that must be updated: + zone.activeFaults = newZoneStatus.activeFaults + zone.heatSetpointStatus = newZoneStatus.heatSetpointStatus + zone.temperatureStatus = newZoneStatus.temperatureStatus + } + } + } + } + } + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + // Note: atomicState.evohomeStatusUpdatedAt is NOT updated. + } + } +} + + +/** + * getEvohomeLocationStatus(locationId) + * + * Gets the status for a specific location and returns data as a map. + * + * Called by getEvohomeStatus(). + **/ +private getEvohomeLocationStatus(locationId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/location/${locationId}/status", + 'query': [ 'includeTemperatureControlSystems': 'True'], + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeLocationStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeLocationStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeZoneStatus(zoneId) + * + * Gets the status for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneStatus(zoneId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeSchedules() + * + * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules. + * + **/ +private getEvohomeSchedules() { + + log.info "${app.label}: getEvohomeSchedules(): Getting schedules for all zones." + + def evohomeSchedules = [] + + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + def schedule = getEvohomeZoneSchedule(zone.zoneId) + if (schedule) { + evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule] + } + } + } + } + } + + if (evohomeSchedules) { + // Write out complete schedules to state: + atomicState.evohomeSchedules = evohomeSchedules + atomicState.evohomeSchedulesUpdatedAt = now() + } + + return evohomeSchedules +} + + +/** + * getEvohomeZoneSchedule(zoneId) + * + * Gets the schedule for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * setThermostatMode(systemId, mode, until) + * + * Set thermostat mode for specified controller, until specified time. + * + * systemId: SystemId of temperatureControlSystem. E.g.: 123456 + * + * mode: String. Either: "auto", "off", "economy", "away", "dayOff", "custom". + * + * until: (Optional) Time to apply mode until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * - Number: Duration in hours if mode is 'economy', or in days if mode is 'away'/'dayOff'/'custom'. + * Duration will be rounded down to align with Midnight in the local timezone + * (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent. + * If 'until' is not specified, a default value is used from the SmartApp settings. + * + * Notes: 'Auto' and 'Off' modes are always permanent. + * Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller). + * Therefore changing the thermostatMode will affect all zones associated with the same controller. + * + * + * Example usage: + * setThermostatMode(123456, 'auto') // Set auto mode permanently, for controller 123456. + * setThermostatMode(123456, 'away','2016-04-01T00:00:00Z') // Set away mode until 1st April, for controller 123456. + * setThermostatMode(123456, 'dayOff','permanent') // Set dayOff mode permanently, for controller 123456. + * setThermostatMode(123456, 'dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night), for controller 123456. + * setThermostatMode(123456, 'economy', 2) // Set economy mode for 2 hours, for controller 123456. + * + **/ +def setThermostatMode(systemId, mode, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}" + + // Clean mode (translate to index): + mode = mode.toLowerCase() + int modeIndex + switch (mode) { + case 'auto': + modeIndex = 0 + break + case 'off': + modeIndex = 1 + break + case 'economy': + modeIndex = 2 + break + case 'away': + modeIndex = 3 + break + case 'dayoff': + modeIndex = 4 + break + case 'custom': + modeIndex = 6 + break + default: + log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!" + modeIndex = 999 + break + } + + // Clean until: + def untilRes + + // until has not been specified, so determine behaviour from settings: + if (-1 == until && 'economy' == mode) { + until = atomicState.thermostatEconomyDuration ?: 0 // Use Default duration for economy mode (hours): + } + else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) { + until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days): + } + + // Convert to date (or 0): + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && 'economy' == mode) { // until is a duration in hours: + untilRes = new Date( now() + (Math.round(until) * 3600000) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { // until is a duration in days: + untilRes = new Date( now() + (Math.round(until) * 86400000) ).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) // Round down to midnight in the LOCAL timezone. + } + else { + log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently." + untilRes = 0 + } + + // If mode is away/dayOff/custom the date needs to be rounded down to midnight in the local timezone, then converted back to string again: + if (0 != untilRes && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilRes).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) + } + + // Build request: + def body + if (0 == untilRes || 'off' == mode || 'auto' == mode) { // Mode is permanent: + body = ['SystemMode': modeIndex, 'TimeUntil': null, 'Permanent': 'True'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True" + } + else { // Mode is temporary: + body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: False, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setThermostatMode(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setThermostatMode(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + + /** + * setHeatingSetpoint(zoneId, setpoint, until=-1) + * + * Set heatingSetpoint for specified zoneId, until specified time. + * + * zoneId: Zone ID of zone, e.g.: "123456" + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * If not specified, setpoint will be applied permanently. + * + * Example usage: + * setHeatingSetpoint(123456, 21.0) // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, 'permanent') // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, '2016-04-01T00:00:00Z') // Set until specific time, for zone 123456. + * + **/ +def setHeatingSetpoint(zoneId, setpoint, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + + // Clean until: + def untilRes + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + def body + if (0 == untilRes) { // Permanent: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 1, 'TimeUntil': null] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent" + } + else { // Temporary: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearHeatingSetpoint(zoneId) + * + * Clear the heatingSetpoint for specified zoneId. + * zoneId: Zone ID of zone, e.g.: "123456" + **/ +def clearHeatingSetpoint(zoneId) { + + log.info "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': ['HeatSetpointValue': 0.0, 'SetpointMode': 0, 'TimeUntil': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + + /** + * generateDni(locId,gatewayId,systemId,deviceId) + * + * Generate a device Network ID. + * Uses the same format as the official Evohome App, but with a prefix of "Evohome." + **/ +private generateDni(locId,gatewayId,systemId,deviceId) { + return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.') +} + + +/** + * formatTemperature(t) + * + * Format temperature value to one decimal place. + * t: can be string, float, bigdecimal... + * Returns as string. + **/ +private formatTemperature(t) { + return Float.parseFloat("${t}").round(1).toString() +} + + + /** + * formatSetpointMode(mode) + * + * Format Evohome setpointMode values to SmartThings values: + * + **/ +private formatSetpointMode(mode) { + + switch (mode) { + case 'FollowSchedule': + mode = 'followSchedule' + break + case 'PermanentOverride': + mode = 'permanentOverride' + break + case 'TemporaryOverride': + mode = 'temporaryOverride' + break + default: + log.error "${app.label}: formatSetpointMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * formatThermostatMode(mode) + * + * Translate Evohome thermostatMode values to SmartThings values. + * + **/ +private formatThermostatMode(mode) { + + switch (mode) { + case 'Auto': + mode = 'auto' + break + case 'AutoWithEco': + mode = 'economy' + break + case 'Away': + mode = 'away' + break + case 'Custom': + mode = 'custom' + break + case 'DayOff': + mode = 'dayOff' + break + case 'HeatingOff': + mode = 'off' + break + default: + log.error "${app.label}: formatThermostatMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * getCurrentSwitchpoint(schedule) + * + * Returns the current active switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getCurrentSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + ScheduleToday.switchpoints.reverse(true) + def currentSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay < c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!currentSwitchPoint) { + // There are no current switchpoints today, so we must look for the last Switchpoint yesterday. + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): No current switchpoints today, so must look to yesterday's schedule." + c.add(Calendar.DATE, -1 ) // Subtract one DAY. + def ScheduleYesterday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleYesterday.switchpoints.sort {it.timeOfDay} + ScheduleYesterday.switchpoints.reverse(true) + currentSwitchPoint = ScheduleYesterday.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + currentSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + currentSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): Current Switchpoint: ${currentSwitchPoint}" + + return currentSwitchPoint +} + + +/** + * getNextSwitchpoint(schedule) + * + * Returns the next switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getNextSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + def nextSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay > c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!nextSwitchPoint) { + // There are no switchpoints left today, so we must look for the first Switchpoint tomorrow. + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): No more switchpoints today, so must look to tomorrow's schedule." + c.add(Calendar.DATE, 1 ) // Add one DAY. + def ScheduleTmrw = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleTmrw.switchpoints.sort {it.timeOfDay} + nextSwitchPoint = ScheduleTmrw.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + nextSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + nextSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): Next Switchpoint: ${nextSwitchPoint}" + + return nextSwitchPoint +} \ No newline at end of file