/** * Curb (Connect) * * Copyright 2017 Justin Haines * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * */ definition( name: "Curb (Connect)", namespace: "jhaines0", author: "Justin Haines", description: "App to get usage data from a Curb home energy monitor", category: "", iconUrl: "http://energycurb.com/wp-content/uploads/2015/12/curb-web-logo.png", iconX2Url: "http://energycurb.com/wp-content/uploads/2015/12/curb-web-logo.png", iconX3Url: "http://energycurb.com/wp-content/uploads/2015/12/curb-web-logo.png", singleInstance: true ){ appSetting "clientId" } preferences { page(name: "auth", title: "Curb", nextPage:"", content:"authPage", uninstall: true) } mappings { path("/oauth/initialize") {action: [GET: "oauthInitUrl"]} path("/oauth/callback") {action: [GET: "callback"]} } def authPage() { log.debug "authPage()" if(!atomicState.accessToken) { atomicState.accessToken = createAccessToken() } if(atomicState.authToken) { log.debug("Already Connected") return dynamicPage(name: "auth", title: "Connected", nextPage: "", install: true, uninstall: true) { section() { paragraph("You are connected to Curb") } section() { paragraph("If you need more frequent measurements, you may adjust the update rate below. [1-15]") input "samplesPerMinute", "number", required: false, title: "Sample Rate (samples per minute)", defaultValue: 1, range: "1..15" } } } else { log.debug("Logging In") return dynamicPage(name: "auth", title: "Login", nextPage: "", uninstall:false) { section() { paragraph("Tap below to log in to the Curb service and authorize SmartThings access") href url:buildRedirectUrl, style:"embedded", required:true, title:"Curb", description:"Click to enter Curb Credentials" } } } } def oauthInitUrl() { atomicState.oauthInitState = UUID.randomUUID().toString() def oauthParams = [ response_type: "code", scope: "offline_access", audience: "app.energycurb.com/api", client_id: curbClientId, connection: "Users", state: atomicState.oauthInitState, redirect_uri: callbackUrl ] redirect(location: "${curbLoginUrl}?${toQueryString(oauthParams)}") } def callback() { log.debug "callback()>> params: $params, params.code ${params.code}" def code = params.code def oauthState = params.state if (oauthState == atomicState.oauthInitState) { def tokenParams = [ grant_type : "authorization_code", code : code, client_id : curbClientId, client_secret : curbClientSecret, redirect_uri : callbackUrl ] httpPostJson([uri: curbTokenUrl, body: tokenParams]) { resp -> log.debug("Got POST response: ${resp.data}") atomicState.refreshToken = resp.data.refresh_token atomicState.authToken = resp.data.access_token getCurbLocations() getUsage() } if (atomicState.authToken) { success() } else { fail() } } else { log.error "callback() failed oauthState != atomicState.oauthInitState" } } def success() { def message = """

Your Curb account is now connected to SmartThings!

Click 'Done' to finish setup.

""" connectionStatus(message) } def fail() { def message = """

The connection could not be established!

Click 'Done' to return to the menu.

""" connectionStatus(message) } def connectionStatus(message, redirectUrl = null) { def redirectHtml = "" if (redirectUrl) { redirectHtml = """ """ } def html = """ Curb & SmartThings connection
Curb Icon connected device icon SmartThings logo ${message}
""" render contentType: 'text/html', data: html } def installed() { log.debug "Installed with settings: ${settings}" initialize() } def updated() { log.debug "Updated with settings: ${settings}" unsubscribe() initialize() } def initialize() { log.debug "Initializing" unschedule() refreshAuthToken() getCurbLocations() getUsage() //runEvery1Minute(getUsageFromHistorical) runEvery5Minutes(getHistorical) runEvery3Hours(refreshAuthToken) def rate = settings.samplesPerMinute log.debug("Sampling at ${rate} samples per minute") schedule("* * * * * ?", doPoll, [data: [cycles: rate]]) } def doPoll(data) { getUsage() def period = 60.0/settings.samplesPerMinute //log.debug("Period: ${period}") def count = data.cycles; count = count - 1; if(count > 0) { runIn(period, doPoll, [data: [cycles: count]]) } } def uninstalled() { log.debug "Uninstalling" removeChildDevices(getChildDevices()) } private removeChildDevices(delete) { delete.each { deleteChildDevice(it.deviceNetworkId) } } def getCurbLocations() { log.debug("Requesting Curb location info"); def params = [ uri: "http://app.energycurb.com", path: "/api/locations", headers: ["Authorization": "Bearer ${atomicState.authToken}"], ] try { httpGet(params) { resp -> atomicState.location = resp.data[0].id log.debug("Location ID: ${atomicState.location}") } } catch (e) { log.error "Could not get location info: $e" } } def updateChildDevice(dni, label, values) { if(dni == null) { log.error("Tried to create a null child device! Label: $label") return; } try { def existingDevice = getChildDevice(dni) if(!existingDevice) { if(values instanceof Collection) { // Trying to update a non-existent device with historical data, just skip it return; } else { // Otherwise create it existingDevice = addChildDevice("jhaines0", "Curb Power Meter", dni, null, [name: "${dni}", label: "${label}"]) } } existingDevice.handleMeasurements(values) } catch (e) { log.error "Error creating or updating device: ${e}" } } include 'asynchttp_v1' def getUsageFromHistorical() { log.debug("Getting Usage (from Historical)") def params = [ uri: "https://app.energycurb.com", path: "/api/historical/${atomicState.location}/5m/m", headers: ["Authorization": "Bearer ${atomicState.authToken}"], requestContentType: 'application/json' ] asynchttp_v1.get(processUsageFromHistorical, params) } def processUsageFromHistorical(resp, data) { if (resp.hasError()) { log.debug "Usage from Historical Response Error: ${resp.getErrorMessage()}" return } log.debug " -> Got Usage (from Historical)" def json = resp.json if(json) { //log.debug "Got Usage: ${json}" def mainSum = 0.0 json.each { //log.debug "Have sensor: ${it.label} (${it.id})" def latest = it.values.max {vv -> vv.t} if(it.main) { mainSum += latest.w } else { updateChildDevice("${it.id}", it.label, latest.w) } } updateChildDevice("__MAIN__", "Main", mainSum) } else { log.error "Malformed data in usage from historical: ${resp.data}" } } def getUsage() { log.debug("Getting Usage") def params = [ uri: "https://app.energycurb.com", path: "/api/latest/${atomicState.location}", headers: ["Authorization": "Bearer ${atomicState.authToken}"], requestContentType: 'application/json' ] asynchttp_v1.get(processUsage, params) } def processUsage(resp, data) { if (resp.hasError()) { log.debug "Usage Response Error: ${resp.getErrorMessage()}, falling back to historical" getUsageFromHistorical() return } def json = resp.json if(json) { log.debug " -> Got Usage Data (${json.circuits.size()} sensors)" //log.debug "Got Latest: ${json}" json.circuits.each { //log.debug "Have sensor: ${it.label} (${it.id})" updateChildDevice("${it.id}", it.label, it.w) } updateChildDevice("__MAIN__", "Main", json.net) } else { log.error "Malformed usage data: ${resp.data}" } } def getHistorical() { log.debug("Getting Historical") def params = [ uri: "https://app.energycurb.com", path: "/api/historical/${atomicState.location}/24h/5m", headers: ["Authorization": "Bearer ${atomicState.authToken}"], requestContentType: 'application/json' ] asynchttp_v1.get(processHistorical, params) } def processHistorical(resp, data) { if (resp.hasError()) { log.debug "Historical Response Error: ${resp.getErrorMessage()}" return } def json = resp.json if(json) { log.debug " -> Got Historical Data" def total = null json.each { updateChildDevice("${it.id}", it.label, it.values) if(it.main) { it.values.sort{a,b -> a.t <=> b.t} if(total == null) { total = it } else { if(it.values.size() != total.values.size()) { log.debug("Size mismatch") } else { for(int i = 0; i < total.values.size(); ++i) { if(total.values[i].t != it.values[i].t) { log.debug("Time mismatch") } else { total.values[i].w = (total.values[i].w) + (it.values[i].w) } } } } } } updateChildDevice("__MAIN__", "Main", total.values) } else { log.error "Malformed historical data: ${resp.data}" } } def toQueryString(Map m) { return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") } def refreshAuthToken() { log.debug "refreshing auth token" if(!atomicState.refreshToken) { log.warn "Can not refresh OAuth token since there is no refreshToken stored" } else { def tokenParams = [ grant_type: "refresh_token", client_id : curbClientId, client_secret : curbClientSecret, refresh_token: atomicState.refreshToken ] httpPostJson([uri: curbTokenUrl, body: tokenParams]) { resp -> log.debug "response contentType: ${resp.contentType}" log.debug("Got POST response (refresh): ${resp.data}") atomicState.authToken = resp.data.access_token } } } def getCurbClientId() { return "R7LHLp5rRr6ktb9hhXfMaILsjwmIinKa" } def getCurbClientSecret() { return "pcxoDsqCN7o_ny5KmEKJ2ci0gL5qqOSfxnzF6JIvwsfRsUVXFdD-DUc40kkhHAZR" } def getCurbAuthUrl() { return "https://energycurb.auth0.com" } def getCurbLoginUrl() { return "${curbAuthUrl}/authorize" } def getCurbTokenUrl() { return "${curbAuthUrl}/oauth/token" } def getServerUrl() { return "https://graph.api.smartthings.com" } def getShardUrl() { return getApiServerUrl() } def getCallbackUrl() { return "https://graph.api.smartthings.com/oauth/callback" } def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" } def getApiEndpoint() { return "https://api.energycurb.com" }