/** * Tesla Powerwall Manager * * Copyright 2019-2024 DarwinsDen.com * * ****** WARNING ****** USE AT YOUR OWN RISK! * This software was developed in the hopes that it will be useful to others, however, * it is beta software and may have unforeseen side effects to your equipment and related accounts. * * 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. * */ String version() { return "v0.3.84.20240405" } /* * 05-Apr-2024 >>> v0.3.84.20240405 - Correct for apparent Tesla server API change relating to Battery Percentage. Thanks to @NeilR. * 16-Feb-2024 >>> v0.3.83.20240216 - Re-add Backup-Only mode with deprecated warning. * 16-Feb-2024 >>> v0.3.82.20240216 - Add battery capacity/total energy - thank you D. Mills for the code snippet. Added grid charging enable/ * disable to scheduler. Removed Tesla deprecated calls from scheduler (set TOU-Strategy, set Backup-Only mode) * 26-Jan-2024 >>> v0.3.81.20240126 - Update for Tesla API auth change. Add commands for energy export mode and grid charging. * 23-Oct-2023 >>> v0.3.80.20231023 - Update for Tesla API change - removal of api/1/powerwalls. * 05-Apr-2022 >>> v0.3.71.20220405 - Correct patch refresh date check integer overflow issue. * 05-Apr-2022 >>> v0.3.70.20220405 - Apparent Tesla auth change - quick patch. * 15-Mar-2022 >>> v0.3.61.20220315 - Added contact sensor capability to PW device to indicate grid status (open=off-grid). * 02-Feb-2022 >>> v0.3.60.20220202 - Add Storm Watch Active. Child device option for enhanced SmartThings/Hubitat integration. * Delta threshold preference setting for power reporting. Enabled dimmer level for reserve control/status. * 30-Jan-2022 >>> v0.3.51.20220130 - Correct update delta check. * 28-Jan-2022 >>> v0.3.50.20220128 - Gateway debug and ping test. * 19-Jan-2022 >>> v0.3.41.20220119 - Cleanup. Ensure refresh token is always scheduled and old SmartThings schedules are cleared. * 18-Jan-2022 >>> v0.3.40.20220118 - Add option to choose between multiple powerwall sites. Fix on-grid actions. * 29-Dec-2021 >>> v0.3.30.20211229 - Merge and update of changes from @x10send: Added support for going off grid via local gateway (Hubitat Only). * Added ability to specify refresh token in lieu of access token. * 24-Oct-2021 >>> v0.3.20.20211024 - UI updates. Added Token expiration notification. Fixes: False off-grid notifications, * multiple SmartThings schedules, Gateway dashboard settings on Hubitat. * 02-Jun-2021 >>> v0.3.1e.20210603 - Re-add local gateway connection for Hubitat, Scheduling infrastructure mods. * 25-May-2021 >>> v0.3.0e.20210325 - Tesla auth API change workarounds: use tokens directly, disable gateway direct code. * 02-Jul-2020 >>> v0.2.8e.20200702 - Added dashboard tile display from local gateway iFrame for Hubitat. * 27-May-2020 >>> v0.2.7e.20200527 - Handle extra null battery site info from Tesla. Handle no time zone set. * 02-Mar-2020 >>> v0.2.6e.20200302 - Correct mobile notifications * 29-Feb-2020 >>> v0.2.5e.20200229 - Additional http command and query error checks. Added option to pause automations. * 19-Feb-2020 >>> v0.2.4e.20200219 - Added battery charge % trigger time and day restriction options. * 31-Jan-2020 >>> v0.2.3e.20200131 - Added battery charge % triggers & TBC Strategy scheduling. * 22-Jan-2020 >>> v0.2.2e.20200122 - Added Stormwatch on/off scheduling. * 16-Jan-2020 >>> v0.2.1e.20200116 - Additional command retry/error checking logic. Hubitat battery% compatibility update. * 10-Jan-2020 >>> v0.2.0e.20200110 - Push notification support for Hubitat * 04-Jan-2020 >>> v0.1.8e.20200104 - Updated async http call for cross-platform support with Hubitat & SmartThings * 03-Jan-2020 >>> v0.1.7e.20200103 - Added access token refresh & command post retry logic * 30-Dec-2019 >>> v0.1.6e.20191230 - Increased reserve percentage value options * 06-Sep-2019 >>> v0.1.5e.20190906 - Updated watchdog to only notify once when issue first occurs and when resolved * 13-Aug-2019 >>> v0.1.4e.20190813 - Added grid/outage status display, notifications, and device on/off controls * 09-Aug-2019 >>> v0.1.3e.20190809 - Added reserve% scheduling & polling interval preferences * 29-Jul-2019 >>> v0.1.2e.20190729 - Set reserve percent to 100% in backup-only mode. Added mode scheduling. * 23-Jul-2019 >>> v0.1.1e.20190723 - Initial beta release */ import groovy.transform.Field definition ( name: "Tesla Powerwall Manager", namespace: "darwinsden", author: "eedwards", description: "Monitor and control your Tesla Powerwall", importUrl: "https://raw.githubusercontent.com/DarwinsDen/Tesla-Powerwall-Manager/master/smartapps/darwinsden/tesla-powerwall-manager.src/tesla-powerwall-manager.groovy", category: "My Apps", iconUrl: pwLogo, iconX2Url: pwLogo ) preferences { page(name: "pageMain") page(name: "pageConnectionMethod") page(name: "teslaAccountInfo") page(name: "gatewayAccountInfo") page(name: "pageDashboardTile") page(name: "pageNotifications") page(name: "pageSchedules") page(name: "pageScheduleOptions") page(name: "pageScheduleWhen") page(name: "pageDeleteSchedule", nextPage: "pageMain") page(name: "pageTriggers") page(name: "pageTriggerOptions") page(name: "pagePwActions") page(name: "pagePwPreferences") page(name: "pageDevicesToControl") page(name: "triggerRestrictions") page(name: "pageTokenFromUrl") } private pageMain() { return dynamicPage(name: "pageMain", title: "", install: true, uninstall: true) { section() { if (hubIsSt()) { paragraph app.versionDetails(), title: "PowerWall Manager", required: false, image: pwLogo } else { paragraph "Powerwall Manager\n ${app.versionDetails()}" } } String connectStr if (hubIsSt()) { connectStr = "A Tesla server connection is required for access and control of the Powerwall through SmartThings." } else { connectStr = "You can connect to the Powerwall through the Tesla server, your local gateway, or both. " + "A Tesla server connection is required for commanding Powerwall state changes. " + "A local gateway connection allows more frequent Powerwall status updates than when connecting through the Tesla server alone." } if (!state.lastServerCheckTime || now() - state.lastServerCheckTime > 300000){ getTeslaServerStatus() } if (!state.lastGatewayCheckTime || now() - state.lastGatewayCheckTime > 300000){ getLocalGwStatus() } state.gwPingResults = null section(connectStr) { hrefMenuPage ("teslaAccountInfo", "Tesla Server Token Information..", state.serverStatusStr, teslaIcon, null, connectedToTeslaServer() ? "complete" : null) if (!hubIsSt()) { hrefMenuPage ("gatewayAccountInfo", "Local Powerwall Gateway Connection..", state.gatewayStatusStr, gatewayIcon, null, connectedToGateway() ? "complete" : null) } } section("Preferences") { hrefMenuPage ("pageNotifications", "Notification preferences..", "", notifyIcon, null) state.scheduleCount = state.scheduleCount ?: 0 hrefMenuPage ("pageSchedules", "Schedule Powerwall setting changes..", "(${state.scheduleCount} active schedules)", schedIcon, null, state.scheduleCount > 0 ? "complete" : null) //String status = state.triggerActionsActive ? "Actions are enabled" : "Perform actions based on Powerwall charge %.." hrefMenuPage ("pageTriggers", "Perform actions based on Powerwall charge %..", "", batteryIcon, null, state.triggerActionsActive ? "complete" : null) Boolean valid = devicesToOffDuringOutage?.size() || devicesToOnAfterOutage?.size() hrefMenuPage ("pageDevicesToControl", "Turn off devices when a grid outage occurs..", "", outageIcon, null, valid ? "complete" : null) if (!hubIsSt()) { hrefMenuPage ("pageDashboardTile", "Display a dashboard tile iFrame from the gateway..", "", dashIcon, null, gatewayTileAddress ? "complete" : null) } hrefMenuPage ("pagePwPreferences", "Powerwall Manager General Preferences..", "", cogIcon, null) } section() { String freeMsg = "This is free software. Donations are very much appreciated, but are not required or expected." if (hubIsSt()) { href(name: "Site", title: "For more information, questions, or to provide feedback, please visit: ${ddUrl}", description: "Tap to open the Powerwall Manager web page on DarwinsDen.com", required: false, image: ddLogoSt, url: ddUrl) href(name: "", title: "", description: freeMsg, required: false, image: ppBtn, url: "https://www.paypal.com/paypalme/darwinsden") } else { String ddMsg = "For more information, questions, or to provide feedback, please visit: ${ddUrl}" String ddDiv = "
" + "
" String ppDiv = "
" + "
" paragraph "
" + freeMsg + " " + ddMsg + "
" paragraph "
" + ddDiv + ppDiv + "
" } } } } def pageSchedules() { setSchedules() state.scheduleDeleted = false state.editingScheduleIndex = -1 dynamicPage(name: "pageSchedules", title: "Powerwall Schedules", install: false, uninstall: false) { section("") { state.scheduleCount = 0 if (state.scheduleList && state.scheduleList.size() > 0) { state.scheduleList.eachWithIndex {item, index -> String actionsStr = getActionsString(schedVal(item,"Mode"), schedVal(item,"Reserve"),schedVal(item,"Stormwatch"), schedVal(item,"GridCharging"), null, schedVal(item,"GridStatus")) String whenStr = getWhenString(schedVal(item,"Time"), schedVal(item,"Days"),schedVal(item,"Months")) Boolean actionsOk = actionsValid(schedVal(item,"Mode"), schedVal(item,"Reserve"),schedVal(item,"Stormwatch"),schedVal(item,"GridCharging"), null,schedVal(item,"GridStatus")) Boolean whenOk = scheduleValid(schedVal(item,"Time"), schedVal(item,"Days")) Boolean disabled = schedVal(item,"Disable") == "true" String msgStr String icon if (!actionsOk) { msgStr = "Actions are required. Select to add.." icon = schedIncomplIcon } else if (!whenOk) { msgStr = "Requires time and days to be set. Select to add.." icon = schedIncomplIcon } else { msgStr = whenStr + "\n" + actionsStr icon = schedOkIcon } Boolean scheduleActive = actionsOk && whenOk && !disabled if (scheduleActive) { state.scheduleCount = state.scheduleCount + 1 } hrefMenuPage ("pageScheduleOptions", schedNameFromIndex(index), msgStr, icon, [schedIndex: index], scheduleActive ? "complete" : null) } } else { paragraph "There are no active schedules." } if (!hubIsSt()) { //Apparent bug in Hubitat - Can't set params in a second 'section' or it will send that instead of what's in first section - keep all in the same section paragraph "\n" hrefMenuPage ("pageScheduleOptions", "Create a new Powerwall scheduled action..", "", addIcon, [newSchedule: true], null) } } if (hubIsSt() && state.scheduleCount < maxSmartThingsSchedules) { section("") { hrefMenuPage ("pageScheduleOptions", "Create a new Powerwall scheduled action..", "", addIcon, [newSchedule: true], null) } } } } String schedNameFromIndex (Integer schedIndex) { String schedName = "Schedule ${schedIndex + 1}" Integer schedNum = state.scheduleList[schedIndex] if (settings["schedule${schedNum}Name"]) { schedName = schedName + ": ${settings["schedule${schedNum}Name"]}" } if (settings["schedule${schedNum}Disable"]) { schedName = schedName + " (Disabled)" } return schedName } void appButtonHandler(btn) { switch (btn) { case "deleteSchedule": deleteScheduleIndex(state.editingScheduleIndex) break case "gatewayPing": def pingData = hubitat.helper.NetworkUtils.ping("${gatewayAddress}") state.gwPingResults = pingData state.lastGwPingIp = "${gatewayAddress}" logger ("Gateway ping results: ${pingData}","debug") break default: logger ("Unknown button type: ${btn}","warn") break } } def pageScheduleOptions(params) { Integer schedIndex if (state.editingScheduleIndex == -1) { if (params.newSchedule) { addNewSchedule() schedIndex = state.scheduleList.size() - 1 } else if (params.schedIndex != null) { schedIndex = params.schedIndex } else { logger ("Unexpected condition in pageScheduleOptions. params are: ${params}","warn") schedIndex = state.editingScheduleIndex } } else { schedIndex = state.editingScheduleIndex } Integer schedNum = state.scheduleList[schedIndex] if (state.scheduleDeleted) { dynamicPage(name: "pageScheduleOptions", title: "", install: false, uninstall: false) { section("") { paragraph "Schedule ${schedIndex + 1} has been deleted" } } } else { dynamicPage(name: "pageScheduleOptions", title: schedNameFromIndex(schedIndex), install: false, uninstall: false) { state.editingScheduleIndex = schedIndex section("Select Powerwall actions to apply:") { String actionsString = getActionsString(schedVal(schedNum,"Mode"), schedVal(schedNum,"Reserve"),schedVal(schedNum,"Stormwatch"), schedVal(schedNum,"GridCharging"), null, schedVal(schedNum,"GridStatus")) Boolean complete = actionsValid(schedVal(schedNum,"Mode"), schedVal(schedNum,"Reserve"),schedVal(schedNum,"Stormwatch"),schedVal(schedNum,"GridCharging"), null, schedVal(item,"GridStatus")) href "pagePwActions", title: actionsString, state: complete ? "complete" : null, description : "", params: [prefix: "schedule${schedNum}", title : "Select at least one Powerwall action to apply:"] } section("Select when to perform these actions:") { String whenString = getWhenString(schedVal(schedNum,"Time"), schedVal(schedNum,"Days"),schedVal(schedNum,"Months")) Boolean complete = scheduleValid(schedVal(schedNum,"Time"), schedVal(schedNum,"Days")) href "pageScheduleWhen", title: whenString, state: complete ? "complete" : null, description: "", params: [schedIndex: schedIndex] paragraph getNextScheduleTime(schedNum) } section("") { input "schedule${schedNum}Name", "text", required: false, title: "Name this schedule (optional)" input "schedule${schedNum}Disable", "bool", required: false, defaultValue: false, title: "Disable this schedule", submitOnChange: true } section("") { if (hubIsSt()) { href "pageDeleteSchedule", title: "Delete this schedule", image: trashIcon, description: "" } else { String trash = "" input name: "deleteSchedule", type: "button", title: trash + "Delete this schedule",submitOnChange: true } } } } } def pageScheduleWhen(params) { Integer schedIndex if (params.schedIndex != null) { schedIndex = params.schedIndex } else { schedIndex = state.editingScheduleIndex logger ("Unexpected params in pageScheduleWhen: ${params}","warn") } Integer schedNum = state.scheduleList[schedIndex] dynamicPage(name: "pageScheduleWhen", title: schedNameFromIndex(schedIndex), install: false, uninstall: false) { section("Select when to perform these actions:") { input "schedule${schedNum}Time", "time", required: false, title: "At what time? (required)", submitOnChange: true input "schedule${schedNum}Days", "enum", required: false, title: "On which days? (required)", multiple: true, submitOnChange: true, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] input "schedule${schedNum}Months", "enum", title: "In which months? (optional - if no months are selected, the schedule will execute for all months)", required: false, multiple: true, submitOnChange: true, options: ["January": "January", "February": "February", "March": "March", "April": "April", "May": "May", "June": "June", "July": "July", "August": "August", "September": "September", "October": "October", "November": "November", "December": "December"] paragraph getNextScheduleTime(schedNum) } } } String schedVal (Integer schedNum, String param) { return settings["schedule${schedNum}${param}"] } void clearScheduleData (data) { logger ("Clearing schedNum data: ${data.schedNum}","debug") Integer schedNum = data.schedNum app.updateSetting("schedule${schedNum}Name",[type:"text",value:""]) app.updateSetting("schedule${schedNum}Mode",[type:"enum",value:""]) app.updateSetting("schedule${schedNum}GridCharging",[type:"enum",value:""]) app.updateSetting("schedule${schedNum}Months",[type:"enum",value:""]) app.updateSetting("schedule${schedNum}Disable",[type:"bool",value:null]) app.updateSetting("schedule${schedNum}Days",[type:"enum",value:""]) app.updateSetting("schedule${schedNum}Stormwatch",[type:"enum",value:""]) app.updateSetting("schedule${schedNum}GridStatus",[type:"enum",value:""]) //app.updateSetting("schedule${schedNum}Time",[type:"text",value:""]) app.removeSetting("schedule${schedNum}Time") app.updateSetting("schedule${schedNum}Reserve",[type:"enum",value:""]) } void deleteScheduleIndex (Integer schedIndex) { Integer schedNum = state.scheduleList[schedIndex] logger ("Deleting schedule: ${schedIndex + 1}, number: ${schedNum}", "debug") state.scheduleNumUsed[schedNum-1] = false state.scheduleDeleted = true state.scheduleList.removeElement(schedNum) runIn(1, clearScheduleData, [data: [schedNum: schedNum]]) } Integer addNewSchedule() { Integer schedNumAdded if (state.scheduleList == null) { state.scheduleNumUsed = [] state.scheduleList = [] } //Look for an unused schedule Number if (state.scheduleNumUsed.size() > 0) { for (int i in 0 .. state.scheduleNumUsed.size() - 1) { if (!state.scheduleNumUsed[i]) { logger ("Re-using schedule number ${i + 1}","debug") schedNumAdded = i + 1 break } } } if (!schedNumAdded) { schedNumAdded = state.scheduleNumUsed.size() + 1 } state.scheduleNumUsed[schedNumAdded - 1] = true logger ("Adding new schedule as with number: ${schedNumAdded}", "debug") state.scheduleList[state.scheduleList.size()] = schedNumAdded } def pageDeleteSchedule() { dynamicPage(name: "pageDeleteSchedule", title: "", nextPage: "pageMain", uninstall: false, install: false) { section() { Integer schedIndex = state.editingScheduleIndex deleteScheduleIndex (schedIndex) paragraph "Schedule ${schedIndex + 1} has been deleted." } } } String formatDate(Long unixTime) { def dateObject = new Date(unixTime) String time = "" if (unixTime) { def df = new java.text.SimpleDateFormat("EEE MMM dd yyyy HH:mm a z") // Ensure the new date object is set to local time zone if (location.timeZone != null) { df.setTimeZone(location.timeZone) } else { logger ("No time zone found for hub..","warn") } time = df.format(dateObject) } return "${time}" } String getNextScheduleTime(Integer schedNum) { String nextExecTime = "(Next exec time: not scheduled)" try { Long currentTime = now() String nextTime = schedVal(schedNum,"Time") if (nextTime) { def nextTimeObject = Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", nextTime) Long nextTimeEpoch = nextTimeObject.getTime(); Long hr24 = 86_400_000 while(nextTimeEpoch < currentTime + hr24 * 365) { def date = new Date(nextTimeEpoch) if (nextTimeEpoch > currentTime && schedDayMonthValid(schedNum, getTheDay(date), getTheMonth(date))) { if (hubIsSt()) { nextExecTime = "Next exec time: ${formatDate(nextTimeEpoch)}" } else { nextExecTime = "(Next exec time: ${formatDate(nextTimeEpoch)})" } break } else { nextTimeEpoch = nextTimeEpoch + hr24 } } } } catch (Exception e) { logger ("Exception getting next scheduled time for schedNum ${schedNum}: ${e}", "warn") } return nextExecTime } String validatedAppender(Boolean valid) { String appender if (valid) { if (hubIsSt()) { appender = " (validated)" } else { appender = " (validated)" } } else { if (hubIsSt()) { appender = " (not validated)" } else { appender = " (not validated)" } } } private teslaAccountInfo() { state.serverVerified = false refreshAccessToken() getTeslaServerStatus() return dynamicPage(name: "teslaAccountInfo", title: "", install: false) { state.lastServerCheckTime = 0 // New data is being entered. Last server check is no longer valid if (hubIsSt() && accessTokenIp) { validateLocalUrl() } String pString if (hubIsSt()) { pString = "This app currently requires a Tesla token generated using another app, such as the Tesla Auth App for " + "IOS or Android, a web-based Tesla token generator, or from a script running on a local server. " } else { pString = "This app currently requires a Tesla token generated using another app, such as the Tesla Auth App for " + "IOS or " + "Android, " + "a web-based Tesla token generator, or from a script running on a local server. " } section ("Tesla Token Information") { paragraph pString input "inputRefreshToken", "text", title: "Refresh Token" + validatedAppender(state.refreshTokenSuccess), autoCorrect: false, required: false, submitOnChange : true if (state.siteSelector?.size() > 0 || settings.inputSite) { input(name: "inputSite", title: "Powerwall Site", type: "enum", required:false, multiple:false, options:state.siteSelector,submitOnChange : true) } } section(hideable: true, hidden: true, "OPTIONAL: You may alternatively supply an Access Token directly or serve one via a local server...") { paragraph ("If an Access Token is entered here, it must be updated periodically depending on the expiration date of the token provided (nominally every 45 days). " + "If a valid Refresh Token is entered above, the Access Token will be periodically overridden.") input "inputAccessToken", "text", title: "Access Token" + validatedAppender(validateInputToken()), autoCorrect: false, required: false, submitOnChange : true paragraph "You may configure a local server to generate and serve an Access Token. " + "If local server information is provided below, this app will query the local server as needed " + "for updated access token information. The token must be" + " provided by your server in a JSON 'access_token' attribute, eg {'access_token' : 'xxxxxx'}." if (hubIsSt()) { String tokenFromUrlStatus if (state.accessTokenFromUrlStatus && accessTokenIp) { tokenFromUrlStatus = "Current Status: " + state.accessTokenFromUrlStatus } else { tokenFromUrlStatus = "Select to enter local server address" } href "pageTokenFromUrl", title: "Enter local URL information..", description: tokenFromUrlStatus, required: false } else { String tokenFromUrlStatus = "" if (accessTokenUrl) { tokenFromUrlStatus = " - " + state.accessTokenFromUrlStatus } input "accessTokenUrl", "text", title: "URL on your local server to obtain access token (eg: http://192.168.1.100/tesla.html) ${tokenFromUrlStatus}", submitOnChange : true, autoCorrect: false, required: false } } } } def pageTokenFromUrl() { dynamicPage(name: "pageTokenFromUrl", title:"Get a token from a local URL.", install: false, uninstall: false) { section("") { state.useTokenFromUrl = false state.accessTokenFromUrlValid = false input "accessTokenIp", "text", title: "IP address of local server - eg: 192,168.1.30", autoCorrect: false, required: false input "accessTokenPath", "text", title: "Optional path on the local server - eg: /tesla/html", autoCorrect: false, required: false } } } private gatewayAccountInfo() { return dynamicPage(name: "gatewayAccountInfo", title: "", install: false) { state.lastGatewayCheckTime = 0 // New data is being entered. Last server check is no longer valid section("Local Gateway Information") { input "gatewayAddress", "text", title: "Powerwall Gateway IP local address (eg. 192.168.1.200)", required: false, submitOnChange: true input "gatewayPw", "password", title: "Gateway Customer Password", autoCorrect: false, required: false, submitOnChange: true } section() { if (gatewayAddress) { paragraph "${getLocalGwStatus()}" input "gatewayPing", "button", title: "Test ping gateway", submitOnChange: true, width: 3 if (state.gwPingResults && gatewayAddress==state.lastGwPingIp ) { String result if (state.gwPingResults.packetLoss) { result = "

Issue pinging ${state.lastGwPingIp} from Hubitat.

" } else { result = "

Hubitat successfully pinged ${state.lastGwPingIp}.

" } paragraph result, width: 9 paragraph "

${String.format('%tH:%" } } } } } String getConnectionMethodStatus() { String statusStr if (!connectionMethod) { statusStr = "Use Remote Tesla Account Server Only" } else { if (connectionMethod == "Use Local Gateway Only") { statusStr = connectionMethod.toString() + ".\n Note: A Tesla Server connection is required for full Powerwall Manager capabilities." } else { statusStr = connectionMethod.toString() } } return statusStr } def pageConnectionMethod() { dynamicPage(name: "pageConnectionMethod", title:"Choose how to connect to the Powerwall.", install: false, uninstall: false) { section("Connection Method") { input "connectionMethod", "enum", required: false, defaultValue: "Use Remote Tesla Account Server Only", title: "Connection Method", options: ["Use Remote Tesla Account Server Only", "Use Local Gateway Only", "Use Both Tesla Server and Local Gateway"] } } } def pageDashboardTile() { dynamicPage(name: "pageDashboardTile", title:"Powerwall Dashboard iFrame Tile", install: false, uninstall: false) { String note = "" if (gatewayTileAddress) { section { createDashboardTile() paragraph "This tile can be displayed on a dashboard using the Tesla Powerwall Device with the custom attribute 'pWTile'" paragraph getTileStr(0.5) } } else { note = "Enter address of gateway to create tile for dashboard:" } section(note) { input("gatewayTileAddress", "text", title: "Powerwall Gateway IP local address (eg. 192.168.1.200)", submitOnChange: true) } section { input("tileHeight", "number", title: "Height (default 517 pixels)", defaultValue: 517, submitOnChange: true, width: 4 ) input("tileWidth", "number", title: "Width (default 460 pixels)", defaultValue: 460, submitOnChange: true, width: 4) input("tileScale", "decimal", title: "Scale (default 0.81)", defaultValue: 0.81, submitOnChange: true, width: 4) } section{ note = "To view this attribute tile on your dashboard, you may need to first visit the gateway URL in your dashboard browser, " + "accept the self-signed certificate exception, and log in as 'customer'. Depending on your browser, you may be required to disable " + "'Prevent cross-site tracking' to view the gateway webpage frame." + "\n•Add to .css to remove extra tile padding in Fully Kiosk Browser: #tile-XXX .tile-contents {padding: 0; margin: -1px}" paragraph "${note}" } } } Boolean connectedToGateway() { return state.gatewayVerified && gatewayAddress } Boolean connectedToTeslaServer() { Boolean connectedViaInputToken = inputAccessToken && state.inputAccessTokenValid Boolean connectedViaTokenFromUrl = state.accessTokenFromUrlValid && ((hubIsSt() && accessTokenIp) || (!hubIsSt() && accessTokenUrl)) return state.serverVerified && (connectedViaInputToken || connectedViaTokenFromUrl) } String getTokenDateString() { if (inputAccessToken != state.lastInputAccessToken) { state.lastInputAccessToken = inputAccessToken state.tokenChangeTime = now() state.tokenAgeWarnSent = false } String msg = "" if (state.tokenChangeTime) { msg = "\nToken updated: ${formatDate(state.tokenChangeTime)}." if (state.tokenExpiration) { msg = msg + "\nExpires: ${formatDate(state.tokenExpiration)}." if (!state.scheduleRefreshToken && state.refreshSchedTime && now() < state.refreshSchedTime) { msg = msg + "\nRefresh scheduled: ${formatDate(state.refreshSchedTime)}." } else if (state.scheduleRefreshToken) { msg = msg + "\nToken Refresh pending." } else if (state.tokenExpiration && now() > state.tokenExpiration) { msg = msg + "\nExpired." } } } return msg } void refreshAccessToken(){ if (settings.inputRefreshToken && settings.inputRefreshToken != ""){ String currentRefreshToken = settings.inputRefreshToken String ssoAccessToken = "" Long expiresIn state.refreshTokenSuccess = false Map payload = ["grant_type":teslaBearerTokenGrantType,"refresh_token":currentRefreshToken, "client_id":teslaBearerTokenClientId, "scope":teslaBearerTokenScope] try{ logger ("Getting updated refresh token and bearer token for access token", "trace") logger ("Calling ${teslaBearerTokenEndpoint} with ${payload}","trace") httpPostJson([uri: teslaBearerTokenEndpoint, body: payload]){ resp -> Integer statusCode = resp.getStatus() if (statusCode == 200) { logger("Bearer access request data: ${resp.data}","trace") app.updateSetting("inputRefreshToken",[type:"text",value:resp.data["refresh_token"]]) ssoAccessToken = resp.data["access_token"] expiresIn = resp.data.expires_in.toLong() logger ("Successfully updated refresh token and bearer token for access token","debug") } else { logger ("Unable to update refresh token and bearer token for access token. Status code: ${statusCode}","warn") if (now() < state.tokenExpiration) { state.scheduleRefreshToken = true //Still time - try again later } } } } catch (Exception e){ logger ("Error getting Tesla server bearer token from refresh token: ${e}","warn") if (now() < state.tokenExpiration) { state.scheduleRefreshToken = true //Still time - try again later } } logger ("Getting updated access token and expiry", "debug") Map ownerPayload = ["grant_type":teslaAccessTokenAuthGrantType, "client_id":teslaAccessTokenAuthClientId] Map ownerApiHeaders = ["Authorization": "Bearer " + ssoAccessToken] try{ httpPostJson([uri: teslaAccessTokenEndpoint, headers: ownerApiHeaders, body: ownerPayload]){ resp -> Integer statusCode = resp.getStatus() if (statusCode == 200){ logger("Access Token access request data: ${resp.data}","trace") acceptAccessToken (resp.data["access_token"], resp.data.expires_in.toLong()) } else { logger ("Unable to update access token. Status code: ${statusCode}","debug") } } } catch (Exception e){ logger ("Issue getting Tesla server access token from bearer refresh token: ${e}","debug") //Use the sso token as is: if (ssoAccessToken && expiresIn) { acceptAccessToken (ssoAccessToken, expiresIn) } } } } void acceptAccessToken (String token, Long expiresIn) { app.updateSetting("inputAccessToken",[type:"text",value:token]) settings.inputAccessToken = token //ST workaround for immediate setting within dynamic page state.tokenExpiration = now() + expiresIn * 1000 def refreshDate = new Date(state.tokenExpiration) logger ("Token expires on ${refreshDate}.","debug") state.scheduleRefreshToken = true state.refreshTokenSuccess = true getTokenDateString() //Reset access token date status } String getTeslaServerStatus() { state.lastServerCheckTime = now() try { String messageStr = "" String tokenStatusStr = "" if (!hubIsSt() && accessTokenIp) { //Hubitat - local call is synchronous so can be done on this main page. For SmartThings //it is asynchronous, so needs to be done on the subpage and result will be available when on the main page validateLocalUrl() } state.useTokenFromUrl = state.accessTokenFromUrlValid state.useInputToken = validateInputToken() state.serverValidAtStartup = false Boolean tokenFromUrlEntered = (!hubIsSt() && accessTokenUrl) || (hubIsSt() && accessTokenIp) Boolean inputTokenEntered = inputAccessToken if (!inputTokenEntered && !tokenFromUrlEntered) { messageStr = "You are not connected to the Tesla server.\nEnter your Tesla account token.." } else { if (state.useInputToken || state.useTokenFromUrl) { getPowerwalls() if (state.serverVerified) { state.serverValidAtStartup = true messageStr = messageStr + "You are connected to the Tesla server." + "\nSite Name: ${state.siteName}, Id: ${state.pwId}." + getTokenDateString() } else { messageStr = "Error: No Powerwalls found on Tesla server\n" + "Please verify your Tesla Account access token." } } else { messageStr = messageStr + "Error Verifying Tesla/Powerwall Account\n" + "Please verify your Tesla account access token." } } //Display token status if they are both entered, or if a token failed validation if (inputTokenEntered && (!state.useInputToken || tokenFromUrlEntered)) { messageStr = messageStr + "\nInput Access Token: ${state.inputAccessTokenStatus}. " } if (tokenFromUrlEntered && (!state.useTokenFromUrl || inputTokenEntered)) { messageStr = messageStr + "\nToken from URL: ${state.accessTokenFromUrlStatus}." } state.serverStatusStr = messageStr return messageStr } catch (Exception e) { logger ("Error getting Tesla server status: ${e}","warn") state.serverStatusStr = "Error accessing Powerwall account\n" + "Please verify your Tesla account access token. ${e}" return state.serverStatusStr } } def gwHeader() { return ["Cookie" : "AuthCookie=${state.gwAuthCookie}; UserRecord=${state.gwUserRecord}"] } String getLocalGwStatus() { state.lastGatewayCheckTime = now() try { String messageStr state.gatewayVerified = false if (settings.gatewayAddress == null || settings.gatewayPw == null) { messageStr = "You are not connected to the local gateway.\nEnter your local gateway IP address and password.." } else { logger ("Connecting to local gateway...","debug") messageStr = "Could not log in to local gateway at ${gatewayAddress}" String gwUri = "https://${gatewayAddress}/api/login/Basic" logger("Posting to gateway URI: ${gwUri}","trace") httpPost([uri: gwUri, contentType: 'application/json', ignoreSSLIssues: true, query: [username: "customer", password : "${gatewayPw}"] ]) { resp -> Integer statusCode = resp.getStatus() logger("Gateway response status code: ${statusCode}","debug") if (statusCode == 200) { resp.headers.each { if (it.name == "Set-Cookie") { String str = it.value if (str.substring(0,10) == "UserRecord") { state.gwUserRecord = str.substring(str.indexOf("=") + 1, str.indexOf(";")) } else if (str.substring(0,10) == "AuthCookie") { state.gwAuthCookie = str.substring(str.indexOf("=") + 1, str.indexOf(";")) } } } messageStr = "Could not verify local gateway auth cookie ${gatewayAddress}" httpGet([uri: "https://${gatewayAddress}", path: "/api/site_info/site_name", headers: gwHeader(), contentType: 'application/json', ignoreSSLIssues: true]) { response -> logger("Local gateway connection verified","debug") state.gatewayVerified = true messageStr = "You are connected to the Powerwall Gateway.\n" + //"Connected at ${gatewayAddress}\n"+ "Site Name: ${response.data.site_name.toString()}." //"Gateway time zone: ${response.data.timezone.toString()}\n" } } else { messageStr = "Unable to login to gateway at: ${gatewayAddress}. Status: ${statusCode}" } } } state.gatewayStatusStr = messageStr return messageStr } catch (Exception e) { logger ("Error getting local gateway status: ${e}","warn") state.gatewayStatusStr = "Error accessing local gateway at: ${gatewayAddress}.\n" + "Please verify your gateway address and password. ${e}" return state.gatewayStatusStr } } def pageNotifications() { dynamicPage(name: "pageNotifications", title: "Notification Preferences", install: false, uninstall: false) { section("Notify me when..") { input "notifyWhenVersionChanges", "bool", required: false, defaultValue: false, title: "Powerwall software version changes" input "notifyWhenGridStatusChanges", "bool", required: false, defaultValue: false, title: "Grid status changes (power failures)" input "notifyWhenReserveApproached", "bool", required: false, defaultValue: false, title: "Powerwall charge level % drops to reserve percentage" input "notifyOfSchedules", "bool", required: false, defaultValue: false, title: "Schedules or charge % actions are being executed by the Powerwall Manager" input "notifyWhenModesChange", "bool", required: false, defaultValue: false, title: "Powerwall configuration (mode/schedule) changes are detected" input "notifyWhenAnomalies", "bool", required: false, defaultValue: true, title: "Anomalies are encountered in the Powerwall Manager" input "notifyWhenStormwatch", "bool", required: false, defaultValue: false, title: "Storm Watch mode is active" input "notifyOfTokenAge", "bool", required: false, defaultValue: true, title: "Access token has not been refreshed (40 days after entering)" } section() { if (hubIsSt()) { input "notificationMethod", "enum", required: false, defaultValue: "push", title: "Notification Method (push notifications are via mobile app)", options: ["none", "text", "push", "text and push"] input "phoneNumber", "phone", title: "Phone number for text messages", description: "Phone Number for text/SMS messages", required: false } else { //Hubitat input(name: "notifyDevices", type: "capability.notification", title: "Send to these notification devices", required: false, multiple: true, submitOnChange: true) } } } } def pagePwPreferences() { dynamicPage(name: "pagePwPreferences", title: "Powerwall Manager Preferences", install: false, uninstall: false) { section() { input "pollingPeriod", "enum", required: false, title: "Tesla server polling interval", defaultValue: "10 minutes (default)", options: ["Do not poll" : "Do not poll", "5 minutes" : "5 minutes", "10 minutes" : "10 minutes (default)", "30 minutes" : "30 minutes", "1 hour": "1 hour"] if (!hubIsSt()) { input "gatewayPollingPeriod", "enum", required: false, title: "Local gateway polling interval", defaultValue: "10 minutes (default)", options: ["Do not poll" : "Do not poll", "1 minute" : "1 minute", "5 minutes" : "5 minutes", "10 minutes" : "10 minutes (default)", "30 minutes" : "30 minutes", "1 hour": "1 hour"] } input "powerThreshold", "enum", required: false, title: "Power change threshold required for power report updates. (Values will always be updated if changed by more than 50%.)", defaultValue: "100 Watts (default)", options: ["10" : "10 Watts", "50" : "50 Watts", "100" : "100 Watts (default)", "500" : "500 Watts", "1000": "1000 Watts"] input "logLevel", "enum", required: false, title: "Log level", defaultValue: "Info (default)", options: ["none" : "No logging", "trace" : "Trace", "debug" : "Debug", "info" : "Info (default)", "warn" : "Warn", "error" : "Error"] label title: "Rename this app:", required: false } section () { paragraph "OPTIONAL: Child devices can be created in the Powerwall Manager Powerwall device preference settings (Hubitat Devices tab), providing " + "additional options for control and monitoring of Powerwall states and power levels via ${getHubType()}." } section (hideable: true, hidden: true, "Additional ${getHubType()} integration information...") { String builtIn String addtl if (hubIsSt()) { builtIn = "'Automations'" addtl = "Node-Red, WebCore, Sharp Tool" } else { builtIn = "'Simple Automation Rules'" addtl = "Node-Red, WebCore, Rule Machine, Sharp Tools" } paragraph "1) Enabling child devices in the Powerwall Manager Powerwall device preference settings allows for " + "control and monitoring of Powerwall states and power levels " + "via built-in ${getHubType()} apps such as ${builtIn}. Otherwise " + "rule engines (${addtl}, etc) that support custom commands and attributes are " + "required for extended integration of the Powerwall Manager with ${getHubType()}.\n" paragraph "2) The on/off switch and open/closed contact state of the Powerwall device can be used to indicate whether a grid outage has occured (on/closed=on-grid, off/open=grid-outage)." paragraph "3) The 'dimmer' level of the Powerwall device can be used to command and monitor the Powerwall reserve (0-100%)." paragraph "4) The battery level of the Powerwall device will reflect the Powerwall charge level (0-100%)." } } } def pageDevicesToControl() { dynamicPage(name: "pageDevicesToControl", title: "Control devices in the event of a grid outage", install: false, uninstall: false) { section("") { input "devicesToOffDuringOutage", "capability.switch", title: "Devices to turn off during a grid outage", required: false, multiple: true input "turnDevicesBackOnAfterOutage", "bool", required: false, defaultValue: false, title: "Turn the above selected devices back On after grid outage is over? (Note: If set, the devices will be turned On regardless of their state prior to the outage)" input "devicesToOnAfterOutage", "capability.switch", title: "Devices to turn on when the grid outage is over", required: false, multiple: true } } } String appendOnNewLine(message, textToAdd) { if (textToAdd) { if (message) { message = message + "\n" + textToAdd } else { message = textToAdd } } return message } def pageTriggers() { dynamicPage(name: "pageTriggers", title: "Powerwall battery charge % level above/below actions.", install: false, uninstall: false) { //state.timeOfLastBelowTrigger = null //state.timeOfLastAboveTrigger = null section ("") { String message = "" Boolean actionsOk state.triggerActionsActive = false //Above Actions actionsOk = actionsValid(aboveTriggerMode, aboveTriggerReserve, aboveTriggerStormwatch, aboveTriggerGridCharging, aboveTriggerDevicesToOn, aboveTriggerGridStatus) && aboveTriggerValue && aboveTriggerEnabled?.toBoolean() state.triggerActionsActive = actionsOk if (actionsOk) { String actionsString = getActionsString(aboveTriggerMode, aboveTriggerReserve, aboveTriggerStormwatch, aboveTriggerGridCharging, aboveTriggerDevicesToOn, aboveTriggerGridStatus) message = "When Powerwall is above ${aboveTriggerValue?.toString()}%:\n" + actionsString + "\n(notification will also be sent if enabled in preferences)" } else { message = "Select to enable Upper % charge level actions.." } href "pageTriggerOptions", title: "Choose actions to execute when the Powerwall battery charge % rises above a pre-defined level:", state : actionsOk ? "complete" : null, description: message, params : [aboveOrBelow : "above"] //Below Actions actionsOk = actionsValid(belowTriggerMode, belowTriggerReserve, belowTriggerStormwatch, belowTriggerGridCharging, belowTriggerDevicesToOff, belowTriggerGridStatus) && belowTriggerValue && belowTriggerEnabled?.toBoolean() state.triggerActionsActive = state.triggerActionsActive || actionsOk if (actionsOk) { String actionsString = getActionsString(belowTriggerMode, belowTriggerReserve, belowTriggerStormwatch, belowTriggerGridCharging, belowTriggerDevicesToOff, belowTriggerGridStatus) message = "When Powerwall is below ${belowTriggerValue?.toString()}%:\n" + actionsString + "\n(notification will also be sent if enabled in preferences)" } else { message = "Select to enable Lower % charge level actions.." } href "pageTriggerOptions", title: "Choose actions to execute when the Powerwall battery charge % drops below a pre-defined level:", state : actionsOk ? "complete" : null, description: message, params : [aboveOrBelow : "below"] //Restrict Options String restrictMessage = '' Boolean restrictionSet = true if (triggerRestrictPeriod1?.toBoolean() && triggerStartTime1 && triggerStopTime1) { restrictMessage = appendOnNewLine(restrictMessage, "Trigger Period 1: " + formatTimeString(triggerStartTime1) + " to " + formatTimeString(triggerStopTime1)) } if (triggerRestrictPeriod2?.toBoolean() && triggerStartTime2 && triggerStopTime2) { restrictMessage = appendOnNewLine(restrictMessage, "Trigger Period 2: " + formatTimeString(triggerStartTime2) + " to " + formatTimeString(triggerStopTime2)) } if (triggerRestrictDays?.toBoolean() && triggerDays?.size() > 0) { restrictMessage = appendOnNewLine(restrictMessage, triggerDays.toString()) } if (restrictMessage == '') { restrictMessage = "No optional schedule restrictions defined.." restrictionSet = false } href "triggerRestrictions", title: "Restrict these triggers to specific times/days (optional):", state : restrictionSet ? "complete" : null, description: restrictMessage } } } def triggerRestrictions() { dynamicPage(name: "triggerRestrictions", title: "Battery Charge % Level Trigger period restrictions", install: false, uninstall: false) { section("") { input "triggerRestrictDays", "bool", required: false, defaultValue: false, title: "Restrict % battery trigger actions to only occur on specified days" input "triggerDays", "enum", required: false, title: "Only on these days...", multiple: true, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] } section("Restrict % battery trigger actions to only occur during specified time periods:") { input "triggerRestrictPeriod1", "bool", required: false, defaultValue: false, title: "Enable Time Period 1" input "triggerStartTime1", "time", required: false, title: "Time Period 1 Start Time" input "triggerStopTime1", "time", required: false, title: "Time Period 1 End Time" input "triggerRestrictPeriod2", "bool", required: false, defaultValue: false, title: "Enable Time Period 2" input "triggerStartTime2", "time", required: false, title: "Time Period 2 Start Time" input "triggerStopTime2", "time", required: false, title: "Time Period 2 Stop Time" } } } def pagePwActions(params) { String prefix = params.prefix String title = params.title if (params.prefix == null) { logger ("Unexpected param for PW action: ${params}","warn") prefix = state.lastPwActionPrefix } state.lastPwActionPrefix = prefix dynamicPage(name: "pagePwActions", title: title, install: false, uninstall: false) { section() { input "${prefix}Mode", "enum", submitOnChange: true, required: false, title: "Set Mode", options: ["No Action", "Self-Powered", "Time-Based Control", "Backup-Only"] if (settings["${prefix}Mode"] == "Backup-Only") { paragraph "Warning: As of September 2021, Backup-Only has been deprecated by Tesla. Tesla recommends setting to Self-Powered " + "with a Reserve of 100% instead." } input "${prefix}Reserve", "enum", required: false, title: "Set Reserve %", options: ["No Action": "No Action", "0": "0%", "5": "5%", "10": "10%", "15": "15%", "20": "20%", "25": "25%", "30": "30%", "35": "35%", "40": "40%", "45": "45%", "50": "50%", "55": "55%", "60": "60%", "65": "65%", "70": "70%", "75": "75%", "80": "80%", "85": "85%", "90": "90%", "95": "95%", "100": "100%"] input "${prefix}Stormwatch", "enum", required: false, title: "Set Storm Watch mode enable/disable", options: ["No Action", "Enable Stormwatch", "Disable Stormwatch"] input "${prefix}GridCharging", "enum", required: false, title: "Set Grid Charging enable/disable", options: ["No Action", "Enable Grid Charging","Disable Grid Charging"] if (!hubIsSt()){ input "${prefix}GridStatus", "enum", required: false, title: "Set Grid Status", options: ["No Action", "Go On Grid","Go Off Grid"] } } } } def pageTriggerOptions(params) { String aboveBelow = params ? params.aboveOrBelow : state.aboveOrBelow state.aboveOrBelow = aboveBelow dynamicPage(name: "pageTriggerOptions", title: "Select '${aboveBelow}' Powerwall Charge % Level Trigger Options", install: false, uninstall: false) { section("") { input "${aboveBelow}TriggerEnabled", "bool", required: false, defaultValue: false, title: "Enable these actions" input "${aboveBelow}TriggerValue", "number", required: false, title: "Actions trigger when charge % is ${aboveBelow} this value:" String onOrOff = aboveBelow == "above" ? "On" : "Off" input "${aboveBelow}TriggerDevicesTo${onOrOff}", "capability.switch", title: "Select devices to turn ${onOrOff} when charge level % is ${aboveBelow} defined trigger", required: false, multiple: true Boolean complete = actionsValid(settings["${aboveBelow}TriggerMode"], settings["${aboveBelow}TriggerReserve"],settings["${aboveBelow}TriggerStormwatch"],settings["${aboveBelow}TriggerGridCharging"], null, settings["${aboveBelow}TriggerGridStatus"]) String actionsString if (complete) { actionsString = getActionsString(settings["${aboveBelow}TriggerMode"], settings["${aboveBelow}TriggerReserve"],settings["${aboveBelow}TriggerStormwatch"], settings["${aboveBelow}TriggerGridCharging"], null,settings["${aboveBelow}TriggerGridStatus"]) } else { actionsString = "No Powerwall actions defined.." } href "pagePwActions", title: "Select Powerwall actions to apply when charge level % is ${aboveBelow} defined trigger", state: complete ? "complete" : null, description: actionsString, params: [prefix: "${aboveBelow} Trigger", title : "Select ${aboveBelow} trigger Powerwall actions to apply:"] } } } Boolean hubIsSt() { return (getHubType() == "SmartThings") } def getPwDevice() { def deviceIdStr = null def device if (state.childDeviceId) { deviceIdStr = state.childDeviceId device = getChildDevice(deviceIdStr) } if (!device) { def devices = getChildDevices() if (devices.size() > 0) { deviceIdStr = getChildDevices().first().getDeviceNetworkId() state.childDeviceId = deviceIdStr device = getChildDevice(deviceIdStr) } } return device } private getHubType() { String hubType = "SmartThings" if (state.hubType == null) { try { include 'asynchttp_v1' } catch (e) { hubType = "Hubitat" } state.hubType = hubType } return state.hubType } Boolean actionsValid(modeSetting, reserveSetting, stormwatchSetting, gridChargingSetting, devicesToControl, gridStatus) { logger("Mode setting: ${modeSetting}, Reserve: ${reserveSetting}, Stormwatch: ${stormwatchSetting}, GridCharging: ${gridChargingSetting}, devicesToControl: ${devicesToControl}, Grid Status: ${gridStatus}","debug") return ((modeSetting && modeSetting.toString() != "No Action") || (reserveSetting && reserveSetting.toString() != "No Action") || (stormwatchSetting && stormwatchSetting.toString() != "No Action") || (gridChargingSetting && gridChargingSetting.toString() != "No Action") || (gridStatus && gridStatus.toString() != "No Action") || (devicesToControl && devicesToControl.toString() != "N/A" && devicesToControl.size() > 0)) } Boolean scheduleValid(timeSetting, daysSetting) { return timeSetting != null && daysSetting != null && (daysSetting.size() > 0 || daysSetting.toString() == "N/A") } String formatTimeString(timeSetting) { def timeFormat = new java.text.SimpleDateFormat("hh:mm a") String isoDatePattern = "yyyy-MM-dd'T'HH:mm:ss.SSS" def isoTime = new java.text.SimpleDateFormat(isoDatePattern).parse(timeSetting.toString()) return timeFormat.format(isoTime).toString() } String getWhenString(timeSetting, daysSetting, monthSetting) { String str if (scheduleValid(timeSetting, daysSetting)) { String timeString = '' if (timeSetting != "N/A") { timeString = formatTimeString(timeSetting) + ' ' } String dayString = '' if (daysSetting != "N/A") { dayString = daysSetting.toString() } if (timeString != '' || dayString != '') { str = timeString + dayString } if (monthSetting && monthSetting != '' & monthSetting != "N/A" ) { str = str + "\nMonths: " + monthSetting.toString() } } else { str = "Requires time and days to be set. Select to add.." } return str } String getActionsString(modeSetting, reserveSetting, stormwatchSetting, gridChargingSetting, controlDevices, gridStatusSetting) { String str = '' if (actionsValid(modeSetting, reserveSetting, stormwatchSetting, gridChargingSetting, controlDevices, gridStatusSetting)) { if (modeSetting && modeSetting.toString() != "No Action") { str = "Mode: " + modeSetting.toString() } if (reserveSetting && reserveSetting.toString() != "No Action") { str = appendOnNewLine(str, "Reserve: " + reserveSetting.toString() + '%') } if (stormwatchSetting && stormwatchSetting.toString() != "No Action") { if (stormwatchSetting.toString() == "Enable Stormwatch") { str = appendOnNewLine(str, "Stormwatch: Enable") } else if (stormwatchSetting.toString() == "Disable Stormwatch") { str = appendOnNewLine(str, "Stormwatch: Disable") } } if (gridChargingSetting && gridChargingSetting.toString() != "No Action") { if (gridChargingSetting.toString() == "Enable Grid Charging") { str = appendOnNewLine(str, "Grid Charging: Enable") } else if (gridChargingSetting.toString() == "Disable Grid Charging") { str = appendOnNewLine(str, "Grid Charging: Disable") } } if (controlDevices && controlDevices.size() > 0) { str = appendOnNewLine(str, "Control Devices: ${controlDevices}") } if (gridStatusSetting && gridStatusSetting.toString() != "No Action") { str = appendOnNewLine(str, "Grid Status: " + gridStatusSetting.toString()) } } else { str = "At least one action is required. Select to add.." } return str } void setSchedules() { if (hubIsSt()) { for(int i in 1 .. maxSmartThingsSchedules) { unschedule("processSchedule${i}") } } else { unschedule ("processSchedule") //possible minor Hubitat bug - pass method as string otherwise will unschedule everything if method does not exist } if (state.scheduleList) { for(int i in 0 .. state.scheduleList.size() - 1) { Integer schedNum = state.scheduleList[i] if (!(schedVal(schedNum,"Disable") == "true")) { if (actionsValid(schedVal(schedNum,"Mode"), schedVal(schedNum,"Reserve"), schedVal(schedNum,"Stormwatch"), schedVal(schedNum,"GridCharging"), null, schedVal(schedNum,"GridStatus"))) { if (scheduleValid(schedVal(schedNum,"Time"), schedVal(schedNum,"Days"))) { logger ("Scheduling index: ${i + 1} num: ${schedNum} for time ${schedVal(schedNum,"Time")}","debug") if (hubIsSt()) { schedule(schedVal(schedNum,"Time"), "processSchedule${schedNum}", [data: [schedNum: schedNum]]) //overwite is not working on ST } else { schedule(schedVal(schedNum,"Time"), processSchedule, [data: [schedNum: schedNum], overwrite: false]) } //schedule(schedVal(schedNum,"Time"), processSchedule, [data: [schedNum: schedNum], overwrite: false]) //[data: [message: msg], overwrite: false] } else { String msg = "Powerwall Manager Schedule index: ${i + 1} num: ${schedNum}. Actions are enabled in preferences, but schedule time and/or days were not specified. Schedule could not be set." logger (msg,"warn") //sendNotificationMessage(msg, "anomaly") } } } else { logger ("Schedule index: ${i + 1} num ${schedNum} is disabled","debug") } } } } String getTheDay(date=null) { if (!date) { date = new Date() } def df = new java.text.SimpleDateFormat("EEEE") // Ensure the new date object is set to local time zone if (location.timeZone != null) { df.setTimeZone(location.timeZone) } else { logger ("no time zone found for hub - schedule processing day","warn") } return df.format(date) } String getTheMonth(date=null) { if (!date) { date = new Date() } def mf = new java.text.SimpleDateFormat("MMMM") if (location.timeZone != null) { mf.setTimeZone(location.timeZone) } else { logger ("no time zone found for hub - schedule processing month","warn") } return mf.format(date) } //Hubitat compatibility private timeOfDayIsBetween(fromDate, toDate, checkDate, timeZone) { return (!checkDate.before(toDateTime(fromDate)) && !checkDate.after(toDateTime(toDate))) } Boolean triggerPeriodActive() { String day = getTheDay() Boolean daysAreSet = triggerRestrictDays?.toBoolean() && triggerDays?.size() > 0 Boolean dayIsActive = daysAreSet && triggerDays?.contains(day) Boolean aPeriodIsSet = (triggerRestrictPeriod1?.toBoolean() || triggerRestrictPeriod2?.toBoolean()) Boolean aPeriodIsActive = (triggerRestrictPeriod1?.toBoolean() && timeOfDayIsBetween(triggerStartTime1, triggerStopTime1, new Date(), location.timeZone)) || (triggerRestrictPeriod2?.toBoolean() && timeOfDayIsBetween(triggerStartTime2, triggerStopTime2, new Date(), location.timeZone)) //Valid conditions: // 1) day matches & period active, 2) day matches & no periods declared, 3) no day is set & period active, 4) no day is set & no periods declared return ((dayIsActive && (aPeriodIsActive || !aPeriodIsSet)) || (!daysAreSet && (aPeriodIsActive || !aPeriodIsSet))) } String commandPwActions(mode, reserve, stormwatch, gridCharging, enableChargeTriggers, gridStatus) { def pwDevice = getPwDevice() String message = "" if (mode && mode.toString() != "No Action") { message = message + " Mode: ${mode.toString()}." if (mode.toString() == "Backup-Only") { setBackupOnlyMode(pwDevice) //runIn(2, commandBackupReservePercent, [data: [reservePercent: 100]]) //String errMessage = "Backup-Only mode no longer supported by Powerwall. Setting reserve to 100%" //sendNotificationMessage(errMessage, "anomaly") } else if (mode.toString() == "Self-Powered") { setSelfPoweredMode(pwDevice) } else if (mode.toString() == "Time-Based Control") { setTimeBasedControlMode(pwDevice) } else { String errMessage = "Unexpected condition processing scheduled mode change: ${mode.toString()}" sendNotificationMessage(errMessage, "anomaly") } } if (reserve && reserve.toString() != "No Action") { message = message + " Reserve: ${reserve}%." if (reserve.toInteger() >= 0 && reserve.toInteger() <= 100) { runIn(10, commandBackupReservePercent, [data: [reservePercent: reserve.toInteger()]]) } else { String errMessage = "Unexpected condition processing scheduled reserve % change: ${reserve}}" sendNotificationMessage(errMessage, "anomaly") } } if (stormwatch && stormwatch.toString() != "No Action") { if (stormwatch.toString() == "Enable Stormwatch") { runIn(15, commandStormwatchEnable) message = message + " Stormwatch: Enabled." } else if (stormwatch.toString() == "Disable Stormwatch") { message = message + " Stormwatch: Disabled." runIn(15, commandStormwatchDisable) } } if (gridCharging && gridCharging.toString() != "No Action") { message = message + " Grid Charging: ${gridCharging.toString()}." if (gridCharging.toString() == "Enable Grid Charging") { runIn(20, commandGridChargingEnable) } else if (gridCharging.toString() == "Disable Grid Charging") { runIn(20, commandGridChargingDisable) } else { String errMessage = "Unexpected condition processing scheduled gridCharging change: ${gridCharging.toString()}" sendNotificationMessage(errMessage, "anomaly") } } if (enableChargeTriggers && enableChargeTriggers.toString() != "No Action") { if (enableChargeTriggers.toString() == "Turn On Peak") { message = message + " Virtual Peak Switch: On." } else if (stormwatch.toString() == "Disable Triggers") { message = message + " Virtual Peak Switch: Off." } } if (gridStatus && gridStatus.toString() != "No Action") { if (gridStatus.toString() == "Go On Grid") { runIn(2, commandGoOffGrid, [data: [isOnGrid:true]]) message = message + " Going On Grid." } else if (gridStatus.toString() == "Go Off Grid"){ runIn(2, commandGoOffGrid, [data: [isOnGrid:false]]) message = message + " Going Off Grid." } } return message } Boolean schedDayMonthValid (Integer schedNum, String day, String month) { Boolean monthValid = !schedVal(schedNum,"Months") || schedVal(schedNum,"Months").contains(month) Boolean dayValid = schedVal(schedNum,"Days") && schedVal(schedNum,"Days").contains(day) return dayValid && monthValid && !(schedVal(schedNum,"Disable") == "true") } void processSchedule(data) { Integer schedNum = data.schedNum if (schedDayMonthValid(schedNum, getTheDay(), getTheMonth())) { logger ("Executing schedule number ${schedNum}","debug") String message = commandPwActions(schedVal(schedNum,"Mode"), schedVal(schedNum,"Reserve"), schedVal(schedNum,"Stormwatch"), schedVal(schedNum,"GridCharging"), null, schedVal(schedNum,"GridStatus")) if (notifyOfSchedules?.toBoolean()) { sendNotificationMessage("Performing scheduled Powerwall actions. " + message) } } else { logger ("Schedule number: ${schedNum} not executing due to day/month/disable criteria", "debug") } } // SmartThings requires explicit schedule declarations since schedule: overwite appears to not work. This currently limits the schedule count in ST. void processSchedule1(data) { processSchedule (data) } void processSchedule2(data) { processSchedule (data) } void processSchedule3(data) { processSchedule (data) } void processSchedule4(data) { processSchedule (data) } void processSchedule5(data) { processSchedule (data) } void processSchedule6(data) { processSchedule (data) } void processSchedule7(data) { processSchedule (data) } void processSchedule8(data) { processSchedule (data) } void processSchedule9(data) { processSchedule (data) } void processSchedule10(data) { processSchedule (data) } void processSchedule11(data) { processSchedule (data) } void processSchedule12(data) { processSchedule (data) } void processSchedule13(data) { processSchedule (data) } void processSchedule14(data) { processSchedule (data) } void processSchedule15(data) { processSchedule (data) } private getId() { "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384" } private getSecret() { "c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3" } private getAgent() { "TeslaApp/4.10.0" //"darwinsden/hubitat" } String getToken() { String returnToken = null if (state.useInputToken) { returnToken = inputAccessToken } else if (state.useTokenFromUrl) { returnToken = state.accessTokenFromUrl } return returnToken } private httpAsyncGet (handlerMethod, String url, String path, query=null) { try { def requestParameters = [uri: url, path: path, query: query, contentType: 'application/json'] if(hubIsSt()) { include 'asynchttp_v1' asynchttp_v1.get(handlerMethod, requestParameters) } else { asynchttpGet(handlerMethod, requestParameters) } } catch (e) { log.error "Http Get failed: ${e}" } } private httpAuthAsyncGet(handlerMethod, String path, Integer attempt = 1) { String theToken = getToken() if (theToken) { try { logger ("Async requesting: ${path}","trace") def requestParameters = [ uri: teslaUrl, path: path, headers: ['User-Agent': agent, 'X-Tesla-User-Agent': agent, Authorization: "Bearer ${theToken}"] ] if (hubIsSt()) { include 'asynchttp_v1' asynchttp_v1.get(handlerMethod, requestParameters, [attempt: attempt]) } else { asynchttpGet(handlerMethod, requestParameters, [attempt: attempt]) } } catch (e) { log.error "Http Async Get failed: ${e}" } } else { logger("Async request to ${path} not sent. Token is invalid","warn") } } private httpAuthGet(String path, Closure closure, authToken = null) { //There is no exception handling here, so that the exception can be uniquely handled by the calling method. if (authToken == null) { authToken = token } def requestParameters = [uri: teslaUrl, path: path, headers: ['User-Agent': agent, 'X-Tesla-User-Agent': agent, Authorization: "Bearer ${authToken}"]] httpGet(requestParameters) {resp -> closure(resp)} } private httpAuthPost(Map params = [:], String cmdName, String path, Closure closure, Integer attempt = null) { //cmdName is descriptive name for logging/notification Integer tryCount = attempt ?: 1 String attemptStr = "" if (tryCount > 1) { attemptStr = ", Attempt: ${tryCount}" } String authToken = getToken() if (authToken) { logger ("Command: ${cmdName} ${params?.body}" + attemptStr,"debug") try { def requestParameters = [uri: teslaUrl, path: path, headers: ['User-Agent': agent, 'X-Tesla-User-Agent': agent, Authorization: "Bearer ${authToken}"]] if (params.body) { requestParameters["body"] = params.body httpPostJson(requestParameters) {resp -> closure(resp)} } else { httpPost(requestParameters) {resp -> closure(resp)} } state.cmdFailedSent = false } catch (groovyx.net.http.HttpResponseException e) { if (tryCount < 3) { logger ("Request attempt ${tryCount} failed for path: ${path}. HTTP status code: ${e?.response?.getStatus()}","debug") if (e?.response?.getStatus() == 401) { handleServerAuthIssue() pause(2000) } pause(1000) httpAuthPost(params, cmdName, path, closure, tryCount + 1) } else { logger ("Request failed after ${tryCount} attempts for path: ${path}. HTTP status code: ${e?.response?.getStatus()}","warn") if (!state.cmdFailedSent) { sendNotificationMessage("Powerwall Manager: Failed HTTP command: ${cmdName} after ${tryCount} tries.") state.cmdFailedSent = true } } } catch (Exception e) { if (tryCount < 3) { logger ("Request attempt ${tryCount} failed for path: ${path}. General Exception: ${e}","debug") pause(1000) httpAuthPost(params, cmdName, path, closure, tryCount + 1) } else { logger ("Request failed after ${tryCount} attempts for path: ${path}. General Exception: ${e}","warn") if (!state.cmdFailedSent) { sendNotificationMessage("Powerwall Manager: Failed command: ${cmdName} after ${tryCount} tries.") state.cmdFailedSent = true } } } } else { logger ("Cannot send command: ${cmdName}. Token is not valid","warn") } } private sendNotificationMessage(message, msgType = null) { logger ("notification message: ${message}","debug") if (msgType == null || msgType != "anomaly" || notifyWhenAnomalies?.toBoolean()) { if (hubIsSt()) { Boolean sendPushMessage = (!notificationMethod || (notificationMethod.toString() == "push" || notificationMethod.toString() == "text and push")) Boolean sendTextMessage = (notificationMethod?.toString() == "text" || notificationMethod?.toString() == "text and push") if (sendTextMessage == true) { if (phoneNumber) { sendSmsMessage(phoneNumber.toString(), message) } } if (sendPushMessage) { sendPush(message) } } else { // Hubitat if (notifyDevices != null) { notifyDevices.each { it.deviceNotification(message) } } } } } private getPowerwalls() { state.serverVerified = false state.siteSelector = [:] Boolean foundPowerwall = false try { httpAuthGet("/api/1/products", { resp -> logger ("response data for products is ${resp.data}","trace") resp.data.response.each { product -> if (product.resource_type == "battery") { state.siteSelector[product.energy_site_id] = "${product.energy_site_id} - ${product.id} - ${product.site_name}" //do not consider battery site if its site_name is null and a battery has previously been found (possibly a bad second site in the database) if (settings.inputSite.toString() == product.energy_site_id.toString() || (!settings.inputSite && (product.site_name != null || !foundPowerwall))) { foundPowerwall = true app.updateSetting("inputSite",[type:"enum",value:product.energy_site_id.toString()]) settings.inputSite = product.energy_site_id.toString() //ST workaround for immediate setting within dynamic page logger ("battery found: ${product.id} site_name: ${product.site_name} energy_site_id: ${product.energy_site_id}","debug") state.energySiteId = product.energy_site_id state.pwId = product.id state.siteName = product.site_name } } } }) } catch (Exception e) { log.error "Exception checking for Powerwalls: ${e}" } state.serverVerified = foundPowerwall } def installed() { log.debug("${app.label} installed.") runIn (1, initialize) } def updated() { logger ("${app.label} updated","debug") initialize() } def uninstalled() { logger ("uninstalling","info") removeChildDevices(getChildDevices()) } private removeChildDevices(delete) { delete.each { deleteChildDevice(it.deviceNetworkId) } } void pollProcedure(String period, def procedure) { switch(period) { case "1 minute": runEvery1Minute(procedure) break case "5 minutes": runEvery5Minutes(procedure) break case "10 minutes": runEvery10Minutes(procedure) break case "30 minutes": runEvery30Minutes(procedure) break case "1 hour": runEvery1Hour(procedure) break case "Do not poll": break default: runEvery10Minutes(procedure) break } } void startPollingServer() { pollProcedure(pollingPeriod, processServerMain) } void startPollingGateway() { pollProcedure(gatewayPollingPeriod, processGatewayMain) } def initialize() { unsubscribe() unschedule() state.lastHeartbeatUpdateTime = [:] createDeviceForPowerwall() setSchedules() schedule(new Date(), versionCheck) if (gatewayTileAddress) { runIn (10, createDashboardTile) } //stagger server and gateway polling runIn (30, startPollingServer) if (connectedToGateway()) { startPollingGateway() } runEvery3Hours(processWatchdog) //runEvery3Hours(refreshAccessToken) runIn(10, processServerMain) runIn(15, processGatewayMain) if (state.tokenExpiration) { state.scheduleRefreshToken = true } } private createDeviceForPowerwall() { def pwDevice = getPwDevice() if (!pwDevice) { String dni = "Powerwall-" + app.id.toString() log.debug "creating Powerwall device dni: ${dni}" def device = addChildDevice("darwinsden", "Tesla Powerwall", dni, null, [name: "Tesla Powerwall", label: "Powerwall", completedSetup: true ]) } else { logger ("device for Powerwall exists","trace") pwDevice.initialize() } } void createDashboardTile() { def pwDevice = getPwDevice() logger ("creating/updating tile...","debug") if (pwDevice) { String tileStr = getTileStr(tileScale?.toFloat()) pwDevice.sendEvent(name: "pwTile", value: tileStr) } else { logger("Unable to update Dashboard tile. Powerwall device does not exist.","warn") } } String versionDetails () { String vers = app.version() if (newerVersionExists(state.latestStableVersion, app.version())) { String latestVersion if (!hubIsSt()) { latestVersion = "${state.latestStableVersion}" } else { latestVersion = state.latestStableVersion } vers = vers + " (${latestVersion} is available)" } return vers } String stripVerPrefix(String ver) { if (ver && (ver.substring(0,1) == 'v' || ver.substring(0,1) == 'V')) { ver = ver.substring(1,ver.size() - 1) } return ver } Boolean newerVersionExists(latest, current) { Boolean isNewer = false if (latest && current) { List latV = stripVerPrefix(latest).tokenize('.') List curV = stripVerPrefix(current).tokenize('.') if (latV.size() >= 3 && curV.size() >= 3) { isNewer = !(curV[0] >= latV[0] && curV[1] >= latV[1] && curV[2] >= latV[2]) } } return isNewer } void versionCb (resp, callData) { if (resp.status == 200) { if (resp.getJson().apps) { state.latestStableVersion = resp.getJson().apps[0]?.version if (newerVersionExists(state.latestStableVersion, app.version())) { if (!state.newVerLogged) { logger ("${app.label} new version ${state.latestStableVersion} is available.","info") state.newVerLogged = true } } else { state.newVerLogged = false } } } } void versionCheck() { state.latestStableVersion = null httpAsyncGet('versionCb',versionUrl,null,null) } Boolean updateIfChanged(device, attr, value, delta = null) { def currentValue = null if (state.currentAttrValue == null) { state.currentAttrValue = [:] } if (state.currentAttrValue[attr] != null) { currentValue = state.currentAttrValue[attr].toString() } Boolean deltaMet = (currentValue == null || value != null && delta != null && Math.abs((value.toInteger() - currentValue.toInteger()).toInteger()) > delta.toInteger()) Boolean changed = value != null && value != '' && currentValue != null && currentValue != '' && value.toString() != currentValue.toString() && (!delta || deltaMet) logger ("${attr} is: ${value} was: ${currentValue} changed: ${changed}","trace") Boolean heartBeatUpdateDue = false if (state.lastHeartbeatUpdateTime == null) { state.lastHeartbeatUpdateTime = [:] } if (state.lastHeartbeatUpdateTime[attr] == null || now() - state.lastHeartbeatUpdateTime[attr] > 3600000) { heartBeatUpdateDue = true } if (changed || heartBeatUpdateDue || (currentValue == null && (value != null && value != ''))) { state.currentAttrValue[attr] = value.toString() state.lastHeartbeatUpdateTime[attr] = now() if (device) { device.sendEvent(name: attr, value: value) } else { logger("No Powerwall device to update ${attr} to ${value}","warn") } } return changed } void processAboveTriggerDeviceActions() { if (aboveTriggerDevicesToOn?.size()) { aboveTriggerDevicesToOn.on() } } void processBelowTriggerDeviceActions() { if (belowTriggerDevicesToOff?.size()) { belowTriggerDevicesToOff.off() } } void checkBatteryNotifications(data) { if (notifyWhenReserveApproached?.toBoolean() && data.reservePercent != null) { reservePct = data.reservePercent.toInteger() if (reservePct != 100 && data.batteryPercent - reservePct < 5) { String status if (data.batteryPercent <= reservePct) { status = "is at or below" } else { status = "is approaching" } if (state.timeOfLastReserveNotification == null) { state.timeOfLastReserveNotification = now() sendNotificationMessage( "Powerwall battery level of ${Math.round(data.batteryPercent*10)/10}% ${status} ${reservePct}% reserve level.") } } else if (state.timeOfLastReserveNotification != null && now() - state.timeOfLastReserveNotification >= 30 * 60 * 1000) { //reset for new notification if alert condition no longer exists and it's been at least 30 minutes since last notification state.timeOfLastReserveNotification = null } } if (aboveTriggerValue) { if (data.batteryPercent >= aboveTriggerValue.toFloat()) { if (state.timeOfLastAboveTrigger == null) { if (triggerPeriodActive() && aboveTriggerEnabled) { state.timeOfLastAboveTrigger = now() String triggerMessage = "Powerwall ${Math.round(data.batteryPercent*10)/10}% battery level is at or above ${aboveTriggerValue}% trigger." if (actionsValid(aboveTriggerMode, aboveTriggerReserve, aboveTriggerStormwatch, aboveTriggerGridCharging, aboveTriggerDevicesToOn, aboveTriggerGridStatus)) { String message = commandPwActions(aboveTriggerMode, aboveTriggerReserve, aboveTriggerStormwatch, aboveTriggerGridCharging, null, aboveTriggerGridStatus) if (aboveTriggerDevicesToOn?.size() > 0) { message = message + " Turning on devices." runIn(1, processAboveTriggerDeviceActions) } triggerMessage = triggerMessage + " Performing actions. " + message } if (notifyOfSchedules?.toBoolean()) { sendNotificationMessage(triggerMessage) } } } } else if (state.timeOfLastAboveTrigger != null && now() - state.timeOfLastAboveTrigger >= 30 * 60 * 1000) { //reset for new trigger if condition no longer exists and it's been at least 30 minutes since last trigger state.timeOfLastAboveTrigger = null } } if (belowTriggerValue) { if (data.batteryPercent <= belowTriggerValue.toFloat()) { if (state.timeOfLastBelowTrigger == null) { if (triggerPeriodActive() && belowTriggerEnabled) { state.timeOfLastBelowTrigger = now() String triggerMessage = "Powerwall ${Math.round(data.batteryPercent*10)/10}% battery level is at or below ${belowTriggerValue}% trigger." if (actionsValid(belowTriggerMode, belowTriggerReserve, belowTriggerStormwatch, belowTriggerGridCharging, belowTriggerDevicesToOff, belowTriggerGridStatus)) { String message = commandPwActions(belowTriggerMode, belowTriggerReserve, belowTriggerStormwatch, belowTriggerGridCharging, null, belowTriggerGridStatus) if (belowTriggerDevicesToOff?.size() > 0) { message = message + " Turning off devices." runIn(1, processBelowTriggerDeviceActions) } triggerMessage = triggerMessage + " Performing actions. " + message } if (notifyOfSchedules?.toBoolean()) { sendNotificationMessage(triggerMessage) } } } } else if (state.timeOfLastBelowTrigger != null && now() - state.timeOfLastBelowTrigger >= 30 * 60 * 1000) { //reset for new trigger if condition no longer exists and it's been at least 30 minutes since last trigger state.timeOfLastBelowTrigger = null } } } String getTileStr(Float zoomLevel) { String tileStr = "" if (gatewayTileAddress) { long width = tileWidth?.toLong() ?: 460 long height = tileHeight?.toLong() ?: 517 float frameScale = zoomLevel?.toFloat() ?: 0.81 String innerDivStyle = "overflow: hidden; transform: scale(${frameScale}); transform-origin: 0 0; border: none; padding: 0; margin: 0;" String outerDivStyle = "height: ${(height*frameScale).toLong()}px; width: ${width-16}px; overflow: hidden; border: none; padding: 0; margin: 0;" String iframeStyle = "height: ${height}px; width: ${width}px; border: none; scrollbar-width: none; overflow: hidden; border: none; padding: 0; margin: 0;" tileStr = "

" } else { tileStr = "Gateway address not entered" } return tileStr } void processGwMeterResponse(response, callData) { logger ("processing gateway meter aggregate response","debug") if (!response.hasError()) { def data = response.json logger ("Gw meter agg: ${data}","trace") def child = getPwDevice() Integer powerDelta = settings.powerThreshold?.toInteger() ?: 100 if (updateIfChanged(child, "loadPower", data.load.instant_power.toInteger(), powerDelta) | updateIfChanged(child, "gridPower", data.site.instant_power.toInteger(), powerDelta) | updateIfChanged(child, "power", data.site.instant_power.toInteger(), powerDelta) | updateIfChanged(child, "solarPower", data.solar.instant_power.toInteger(), powerDelta) | updateIfChanged(child, "powerwallPower", data.battery.instant_power.toInteger(), powerDelta)) { child.refreshChildDevices() } } else { logger ("Error procesing gateway meter data: ${response.getStatus()} ${response.getErrorMessage()}","warn") if (response.getStatus() == 401 || response.getStatus() == 403) { runIn (5, reVerifyGateway) } } } Float scaleGatewayBatteryPercent (Float percent) { Float scaled = (percent - 5.0)/0.95 //adjust TEG to match Tesla Server API. Remove 5% and rescale 0 - 100% return Math.round(scaled * 10)/10 //rounded to one decimal place } void processGwSoeResponse(response, callData) { logger ("processing gateway SOE response", "debug") if (!response.hasError()) { def data = response.json logger ("Gw SOE: ${data}","trace") def child = getPwDevice() Float batteryPercent = scaleGatewayBatteryPercent(data.percentage) //adjust TEG to match Tesla Server API updateIfChanged(child, "battery", (batteryPercent + 0.5).toInteger()) updateIfChanged(child, "batteryPercent", batteryPercent) runIn(1, checkBatteryNotifications, [data: [batteryPercent: batteryPercent, reservePercent: null]]) } else { logger ("Error procesing gateway SOE: ${response.getStatus()} ${response.getErrorMessage()}","warn") } } def processGwFullStatusResponse(response, callData) { logger ("processing gateway full status response","debug") if (!response.hasError()) { def data = response.json logger ("Gw Full status: ${data}","trace") def child = getPwDevice() updateIfChanged(child, "currentCapacity", data.nominal_full_pack_energy.toInteger()) } else { logger ("Error procesing gateway full status: ${response.getStatus() ${response.getErrorMessage()}}","warn") } } void processGwOpResponse(response, callData) { logger ("processing gateway operation response","debug") if (!response.hasError()) { def data = response.json logger ("Gw OP: ${data}","trace") Float reservePercent = scaleGatewayBatteryPercent(data.backup_reserve_percent) //adjust TEG to match Tesla Server API updateOpModeAndReserve(data.real_mode, (reservePercent + 0.5).toInteger()) } else { logger ("Error procesing gateway operation: ${response.getStatus()} ${response.getErrorMessage()}","warn") } } void processGwSiteNameResponse(response, callData) { logger ("processing gateway sitename response","debug") if (!response.hasError()) { def data = response.json logger ("Gw Site Name: ${data}","trace") def child = getPwDevice() updateIfChanged(child, "siteName", data.site_name.toString()) } else { logger ("Error procesing gateway sitename: ${response.getStatus()} ${response.getErrorMessage()}","warn") } } def processGwStatusResponse(response, callData) { logger ("processing gateway status response","debug") if (!response.hasError()) { def data = response.json logger ("Gw Status: ${data}","trace") updateVersion (data.version) } else { logger ("Error procesing gateway status: ${response.getStatus() ${response.getErrorMessage()}}","warn") } } def processGwGridStatResponse(response, callData) { logger ("processing gateway grid status response","debug") if (!response.hasError()) { def data = response.json updateGridStatus(data.grid_status) } else { logger ("Error procesing gateway grid status: ${response.getStatus()} ${response.getErrorMessage()}","warn") } } void updateGridStatus(String gridStatus) { //Server: Active, Inactive, Unknown //Gateway: SystemGridConnected, SystemIslandedActive, SystemTransitionToGrid if (gridStatus) { String gridStatusEnum switch (gridStatus) { case "Active": case "SystemGridConnected": gridStatusEnum = "onGrid" break case "Inactive": case "SystemIslandedActive": gridStatusEnum = "offGrid" break case "SystemTransitionToGrid": case "Unknown": break // No status change default: sendNotificationMessage("Powerwall Manager received unexpected grid status: ${gridStatus}", "anomaly") break } if (gridStatusEnum) { Boolean changed = updateIfChanged(getPwDevice(), "gridStatus", gridStatusEnum) if (changed) { if (gridStatusEnum == "offGrid") { runIn(1, processOffGridActions) } else { runIn(1, processOnGridActions) } } } } } void updateOpModeAndReserve(String opMode, def reservePercent) { def pwDevice = getPwDevice() Boolean reserveChanged Boolean opModeChanged if (reservePercent || reservePercent == 0) { //protect against null/bad data reserveChanged = updateIfChanged(pwDevice, "reservePercent", reservePercent) updateIfChanged(pwDevice, "level", reservePercent) updateIfChanged(pwDevice, "reservePercent", reservePercent) updateIfChanged(pwDevice, "reserve_pending", reservePercent) } if (opMode) { String opModePretty if (opMode == "autonomous") { opModePretty = "Time-Based Control" } else if (opMode == "self_consumption") { opModePretty = "Self-Powered" } else if (opMode == "backup") { opModePretty = "Backup-Only" //deprecated } else { opModePretty = opMode logger ("Unrecognized Op Mode: ${opMode}","info") } opModeChanged = updateIfChanged(pwDevice, "currentOpState", opModePretty) if (opModeChanged && notifyWhenModesChange?.toBoolean()) { sendNotificationMessage("Powerwall op mode changed to ${opModePretty}") } } if (reserveChanged || opModeChanged) { pwDevice.refreshChildDevices() } } void updateVersion(String version) { if (version != null) { def pwDevice = getPwDevice() String lastVersion = pwDevice.currentValue("pwVersion") Boolean changed = updateIfChanged(pwDevice, "pwVersion", 'V' + version) if (changed && notifyWhenVersionChanges?.toBoolean()) { String msg = "Powerwall software version changed to 'V${version}'." if (lastVersion) { msg = "${msg} Prior version was '${lastVersion}'" if (state.lastVerChangeDate) { msg = "${msg} (${state.lastVerChangeDate})." } else { msg = "${msg}." } } state.lastVerChangeDate = "${new Date()}" sendNotificationMessage(msg) } } } void updateOptimizationStrategy(String strategy) { if (strategy) { String strategyUi if (strategy == "economics") { strategyUi = "Cost-Saving" } else if (strategy == "balanced") { strategyUi = "Balanced" } else { logger ("Unrecognized Strategy: ${strategy}","info") strategyUi = strategy } state.strategy = strategyUi.toString() Boolean changed = updateIfChanged(pwDevice, "currentStrategy", strategyUi) if (changed && notifyWhenModesChange?.toBoolean()) { sendNotificationMessage("Powerwall ATC optimization strategy changed to ${strategyUi}") } } } def processSiteInfoResponse(response, callData) { logger ("processing server site info response","debug") if (!response.hasError()) { def data = response.json.response logger ("Site Info: ${data}","trace") updateOptimizationStrategy (data?.tou_settings?.optimization_strategy) if (data?.tou_settings?.schedule && notifyWhenModesChange?.toBoolean() && state.lastSchedule && data.tou_settings.schedule != state.lastSchedule) { sendNotificationMessage("Powerwall Advanced Time Controls schedule has changed") } if (data.tou_settings?.schedule) { state.lastSchedule = data.tou_settings.schedule } updateVersion (data.version) updateOpModeAndReserve(data.default_real_mode, data.backup_reserve_percent?.toInteger()) def child = getPwDevice() if (data.user_settings?.storm_mode_enabled != null) { Boolean changed = updateIfChanged(child, "stormwatch", data.user_settings.storm_mode_enabled.toBoolean()) if (changed) { child.refreshChildDevices() } } if (data.components?.customer_preferred_export_rule != null) { updateIfChanged(child, "energyExportMode", data.components.customer_preferred_export_rule) } if (data.components?.disallow_charge_from_grid_with_solar_installed != null) { updateIfChanged(child, "gridChargingEnabled", !data.components.disallow_charge_from_grid_with_solar_installed) } else { //Grid Charging with solar status status only appears if disabled.. updateIfChanged(child, "gridChargingEnabled", true) } updateIfChanged(child, "siteName", data.site_name.toString()) } else { Integer status = response.getStatus() if (status == 401) { //log.warn "Site resp error: ${response.getErrorMessage()}." runIn (1, handleServerAuthIssue) } if (callData?.attempt && callData.attempt < 2) { logger ("Site response error on attempt ${callData?.attempt}: ${response.getErrorMessage()}. Retrying...","debug") runIn(20, requestSiteInfo, [data: [attempt: callData.attempt + 1]]) } else { logger ("Site response error after ${callData?.attempt} attempts: ${status} ${response.getErrorMessage()}.","warn") } } } def processSiteLiveStatusResponse(response, callData) { logger ("processing server site live status response","debug") if (!response.hasError()) { def data = response.json.response logger ("Site Live Status: ${data}","trace") Boolean stormwatchMode = data?.storm_mode_active def child = getPwDevice() Boolean stormwatchChanged = updateIfChanged(child, "stormwatchActive", data?.storm_mode_active) if (stormwatchChanged && settings.notifyWhenStormwatch) { if (stormwatchMode) { sendNotificationMessage("Powerwall Storm Watch mode is active.") } else { sendNotificationMessage("Powerwall Storm Watch is no longer active.") } } if (data.total_pack_energy > 1) //sometimes data appears invalid { float batteryPercent = data.energy_left.toFloat() / data.total_pack_energy.toFloat() * 100.0 float bpRounded = Math.round(batteryPercent * 10)/10 //rounded to one decimal place updateIfChanged(child, "battery", (bpRounded + 0.5).toInteger()) updateIfChanged(child, "batteryPercent", bpRounded) if (!connectedToGateway()) { //Gateway value may be slightly different from server. Need to verify. Use gateway value only for now if available. updateIfChanged(child, "currentCapacity", data.total_pack_energy.toInteger()) } runIn(1, checkBatteryNotifications, [data: [batteryPercent: bpRounded, reservePercent: child.currentValue("reservePercent")]]) } else if(data.percentage_charged != null) { // Added by NeilR 3/23/2024 - duplicated above and edited logger ("Using 'percentage_charged' for battery level","debug") float batteryPercent = data.percentage_charged.toFloat() float bpRounded = Math.round(batteryPercent * 10)/10 //rounded to one decimal place updateIfChanged(child, "battery", (bpRounded + 0.5).toInteger()) updateIfChanged(child, "batteryPercent", bpRounded) if (!connectedToGateway()) { //Gateway value may be slightly different from server. Need to verify. Use gateway value only for now if available. // //updateIfChanged(child, "currentCapacity", data.total_pack_energy.toInteger()) } runIn(1, checkBatteryNotifications, [data: [batteryPercent: bpRounded, reservePercent: child.currentValue("reservePercent")]]) } Integer powerDelta = settings.powerThreshold?.toInteger() ?: 100 Boolean powerChanged = updateIfChanged(child, "loadPower", data.load_power.toInteger(), powerDelta) | updateIfChanged(child, "gridPower", data.grid_power.toInteger(), powerDelta) | updateIfChanged(child, "power", data.grid_power.toInteger(), powerDelta) | updateIfChanged(child, "solarPower", data.solar_power.toInteger(), powerDelta) | updateIfChanged(child, "powerwallPower", data.battery_power.toInteger(), powerDelta) if (stormwatchChanged || powerChanged) { child.refreshChildDevices() } if (!connectedToGateway()) { //Do not update if connected to gateway, to prevent status data thrashing updateGridStatus (data.grid_status) } state.lastCompletedTime = now() } else { if (status != 401) { if (callData?.attempt && callData.attempt < 2) { runIn(30, requestSiteLiveStatus, [data: [attempt: callData.attempt + 1]]) } else { logger ("Site live status response error after ${callData?.attempt} attempts: ${response.getStatus()} ${response.getErrorMessage()}.","warn") } } } } void processOffGridActions() { logger ("processing off grid actions","debug") def child = getPwDevice() updateIfChanged(child, "switch", "off") updateIfChanged(child, "contact", "open") if (notifyWhenGridStatusChanges?.toBoolean()) { sendNotificationMessage("Powerwall status changed to: Off Grid") } if (devicesToOffDuringOutage?.size()) { devicesToOffDuringOutage.off() } } void processOnGridActions() { logger ("processing on grid actions","debug") def child = getPwDevice() updateIfChanged(child, "switch", "on") updateIfChanged(child, "contact", "closed") if (notifyWhenGridStatusChanges?.toBoolean()) { sendNotificationMessage("Powerwall status changed to: On Grid") } if (devicesToOffDuringOutage?.size() && turnDevicesBackOnAfterOutage?.toBoolean()) { devicesToOffDuringOutage.on() } if (devicesToOnAfterOutage?.size()) { devicesToOnAfterOutage.on() } } void requestSiteInfo(data) { if (!state?.lastSiteInfoRequestTime || now() - state.lastSiteInfoRequestTime > 1000) { Integer tryCount = data?.attempt ?: 1 if (state.serverVerified) { httpAuthAsyncGet('processSiteInfoResponse', "/api/1/energy_sites/${state.energySiteId}/site_info", tryCount) } state.lastSiteInfoRequestTime = now() } } void requestSiteLiveStatus(data) { if (!state?.lastSiteLiveStatusRequestTime || now() - state.lastSiteLiveStatusRequestTime > 1000) { Integer tryCount = data?.attempt ?: 1 if (state.serverVerified) { httpAuthAsyncGet('processSiteLiveStatusResponse', "/api/1/energy_sites/${state.energySiteId}/live_status", tryCount) } state.lastSiteLiveStatusRequestTime = now() } } void reVerifyGateway() { getLocalGwStatus() } void requestGatewayMeterData() { String gwUri = "https://${gatewayAddress}" asynchttpGet(processGwMeterResponse, [uri: gwUri, path: "/api/meters/aggregates", headers: gwHeader(), contentType: 'application/json', ignoreSSLIssues: true]) } void requestGatewaySiteData() { String gwUri = "https://${gatewayAddress}" asynchttpGet(processGwSoeResponse, [uri: gwUri, path: "/api/system_status/soe", headers: gwHeader(), contentType: 'application/json', ignoreSSLIssues: true]) asynchttpGet(processGwSiteNameResponse, [uri: gwUri, path: "/api/site_info/site_name", headers: gwHeader(), contentType: 'application/json', ignoreSSLIssues: true]) asynchttpGet(processGwGridStatResponse, [uri: gwUri, path: "/api/system_status/grid_status", headers: gwHeader(), contentType: 'application/json', ignoreSSLIssues: true]) asynchttpGet(processGwFullStatusResponse, [uri: gwUri, path: "/api/system_status", headers: gwHeader(), contentType: 'application/json', ignoreSSLIssues: true]) if (!connectedToTeslaServer()) { //Only process if not connected to the Tesla server to prevent data thrashing asynchttpGet(processGwOpResponse, [uri: gwUri, path: "/api/operation", headers: gwHeader(), contentType: 'application/json', ignoreSSLIssues: true]) asynchttpGet(processGwStatusResponse, [uri: gwUri, path: "/api/system/update/status", headers: gwHeader(), contentType: 'application/json', ignoreSSLIssues: true]) } } void commandOpMode(data) { httpAuthPost(body: [default_real_mode: data.mode], "${data.mode} mode", "/api/1/energy_sites/${state.energySiteId}/operation", { resp -> //log.debug "${resp.data}" }) runIn(2, requestSiteInfo) runIn(30, processWatchdog) } void setSelfPoweredMode(child) { /* if (child) { def pwDevice = getPwDevice() if (pwDevice.currentValue("currentOpState") != "Self-Powered") { updateIfChanged(pwDevice, "currentOpState", "Pending Self-Powered") } } */ runIn(1, commandOpMode, [data: [mode: "self_consumption"]]) } void setTimeBasedControlMode(child) { /*if (child) { def pwDevice = getPwDevice() if (pwDevice.currentValue("currentOpState") != "Time-Based Control") { updateIfChanged(pwDevice, "currentOpState", "Pending Time-Based") } }*/ runIn(1, commandOpMode, [data: [mode: "autonomous"]]) } void setBackupOnlyMode(child) { //Deprecated /*if (child) { child.sendEvent(name: "currentOpState", value: "Pending Backup-Only", displayed: false) updateIfChanged(child, "currentOpState", "Pending Backup-Only") } */ runIn(1, commandOpMode, [data: [mode: "backup"]]) } void commandTouStrategy(data) { logger ("commanding TOU strategy to ${data.strategy}","debug") //request Site Data to get a current tbc schedule. Schedule needs to be sent on tou strategy command or else schedule will be re-set to default def latestSchedule try { httpAuthGet("/api/1/energy_sites/${state.energySiteId}/site_info", { resp -> //log.debug "${resp.data}" if (resp?.data?.response?.tou_settings?.schedule) { latestSchedule = resp.data.response.tou_settings.schedule } }) } catch (Exception e) { log.debug "Exception ${e} getting latest schedule" } if (latestSchedule == null) { //log.debug "setting latest schedule to last known state" latestSchedule = state.lastSchedule } def commands = [tou_settings: [optimization_strategy: data.strategy, schedule: latestSchedule]] httpAuthPost(body: commands, "${data.strategy}", "/api/1/energy_sites/${state.energySiteId}/time_of_use_settings", { resp -> //log.debug "${resp.data}" }) runIn(2, requestSiteInfo) runIn(30, processWatchdog) } void setTbcBalanced(child) { if (child) { if (child.currentValue("currentStrategy") != "Balanced") { updateIfChanged(child, "currentStrategy", "Pending Balanced") } } runIn(2, commandTouStrategy, [data: [strategy: "balanced"]]) } void setTbcCostSaving(child) { if (child) { if (child.currentValue("currentStrategy") != "Cost-Saving") { updateIfChanged(child, "currentStrategy", "Pending Cost-Saving") } } runIn(2, commandTouStrategy, [data: [strategy: "economics"]]) } void commandBackupReservePercent(data) { httpAuthPost(body: [backup_reserve_percent: data.reservePercent], "reserve ${data.reservePercent}%", "/api/1/energy_sites/${state.energySiteId}/backup", { resp -> }) runIn(2, requestSiteInfo) runIn(30, processWatchdog) } void commandGoOffGrid(data) { if (!connectedToGateway()) { logger ("Not connected to gateway, cannot set GoOffGrid Status") return } try { def islandingMode = "backup" logger ("commanding GoOffGrid data is ${data.isOnGrid}","debug") if (!data.isOnGrid) { islandingMode = "intentional_reconnect_failsafe" } logger ("commanding GoOffGrid strategy to ${islandingMode}","debug") httpPost([uri: "https://${gatewayAddress}", path: "/api/v2/islanding/mode", headers: gwHeader(), body:"{\"island_mode\":\"${islandingMode}\"}", contentType: 'application/json', ignoreSSLIssues: true]) { response -> logger("local islanding call successful","debug") logger("response ${response}","debug") } runIn(2, requestSiteLiveStatus) runIn(30, processWatchdog) } catch (Exception e) { logger ("Error setting local gateway island status: ${e}","warn") state.gatewayStatusStr = "Error accessing local gateway.\n" + "Please verify your gateway address and password. ${e}" } } void goOffGrid(child){ logger ("commanding go off grid","debug") runIn(2, commandGoOffGrid, [data: [isOnGrid:false]]) } void goOnGrid(child){ logger ("commanding go on grid","debug") runIn(2, commandGoOffGrid, [data: [isOnGrid:true]]) } void setBackupReservePercent(child, value) { if (value != null && value.toInteger() >= 0 && value.toInteger() <= 100) { runIn(2, commandBackupReservePercent, [data: [reservePercent: value.toInteger()]]) } else { log.debug "Backup reserve percent of: ${value} not sent. Must be between 0 and 100" } } void commandStormwatchEnable() { httpAuthPost(body: [enabled: true], "stormwatch mode enable", "/api/1/energy_sites/${state.energySiteId}/storm_mode", { resp -> //log.debug "${resp.data}" }) runIn(3, requestSiteInfo) runIn(30, processWatchdog) } void commandStormwatchDisable() { httpAuthPost(body: [enabled: false], "stormwatch mode disable", "/api/1/energy_sites/${state.energySiteId}/storm_mode", { resp -> //log.debug "${resp.data}" }) runIn(2, requestSiteInfo) runIn(30, processWatchdog) } void enableStormwatch(child) { logger ("commanding stormwatch on","debug") runIn(2, commandStormwatchEnable) } void disableStormwatch(child) { logger ("commanding stormwatch off","debug") runIn(2, commandStormwatchDisable) } void commandGridChargingEnable() { httpAuthPost(body: [disallow_charge_from_grid_with_solar_installed: false], "grid charging enable", "/api/1/energy_sites/${state.energySiteId}/grid_import_export", { resp -> //log.debug "${resp.data}" }) runIn(2, requestSiteInfo) runIn(30, processWatchdog) } void commandGridChargingDisable() { httpAuthPost(body: [disallow_charge_from_grid_with_solar_installed: true], "grid charging disable", "/api/1/energy_sites/${state.energySiteId}/grid_import_export", { resp -> //log.debug "${resp.data}" }) runIn(2, requestSiteInfo) runIn(30, processWatchdog) } void enableGridCharging(child) { logger ("commanding grid charging on","debug") runIn(2, commandGridChargingEnable) } void disableGridCharging(child) { logger ("commanding grid charging off","debug") runIn(2, commandGridChargingDisable) } void commandEnergyExportModeSolarOnly() { httpAuthPost(body: [customer_preferred_export_rule: "pv_only"], "energy export mode solar-only", "/api/1/energy_sites/${state.energySiteId}/grid_import_export", { resp -> //log.debug "${resp.data}" }) runIn(3, requestSiteInfo) runIn(30, processWatchdog) } void commandEnergyExportModeEverything() { httpAuthPost(body: [customer_preferred_export_rule: "battery_ok"], "energy export mode everything", "/api/1/energy_sites/${state.energySiteId}/grid_import_export", { resp -> //log.debug "${resp.data}" }) runIn(2, requestSiteInfo) runIn(30, processWatchdog) } void setEnergyExportModeSolarOnly(child) { logger ("setting export mode to solar only","debug") runIn(2, commandEnergyExportModeSolarOnly) } void setEnergyExportModeEverything(child) { logger ("setting export mode to battery and solar","debug") runIn(2, commandEnergyExportModeEverything) } def refresh(child) { if (logLevel == "debug" | logLevel == "trace") { logger ("refresh requested","debug") } state.lastHeartbeatUpdateTime = [:] runIn(1, processServerMain) runIn(2, processGatewayMain) runIn(30, processWatchdog) } void processWatchdog() { def lastTimeProcessed def lastTimeCompleted if (!state.lastProcessedTime | !state.lastCompletedTime) { lastTimeProcessed = now() lastTimeCompleted = now() } else { lastTimeProcessed = state.lastProcessedTime lastTimeCompleted = state.lastCompletedTime } def secondsSinceLastProcessed = (now() - lastTimeProcessed) / 1000 def secondsSinceLastProcessCompleted = (now() - lastTimeCompleted) / 1000 def maxDownTime = 1800 if (pollingPeriod) { if (pollingPeriod == "30 minutes") { maxDownTime = 6000 } else if (pollingPeriod == "1 hour") { maxDownTime = 8000 } else if (pollingPeriod == "Do not poll") { maxDownTime = 700000 } } if (secondsSinceLastProcessed > maxDownTime) { if (!state?.processedWarningSent) { String msg = "Powerwall Manager has not executed in ${(secondsSinceLastProcessed/60).toInteger()} minutes. Reinitializing" sendNotificationMessage("Warning: " + msg, "anomaly") state.processedWarningSent = true logger (msg,"warn") runIn(30, initialize) } } else if (secondsSinceLastProcessCompleted > maxDownTime) { if (state.serverValidAtStartup) { if (!state?.completedWarningSent) { String msg = "Powerwall Manager has not successfully received and processed server data in ${(secondsSinceLastProcessCompleted/60).toInteger()} minutes. Reinitializing" sendNotificationMessage("Warning: " + msg,"anomaly") state.completedWarningSent = true logger (msg,"warn") runIn(30, initialize) } } } else { if (state?.completedWarningSent || state?.processedWarningSent) { String msg = "Info: Powerwall Manager has successfully resumed operation" sendNotificationMessage(msg, "anomaly") state.completedWarningSent = false state.processedWarningSent = false logger(msg,"info") } } } void scheduleRefreshAccessToken() { if (state.tokenExpiration) { Long refreshDateEpoch = state.tokenExpiration - oneHourMs*2.5 // 2.5 hours before expiration //Min 1 hour, max 30 days if (refreshDateEpoch - now() < oneHourMs) { refreshDateEpoch = now() + oneHourMs } else if (refreshDateEpoch - now() > oneDayMs*30) { refreshDateEpoch = now() + oneDayMs*30 } def refreshDate = new Date(refreshDateEpoch) logger ("Scheduling token refresh for ${refreshDate}.","debug") runOnce(refreshDate, refreshAccessToken) state.scheduleRefreshToken = false state.refreshSchedTime = refreshDateEpoch } else { logger ("Unable to schedule refresh token. No expiration date found","warn") } } void processServerMain() { //if (!state.forceSrvrFailure) { // log.debug "server fail test initiated" // state.forceSrvrFailure=true // app.updateSetting("inputAccessToken",[type:"text",value:" "]) //} state.lastProcessedTime = now() def lastStateProcessTime if (state.lastStateRunTime == null) { lastStateProcessTime = 0 } else { lastStateProcessTime = state.lastStateRunTime } def secondsSinceLastRun = (now() - lastStateProcessTime) / 1000 if (secondsSinceLastRun > 60) { state.lastStateRunTime = now() runIn(1, requestSiteLiveStatus) runIn(10, requestSiteInfo) if ((settings.notifyTokenAge == null || settings.notifyOfTokenAge) && state.tokenChangeTime && !state.tokenAgeWarnSent) { Integer tokenAgeDays = ((now() - state.tokenChangeTime)/oneDayMs).toInteger() if (tokenAgeDays > 40) { state.tokenAgeWarnSent = true sendNotificationMessage("Powerwall Manager: Tesla access token was last updated ${tokenAgeDays} days ago.") } } if (state.scheduleRefreshToken && state.tokenExpiration) { scheduleRefreshAccessToken() } } } void processGatewayMain() { //if (!state.forceGwFailure) { // log.debug "gateway fail test initiated" // state.forceGwFailure=true // state.gwAuthCookie = " " //} logger ("Processing processGatewayMain","debug") if (gatewayAddress) { if (state.gatewayVerified) { logger ("requesting data from gateway","debug") if (state.gatewayConnectFailMode) { //was in failure mode, ok now unschedule (reVerifyGateway) state.gatewayConnectFailMode = false } runIn (2, requestGatewayMeterData) runIn (5, requestGatewaySiteData) } else if (!state.gatewayConnectFailMode) { //gateway is not validated, but not yet in failure mode, re-check gateway login logger ("gateway not been verified","debug") getLocalGwStatus() if (state.gatewayVerified) { //it's good now logger ("gateway now verified","debug") runIn (2, requestGatewayMeterData) runIn (5, requestGatewaySiteData) } else { //Gateway could not be verified. Put in gateway failure mode logger ("entering gateway fail mode","debug") state.gatewayConnectFailMode = true runEvery1Hour (reVerifyGateway) } } } } String validationStrFromCode(Integer theCode) { String theString switch (theCode) { case 200: theString = "Validated with Tesla" break; case 401: theString = "Not Validated with Tesla - Unauthorized" break default: theString = "Not Validated with Tesla - ${codeFromToken} Error" break } return theString } void validateTokenFromUrl(String theToken) { Integer codeFromToken = statusCodeFromToken(theToken) Boolean tokenValid = (codeFromToken == 200) state.accessTokenFromUrlValid = tokenValid state.accessTokenFromUrlStatus = validationStrFromCode (codeFromToken) if (tokenValid) { state.serverFailureMode = false } logger ("Token from URL Valid: ${tokenValid}","debug") } Boolean validateInputToken() { Integer codeFromToken = statusCodeFromToken(inputAccessToken) Boolean tokenValid = (codeFromToken == 200) state.inputAccessTokenValid = tokenValid state.inputAccessTokenStatus = validationStrFromCode (codeFromToken) if (tokenValid) { state.serverFailureMode = false } logger ("Input Access Token Valid: ${tokenValid}","debug") return tokenValid } def tokenFromUrlCallback (resp){ state.accessTokenFromUrlStatus = "Received status from URL" if (resp.status == 200) { state.accessTokenFromUrlStatus = "Received OK status from local URL" logger ("Token from URL body = ${resp.body}", "trace") def results = new groovy.json.JsonSlurper().parseText(resp.body) logger("Access token from URL received in callback: ${results.access_token}","debug") if (results.access_token) { state.accessTokenFromUrlStatus = "Received Token from URL" String theToken = results.access_token state.accessTokenFromUrl = theToken validateTokenFromUrl(theToken) } } else { logger ("Token from URL failed with status ${resp.status}","warn") state.accessTokenFromUrlStatus = "Token Not received from local URL. Status: ${resp.status}" } } void validateLocalUrl() { // get the token from the local URL, validate with Tesla, and set state status state.accessTokenFromUrlValid = false def accessTokenFromUrl String thePath = accessTokenPath ?: "/" if (hubIsSt()) { state.accessTokenFromUrlStatus = "Requested from local URL" def httpGetAction = physicalgraph.device.HubAction.newInstance( method: "GET", path: "${thePath}", headers: [HOST : "${accessTokenIp}:0080"], null, [callback: tokenFromUrlCallback]) sendHubCommand(httpGetAction); } else { state.accessTokenFromUrlCode = 0 if (accessTokenUrl) { def params = [ uri: accessTokenUrl, contentType : 'application/json' ] try { httpGet(params) { resp -> Integer code = resp.status if (code == 200) { logger ("Received Access Token from local URL","debug") accessTokenFromUrl = "${resp.data.access_token}" state.accessTokenFromUrl = accessTokenFromUrl validateTokenFromUrl(accessTokenFromUrl) } else { logger ("Get Access Token from local URL failed with status ${code}","warn") state.accessTokenFromUrlStatus = "Token Not Received from local URL. Status: ${code}" } state.accessTokenFromUrlCode = code } } catch (groovyx.net.http.HttpResponseException e) { def statusCode = e?.response?.getStatus() logger ("Access token from URL failed with HTTP exception: ${e} code: ${statusCode}","info") state.accessTokenFromUrlStatus = "Token Not Received from local URL - ${statusCode} ${e?.response?.getStatusLine()}" } catch (Exception e) { logger ("Access token from URL failed with general exception: ${e}","info") state.accessTokenFromUrlStatus = "Token Not Received from local URL. General exception on call" } } else { logger ("Cannot query local server for URL. accessTokenUrl is null", "trace") } } } Integer statusCodeFromToken (tryToken) { Integer statusCode = 0 String path = "/api/1/products" if (tryToken) { try { httpAuthGet(path, { resp -> statusCode = resp.status }, tryToken) } catch (groovyx.net.http.HttpResponseException e) { logger ("HTTP exception getting status from ${path} : ${e}","info") statusCode = e?.response?.getStatus() } catch (Exception e) { logger ("General exception getting token status from ${path}: ${e}","info") } } return statusCode } Boolean tokenFailover() { Boolean success = false if (state.inputAccessTokenValid) { state.useInputToken = true success = true logger ("Input token is now valid","debug") } else if (state.accessTokenFromUrlValid) { state.useTokenFromUrl = true //The input token was is no longer valid, but the token from URL is good. Stop using the input token so only the token from URL is considered state.useInputToken = false success = true logger ("Token from URL is now valid","debug") } else { // both tokens failed, send a notification) if (!state.serverFailureMode) { state.serverFailureMode = true String msg = "Authorization issue connecting to Tesla Server. Please check your tokens in the Powerwall Manager app" logger (msg,"error") if (state.serverValidAtStartup) { sendNotificationMessage("Powerwall Manager: " + msg, "anomaly") } } } return success } void handleServerAuthIssue() { if (!state.serverFailureMode) { if (!validateInputToken() && state.accessTokenFromUrlValid) { state.useInputToken = false //force use of token from URL } if (accessTokenIp) { validateLocalUrl() //check for a new token from URL } runIn (3, initialServerFailover) } } void initialServerFailover() { if (!tokenFailover()) { //Still no valid tokens runIn(3600, prepDailyServerFailover) // in one hour } } void prepDailyServerFailover() { if (accessTokenIp) { validateLocalUrl() } runIn (5, dailyServerFailover) } void dailyServerFailover() { refreshAccessToken() validateInputToken() if (!tokenFailover()) { //Still no valid tokens state.useInputToken = false state.useTokenFromUrl = false runIn(8600, prepDailyServerFailover()) //continue running a daily failover check } } void logger (String message, String msgLevel="debug") { Integer prefLevelInt = settings.logLevel ? logLevels[settings.logLevel] : 4 Integer msgLevelInt = logLevels[msgLevel] if (msgLevelInt >= prefLevelInt && prefLevelInt) { log."${msgLevel}" message } else if (!msgLevelInt) { log.info "${message} logged with invalid level: ${msgLevel}" } } def hrefMenuPage (String page, String titleStr, String descStr, String image, params, state = null) { if (hubIsSt()) { href page, title: titleStr, description: descStr, required: false, image: image, params: params, state: state } else { String imgFloat = "" String imgElement = "" if (descStr) {imgFloat = "float: left;"} //Center title} if no description if (image) {imgElement = ""} String titleDiv = imgElement + titleStr String descDiv = "
" + descStr + "
" href page, description: descDiv, title: titleDiv, required: false, params : params, state : state } } // Constants @Field static final Map logLevels = ["none":0, "trace":1,"debug":2,"info":3, "warn":4,"error":5] @Field static final String teslaUrl = "https://owner-api.teslamotors.com" @Field static final String ddUrl = "https://darwinsden.com/powerwall/" @Field static final String versionUrl = "https://raw.githubusercontent.com/DarwinsDen/Tesla-Powerwall-Manager/master/packageManifest.json" @Field static final String teslaBearerTokenEndpoint = "https://auth.tesla.com/oauth2/v3/token" @Field static final String teslaBearerTokenGrantType = "refresh_token" @Field static final String teslaBearerTokenClientId = "ownerapi" @Field static final String teslaBearerTokenScope = "openid email offline_access" @Field static final String teslaAccessTokenEndpoint = "https://owner-api.teslamotors.com/oauth/token" @Field static final String teslaAccessTokenAuthGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" @Field static final String teslaAccessTokenAuthClientId = "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384" @Field static final Integer maxSmartThingsSchedules = 15 @Field static final Long oneMinuteMs = 1000*60 @Field static final Long oneHourMs = 1000*60*60 @Field static final Long oneDayMs = 1000*60*60*24 // Icons @Field static final String teslaIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/Tesla-Icon40.png" @Field static final String gatewayIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/gateway.png" @Field static final String notifyIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/notification40.png" @Field static final String batteryIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/battery40.png" @Field static final String outageIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/outage40.png" @Field static final String ddLogoHubitat = "https://darwinsden.com/download/ddlogo-for-hubitat-pwManagerv4-png" @Field static final String ddLogoSt = "https://darwinsden.com/download/ddlogo-for-st-pwManagerV4-png" @Field static final String cogIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/cogD40.png" @Field static final String dashIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/dashboard40.png" @Field static final String schedIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/schedClock40.png" @Field static final String schedOkIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/schedOk40.png" @Field static final String addIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/add40.png" @Field static final String schedIncomplIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/schedIncompl40.png" @Field static final String ppBtn = "https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif" @Field static final String pwLogo = "https://raw.githubusercontent.com/DarwinsDen/Tesla-Powerwall-Manager/main/images/PWLogo.png" @Field static final String trashIcon = "https://rawgit.com/DarwinsDen/SmartThingsPublic/master/resources/icons/trash40.png"