/** * Emporia Vue Utility Connect Driver * * 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. * * Special thanks goes to ke7lvb who created the original Emporia Vue driver for Hubitat that the majority of this driver code is leveraged. * The original Emporia Vue driver is available at https://github.com/ke7lvb/Emporia-Vue-Hubitat * Emporia does not have an official API and this driver may stop working at any time * * The main purpose of this driver is to specifically pull the same energy usage information from the Emporia Vue Utility Connect device (https://shop.emporiaenergy.com/products/utility-connect) that * is displayed in the Emporia Vue Mobile App e.g. Amps being sent/consumed to the grid that can then be used in Hubitat Rules to control other devices. For example, if the Meter reads - 30 AMPs due to Solar Production * then set my car charger to 20 amps and charge the car until the Meter shows I'm consuming power from the grid instead of sending power to the grid. * * Change History: * * Date Who What * ---- --- ---- * 12-21-24 gomce62 Initial release * 01-07024 gomce62 Added code to get EmporiaVue API Tokens based on code contributions from @amithalp and @ke7lvb */ import groovy.json.* import java.text.SimpleDateFormat import java.util.TimeZone metadata { definition( name: "Emporia Vue Utility Connect Driver", namespace: "gomce62", author: "Chris Feduniw", importUrl:"https://raw.githubusercontent.com/gomce62/Hubitat/refs/heads/Drivers/Emporia_Vue_Utility_Connect_Driver", ){ capability "Actuator" capability "Refresh" capability "PowerSource" capability "PowerMeter" capability "EnergyMeter" command "authToken", [[name: "Update Authtoken*", type: "STRING"]] command "getDeviceGid" command "generateToken" command "refreshToken" attribute "tokenExpiry", "string" attribute "lastUpdate", "string" attribute "KilowattHours", "number" attribute "Dollars", "number" attribute "AmpHours", "number" attribute "Trees", "number" attribute "GallonsOfGas", "number" attribute "MilesDriven", "number" attribute "Carbon", "number" attribute "Scale", "string" } preferences { input name: "logEnable", type: "bool", title: "Enable Info logging", defaultValue: true, description: "" input name: "debugLog", type: "bool", title: "Enable Debug logging", defaultValue: true, description: "" input name: "jsonState", type: "bool", title: "Show JSON state", defaultValue: true, description: "" input name: "email", type: "string", title: "Emporia Email", required: true input name: "password", type: "password", title: "Emporia Password", required: true input("scale", "enum", title: "Scale", options: [ ["1S": "1 Second"], ["1MIN": "1 Minute"], ["1H": "1 Hour"], ["1D": "1 Day"], ["1W": "1 Week"], ["1Mon": "1 Month"], ["1Y": "1 Year"] ], required: true, defaultValue: "1H") input("refresh_interval", "enum", title: "How often to refresh the Emporia data", options: [ 0: "Do NOT update", 1: "1 Minute", 5: "5 Minutes", 10: "10 Minutes", 15: "15 Minutes", 20: "20 Minutes", 30: "30 Minutes", 45: "45 Minutes", 60: "1 Hour" ], required: true, defaultValue: "60") } } def version(){ return "2.0.0" } def installed(){ if(logEnable) log.info "Driver installed" state.version = version() state.deviceGID = [] state.deviceNames = [] } def uninstalled() { unschedule(refresh) if(logEnable) log.info "Driver uninstalled" } def updated(){ if (logEnable) log.info "Settings updated" if (settings.refresh_interval != "0") { if (settings.refresh_interval == "60") { schedule("7 0 * ? * * *", refresh, [overwrite: true]) } else { schedule("7 */${settings.refresh_interval} * ? * *", refresh, [overwrite: true]) } }else{ unschedule(refresh) } state.version = version() sendEvent(name: "Scale", value: scale) if(jsonState == false){ state.remove("JSON") } // Schedule token refresh if (state.tokenExpiry) { def refreshTime = (state.tokenExpiry - now() - 300000) / 1000 // Refresh 5 minutes before expiry runIn(refreshTime.toInteger(), refreshToken) if (logEnable) log.info "Token refresh scheduled in ${refreshTime.toInteger()} seconds" } state.version = version() if (!jsonState) { state.remove("JSON") } } def generateToken() { def authEndpoint = "https://cognito-idp.us-east-2.amazonaws.com/" def headers = [ "Content-Type": "application/x-amz-json-1.1", "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth" ] def body = JsonOutput.toJson([ AuthFlow: "USER_PASSWORD_AUTH", ClientId: "4qte47jbstod8apnfic0bunmrq", AuthParameters: [ USERNAME: settings.email, PASSWORD: settings.password ] ]) def params = [uri: authEndpoint, headers: headers, body: body] try { httpPost(params) { resp -> if (resp.status == 200) { def responseText = resp.getData().getText('UTF-8') def responseData = new JsonSlurper().parseText(responseText) if (responseData.AuthenticationResult) { state.idToken = responseData.AuthenticationResult.IdToken state.accessToken = responseData.AuthenticationResult.AccessToken state.refreshToken = responseData.AuthenticationResult.RefreshToken state.tokenExpiry = now() + (responseData.AuthenticationResult.ExpiresIn * 1000) sendEvent(name: "tokenExpiry", value: new Date(state.tokenExpiry).format("yyyy-MM-dd'T'HH:mm:ss'Z'")) if (logEnable) log.info "Token generated successfully. ID Token: ${state.idToken}" updated() // Trigger updated to schedule refresh } else { log.error "AuthenticationResult missing in response. Response: ${responseData}" } } else { log.error "Failed to generate token. HTTP status: ${resp.status}" if (debugLog) log.debug "Response data: ${resp.getData().getText('UTF-8')}" } } } catch (e) { log.error "Error generating token: ${e.message}" } } def refreshToken() { def authEndpoint = "https://cognito-idp.us-east-2.amazonaws.com/" def headers = [ "Content-Type": "application/x-amz-json-1.1", "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth" ] def body = JsonOutput.toJson([ AuthFlow: "REFRESH_TOKEN_AUTH", ClientId: "4qte47jbstod8apnfic0bunmrq", AuthParameters: [ REFRESH_TOKEN: state.refreshToken ] ]) def params = [uri: authEndpoint, headers: headers, body: body] try { httpPost(params) { resp -> if (resp.status == 200) { def responseText = resp.getData().getText('UTF-8') def responseData = new JsonSlurper().parseText(responseText) if (responseData.AuthenticationResult) { state.idToken = responseData.AuthenticationResult.IdToken state.accessToken = responseData.AuthenticationResult.AccessToken state.tokenExpiry = now() + (responseData.AuthenticationResult.ExpiresIn * 1000) sendEvent(name: "tokenExpiry", value: new Date(state.tokenExpiry).format("yyyy-MM-dd'T'HH:mm:ss'Z'")) log.info "Token refreshed successfully. New ID Token: ${state.idToken}" updated() // Trigger updated to reschedule refresh } else { log.error "AuthenticationResult missing in refresh response. Response: ${responseData}" } } else { log.error "Failed to refresh token. HTTP status: ${resp.status}" if (debugLog) log.debug "Response data: ${resp.getData().getText('UTF-8')}" } } } catch (e) { log.error "Error refreshing token: ${e.message}" } } def getDeviceGid() { def host = "https://api.emporiaenergy.com/" def command = "customers/devices" try { def response = httpGet([uri: "${host}${command}", headers: ['authtoken': state.idToken]]) { resp -> resp.data } if (debugLog) log.debug JsonOutput.toJson(response.devices) def deviceGID = [] def deviceNames = [] response.devices.each { value -> if (debugLog) log.debug value.deviceGid deviceGID.add(value.deviceGid) value.devices.channels.each { next_value -> deviceNames.add(next_value.name) } } state.deviceGID = deviceGID state.deviceNames = deviceNames - null - '' } catch (e) { log.error "Error fetching device GID: ${e.message}" } } def refresh() { if (state.deviceGID) { def Gid_string = state.deviceGID.join("+") def outputTZ = TimeZone.getTimeZone('UTC') def instant = new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'", outputTZ) def host = "https://api.emporiaenergy.com/" ["KilowattHours", "Dollars", "AmpHours", "Trees", "GallonsOfGas", "MilesDriven", "Carbon"].each { energyUnit -> attemptRefresh([host: host, Gid_string: Gid_string, instant: instant, energyUnit: energyUnit, attempts: 0]) } def now = new Date() def dateFormat = new SimpleDateFormat("MM-dd-yyyy h:mm:ss a") def formattedDate = dateFormat.format(now) state.lastUpdate = formattedDate sendEvent(name: "lastUpdate", value: formattedDate) } else { log.info "Device GID not found. Please refresh Authtoken and getDeviceGid" } } def attemptRefresh(params) { def host = params.host def Gid_string = params.Gid_string def instant = params.instant def energyUnit = params.energyUnit def attempts = params.attempts def command = "AppAPI?apiMethod=getDeviceListUsages&deviceGids=${Gid_string}&instant=${instant}&scale=${scale}&energyUnit=${energyUnit}" if (debugLog) log.debug "${host}${command}" def JSON = httpGet([uri: "${host}${command}", headers: ['authtoken': state.idToken]]) { resp -> resp.data } if (jsonState) { state.JSON = JsonOutput.toJson(JSON) } def devices = JSON.deviceListUsages.devices def combinedTotals = 0 def needsRetry = false // The EmporiaVue API sometimes returns a null value due to the server being busy. // The following code attempts another refresh for up to 5 tries after waiting 10 seconds. devices.each { value -> value.channelUsages.each { next_value -> def usage = next_value.usage if (debugLog) log.debug next_value if (usage == null) { if (debugLog) log.debug "Null value encountered on ${next_value.name}, attempting another refresh" needsRetry = true if (attempts < 6) { runIn(10, attemptRefresh, [overwrite: false, data: [host: host, Gid_string: Gid_string, instant: instant, energyUnit: energyUnit, attempts: attempts + 1]]) return } else { log.error "Null value encountered on ${next_value.name} after 5 attempts. Check EmporiaVue Mobile App to see if device is online" return } } def Wh = roundToTwoDecimalPlaces(convertToWh(usage) ?: 0) combinedTotals += Wh } } if (!needsRetry) { sendEvent(name: energyUnit, value: "${roundToTwoDecimalPlaces(combinedTotals / 1000)}") } } // Placeholder method for rounding to two decimal places def roundToTwoDecimalPlaces(value) { return Math.round(value * 100) / 100 } def authToken(token) { state.idToken = token now = new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'") state.lastTokenUpdate = timeToday(now) } def convertToWh(usage) { if (usage != null) { switch(scale) { case "1S": return Math.round(usage * 60 * 60 * 1000) case "1MIN": return Math.round(usage * 60 * 1000) default: return Math.round(usage * 1000) } } }