diff --git a/devicetypes/smartthings/aeon-home-energy-meter-c3.src/aeon-home-energy-meter-c3.groovy b/devicetypes/smartthings/aeon-home-energy-meter-c3.src/aeon-home-energy-meter-c3.groovy index 3d7d73c..90291d5 100644 --- a/devicetypes/smartthings/aeon-home-energy-meter-c3.src/aeon-home-energy-meter-c3.groovy +++ b/devicetypes/smartthings/aeon-home-energy-meter-c3.src/aeon-home-energy-meter-c3.groovy @@ -56,7 +56,7 @@ metadata { state "configure", label: '', action: "configuration.configure", icon: "st.secondary.configure" } - PLATFORM_graphTile(name: "powerGraph", attribute: "device.power") + graphTile(name: "powerGraph", attribute: "device.power") main(["power", "energy"]) details(["powerGraph", "power", "energy", "reset", "refresh", "configure"]) @@ -68,16 +68,8 @@ metadata { // ======================================================== preferences { - input name: "graphPrecision", type: "enum", title: "Graph Precision", description: "Daily", required: true, options: PLATFORM_graphPrecisionOptions(), defaultValue: "Daily" - input name: "graphType", type: "enum", title: "Graph Type", description: selectedGraphType(), required: false, options: PLATFORM_graphTypeOptions() -} - -def selectedGraphPrecision() { - graphPrecision ?: "Daily" -} - -def selectedGraphType() { - graphType ?: "line" + input name: "graphPrecision", type: "enum", title: "Graph Precision", description: "Daily", required: true, options: graphPrecisionOptions(), defaultValue: "Daily" + input name: "graphType", type: "enum", title: "Graph Type", description: "line", required: false, options: graphTypeOptions() } // ======================================================== @@ -91,22 +83,6 @@ mappings { GET: "renderGraph" ] } - path("/graphDataSizes") { // for testing. remove before publishing - action: - [ - GET: "graphDataSizes" - ] - } -} - -def graphDataSizes() { // for testing. remove before publishing - state.findAll { k, v -> k.startsWith("measure.") }.inject([:]) { attributes, attributeData -> - attributes[attributeData.key] = attributeData.value.inject([:]) { dateTypes, dateTypeData -> - dateTypes[dateTypeData.key] = dateTypeData.value.size() - dateTypes - } - attributes - } } // ======================================================== @@ -121,8 +97,7 @@ def parse(String description) { } log.debug "Parse returned ${result?.descriptionText}" - PLATFORM_migrateGraphDataIfNeeded() - PLATFORM_storeData(result.name, result.value) + storeGraphData(result.name, result.value) return result } @@ -176,535 +151,15 @@ def configure() { def renderGraph() { - def data = PLATFORM_fetchGraphData(params.attribute) + def data = fetchGraphData(params.attribute) def totalData = data*.runningSum def xValues = data*.unixTime def yValues = [ - Total: [color: "#49a201", data: totalData, type: selectedGraphType()] + Total: [color: "#49a201", data: totalData] ] - PLATFORM_renderGraph(attribute: params.attribute, xValues: xValues, yValues: yValues, focus: "Total", label: "Watts") -} - -// TODO: // ======================================================== -// TODO: // PLATFORM CODE !!! DO NOT ALTER !!! -// TODO: // ======================================================== - -// ======================================================== -// PLATFORM TILES -// ======================================================== - -def PLATFORM_graphTile(Map tileParams) { - def cleanAttribute = tileParams.attribute - "device." - "capability." - htmlTile([name: tileParams.name, attribute: tileParams.attribute, action: "graph/${cleanAttribute}", width: 3, height: 2] + tileParams) -} - -// ======================================================== -// PLATFORM GRAPH RENDERING -// ======================================================== - -private PLATFORM_graphTypeOptions() { - [ - "line", // DEFAULT - "spline", - "step", - "area", - "area-spline", - "area-step", - "bar", - "scatter", - "pie", - "donut", - "gauge", - ] -} - -private PLATFORM_renderGraph(graphParams) { - - String attribute = graphParams.attribute - List xValues = graphParams.xValues - Map yValues = graphParams.yValues - String focus = graphParams.focus ?: "" - String label = graphParams.label ?: "" - - /* - def xValues = [1, 2] - - def yValues = [ - High: [type: "spline", data: [5, 6], color: "#bc2323"], - Low: [type: "spline", data: [0, 1], color: "#153591"] - ] - - Available type values: - line // DEFAULT - spline - step - area - area-spline - area-step - bar - scatter - pie - donut - gauge - -*/ - - def graphData = PLATFORM_buildGraphData(xValues, yValues, label) - - def legendData = yValues*.key - def focusJS = focus ? "chart.focus('${focus}')" : "// focus not specified" - def flowColumn = focus ?: yValues ? yValues.keySet().first() : null - - def htmlTitle = "${(device.label ?: device.name)} ${attribute.capitalize()} Graph" - renderHTML(htmlTitle) { html -> - html.head { - """ - - - - - - - - - - - """ - } - html.body { - """ -
- - - - """ - } - } -} - -private PLATFORM_buildGraphData(List xValues, Map yValues, String label = "") { - - /* - def xValues = [1, 2] - - def yValues = [ - High: [type: "spline", data: [5, 6], color: "#bc2323"], - Low: [type: "spline", data: [0, 1], color: "#153591"] - ] - */ - - [ - interaction: [ - enabled: false - ], - bindto : '#chart', - padding : [ - left : 30, - right : 30, - bottom: 0, - top : 0 - ], - legend : [ - show: false, -// hide : false,//(yValues.keySet().size() < 2), -// position: 'inset', -// inset: [ -// anchor: "top-right" -// ], -// item: [ -// onclick: "do nothing" // (yValues.keySet().size() > 1) ? null : "do nothing" -// ] - ], - data : [ - x : "x", - columns: [(["x"] + xValues)] + yValues.collect { k, v -> [k] + v.data }, - types : yValues.inject([:]) { total, current -> total[current.key] = current.value.type; return total }, - colors : yValues.inject([:]) { total, current -> total[current.key] = current.value.color; return total } - ], - axis : [ - x: [ - type: 'timeseries', - tick: [ - centered: true, - culling : [max: 7], - fit : true, - format : PLATFORM_getGraphDateFormat() -// format: PLATFORM_getGraphDateFormatFunction() // throws securityException when trying to escape javascript - ] - ], - y: [ - label : label, - padding: [ - top: 50 - ] - ] - ] - ] -} - -private PLATFORM_getGraphDateFormat(dateType = selectedGraphPrecision()) { - // https://github.com/mbostock/d3/wiki/Time-Formatting - def graphDateFormat - switch (dateType) { - case "Live": - graphDateFormat = "%I:%M" // hour (12-hour clock) as a decimal number [00,12] // AM or PM - break - case "Hourly": - graphDateFormat = "%I %p" // hour (12-hour clock) as a decimal number [00,12] // AM or PM - break - case "Daily": - graphDateFormat = "%a" // abbreviated weekday name - break - case "Monthly": - graphDateFormat = "%b" // abbreviated month name - break - case "Annually": - graphDateFormat = "%y" // year without century as a decimal number [00,99] - break - } - graphDateFormat -} - -private String PLATFORM_getGraphDateFormatFunction(dateType = selectedGraphPrecision()) { - def graphDateFunction = "function(date) { return date; }" - switch (dateType) { - case "Live": - graphDateFunction = """ - function(date) { - return.getMinutes(); - } - """ - break; - case "Hourly": - graphDateFunction = """ function(date) { - var hour = date.getHours(); - if (hour == 0) { - return String(/12 am/).substring(1).slice(0,-1); - } else if (hour > 12) { - return hour -12 + String(/ pm/).substring(1).slice(0,-1); - } else { - return hour + String(/ am/).substring(1).slice(0,-1); - } - }""" - break - case "Daily": - graphDateFunction = """ function(date) { - var day = date.getDay(); - switch(day) { - case 0: return String(/Sun/).substring(1).slice(0,-1); - case 1: return String(/Mon/).substring(1).slice(0,-1); - case 2: return String(/Tue/).substring(1).slice(0,-1); - case 3: return String(/Wed/).substring(1).slice(0,-1); - case 4: return String(/Thu/).substring(1).slice(0,-1); - case 5: return String(/Fri/).substring(1).slice(0,-1); - case 6: return String(/Sat/).substring(1).slice(0,-1); - } - }""" - break - case "Monthly": - graphDateFunction = """ function(date) { - var month = date.getMonth(); - switch(month) { - case 0: return String(/Jan/).substring(1).slice(0,-1); - case 1: return String(/Feb/).substring(1).slice(0,-1); - case 2: return String(/Mar/).substring(1).slice(0,-1); - case 3: return String(/Apr/).substring(1).slice(0,-1); - case 4: return String(/May/).substring(1).slice(0,-1); - case 5: return String(/Jun/).substring(1).slice(0,-1); - case 6: return String(/Jul/).substring(1).slice(0,-1); - case 7: return String(/Aug/).substring(1).slice(0,-1); - case 8: return String(/Sep/).substring(1).slice(0,-1); - case 9: return String(/Oct/).substring(1).slice(0,-1); - case 10: return String(/Nov/).substring(1).slice(0,-1); - case 11: return String(/Dec/).substring(1).slice(0,-1); - } - }""" - break - case "Annually": - graphDateFunction = """ - function(date) { - return.getFullYear(); - } - """ - break - } - groovy.json.StringEscapeUtils.escapeJavaScript(graphDateFunction) -} - -private jsEscapeString(str = "") { - "String(/${str}/).substring(1).slice(0,-1);" -} - -private PLATFORM_fetchGraphData(attribute) { - - log.debug "PLATFORM_fetchGraphData(${attribute})" - - /* - [ - [ - dateString: "2014-12-1", - unixTime: 1421931600000, - min: 0, - max: 10, - average: 5 - ], - ... - ] - */ - - def attributeBucket = state["measure.${attribute}"] ?: [:] - def dateType = selectedGraphPrecision() - attributeBucket[dateType] -} - -// ======================================================== -// PLATFORM DATA STORAGE -// ======================================================== - -private PLATFORM_graphPrecisionOptions() { ["Live", "Hourly", "Daily", "Monthly", "Annually"] } - -private PLATFORM_storeData(attribute, value) { - PLATFORM_graphPrecisionOptions().each { dateType -> - PLATFORM_addDataToBucket(attribute, value, dateType) - } -} - -/* -[ - Hourly: [ - [ - dateString: "2014-12-1", - unixTime: 1421931600000, - min: 0, - max: 10, - average: 5 - ], - ... - ], - ... -] -*/ - -private PLATFORM_addDataToBucket(attribute, value, dateType) { - - def numberValue = value.toBigDecimal() - - def attributeKey = "measure.${attribute}" - def attributeBucket = state[attributeKey] ?: [:] - - def dateTypeBucket = attributeBucket[dateType] ?: [] - - def now = new Date() - def itemDateString = now.format("PLATFORM_get${dateType}Format"()) - def item = dateTypeBucket.find { it.dateString == itemDateString } - - if (!item) { - // no entry for this data point yet, fill with initial values - item = [:] - item.average = numberValue - item.runningSum = numberValue - item.runningCount = 1 - item.min = numberValue - item.max = numberValue - item.unixTime = now.getTime() - item.dateString = itemDateString - - // add the new data point - dateTypeBucket << item - - // clear out old data points - def old = PLATFORM_getOldDateString(dateType) - if (old) { // annual data never gets cleared - dateTypeBucket.findAll { it.unixTime < old }.each { dateTypeBucket.remove(it) } - } - - // limit the size of the bucket. Live data can stack up fast - def sizeLimit = 25 - if (dateTypeBucket.size() > sizeLimit) { - dateTypeBucket = dateTypeBucket[-sizeLimit..-1] - } - - } else { - //re-calculate average/min/max for this bucket - item.runningSum = (item.runningSum.toBigDecimal()) + numberValue - item.runningCount = item.runningCount.toInteger() + 1 - item.average = item.runningSum.toBigDecimal() / item.runningCount.toInteger() - - if (item.min == null) { - item.min = numberValue - } else if (numberValue < item.min.toBigDecimal()) { - item.min = numberValue - } - if (item.max == null) { - item.max = numberValue - } else if (numberValue > item.max.toBigDecimal()) { - item.max = numberValue - } - } - - attributeBucket[dateType] = dateTypeBucket - state[attributeKey] = attributeBucket -} - -private PLATFORM_getOldDateString(dateType) { - def now = new Date() - def date - switch (dateType) { - case "Live": - date = now.getTime() - 60 * 60 * 1000 // 1h * 60m * 60s * 1000ms // 1 hour - break - case "Hourly": - date = (now - 1).getTime() - break - case "Daily": - date = (now - 10).getTime() - break - case "Monthly": - date = (now - 30).getTime() - break - case "Annually": - break - } - date -} - -private PLATFORM_getLiveFormat() { "HH:mm:ss" } - -private PLATFORM_getHourlyFormat() { "yyyy-MM-dd'T'HH" } - -private PLATFORM_getDailyFormat() { "yyyy-MM-dd" } - -private PLATFORM_getMonthlyFormat() { "yyyy-MM" } - -private PLATFORM_getAnnuallyFormat() { "yyyy" } - -// ======================================================== -// PLATFORM GRAPH DATA MIGRATION -// ======================================================== - -private PLATFORM_migrateGraphDataIfNeeded() { - if (!state.hasMigratedOldGraphData) { - def acceptableKeys = PLATFORM_graphPrecisionOptions() - def needsMigration = state.findAll { k, v -> v.keySet().findAll { !acceptableKeys.contains(it) } }.keySet() - needsMigration.each { PLATFORM_migrateGraphData(it) } - state.hasMigratedOldGraphData = true - } -} - -private PLATFORM_migrateGraphData(attribute) { - - log.trace "about to migrate ${attribute}" - - def attributeBucket = state[attribute] ?: [:] - def migratedAttributeBucket = [:] - - attributeBucket.findAll { k, v -> !PLATFORM_graphPrecisionOptions().contains(k) }.each { oldDateString, oldItem -> - - def dateType = oldDateString.contains('T') ? "Hourly" : PLATFORM_graphPrecisionOptions().find { - "PLATFORM_get${it}Format"().size() == oldDateString.size() - } - - def dateTypeFormat = "PLATFORM_get${dateType}Format"() - - def newBucket = attributeBucket[dateType] ?: [] -/* - def existingNewItem = newBucket.find { it.dateString == oldDateString } - if (existingNewItem) { - newBucket.remove(existingNewItem) - } -*/ - - def newItem = [ - min : oldItem.min, - max : oldItem.max, - average : oldItem.average, - runningSum : oldItem.runningSum, - runningCount: oldItem.runningCount, - dateString : oldDateString, - unixTime : new Date().parse(dateTypeFormat, oldDateString).getTime() - ] - - newBucket << newItem - migratedAttributeBucket[dateType] = newBucket - } - - state[attribute] = migratedAttributeBucket + renderGraph(attribute: params.attribute, xValues: xValues, yValues: yValues, focus: "Total", label: "Watts") } diff --git a/smartapps/smartthings/lifx-connect.src/lifx-connect.groovy b/smartapps/smartthings/lifx-connect.src/lifx-connect.groovy index df3f590..645e5ac 100644 --- a/smartapps/smartthings/lifx-connect.src/lifx-connect.groovy +++ b/smartapps/smartthings/lifx-connect.src/lifx-connect.groovy @@ -62,7 +62,7 @@ def authPage() { def options = locationOptions() ?: [] def count = options.size() - def refreshInterval = 3 + def refreshInterval = 3 return dynamicPage(name:"Credentials", title:"Select devices...", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) { section("Select your location") { @@ -391,4 +391,4 @@ def refreshDevices() { getChildDevices().each { device -> device.refresh() } -} +} \ No newline at end of file diff --git a/smartapps/smartthings/mood-cube.src/mood-cube.groovy b/smartapps/smartthings/mood-cube.src/mood-cube.groovy index 41e87c9..cfca67a 100644 --- a/smartapps/smartthings/mood-cube.src/mood-cube.groovy +++ b/smartapps/smartthings/mood-cube.src/mood-cube.groovy @@ -225,7 +225,7 @@ private restoreStates(sceneId) { if (type == "level") { log.debug "${light.displayName} level is '$level'" if (level != null) { - light.setLevel(value) + light.setLevel(level) } } else if (type == "color") { diff --git a/smartapps/smartthings/wattvision-manager.src/wattvision-manager.groovy b/smartapps/smartthings/wattvision-manager.src/wattvision-manager.groovy index 186685d..ad81049 100644 --- a/smartapps/smartthings/wattvision-manager.src/wattvision-manager.groovy +++ b/smartapps/smartthings/wattvision-manager.src/wattvision-manager.groovy @@ -346,18 +346,20 @@ private getSensorJSON(id, key) { def sensorUrl = "${wattvisionBaseURL()}/partners/smartthings/sensor_list?api_id=${id}&api_key=${key}" - httpGet(uri: sensorUrl) { response -> + httpGet(uri: sensorUrl) { response -> - def json = new org.json.JSONObject(response.data) + def sensors = [:] - state.sensors = json - - json.each { sensorId, sensorName -> + response.data.each { sensorId, sensorName -> + sensors[sensorId] = sensorName createChild(sensorId, sensorName) - } + } + + state.sensors = sensors return "success" } + } def createChild(sensorId, sensorName) { diff --git a/smartapps/smartthings/withings-manager.src/withings-manager.groovy b/smartapps/smartthings/withings-manager.src/withings-manager.groovy new file mode 100644 index 0000000..1e4d8ba --- /dev/null +++ b/smartapps/smartthings/withings-manager.src/withings-manager.groovy @@ -0,0 +1,775 @@ +/** + * Title: Withings Service Manager + * Description: Connect Your Withings Devices + * + * Author: steve + * Date: 1/9/15 + * + * + * Copyright 2015 steve + * + * 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: "Withings Manager", + namespace: "smartthings", + author: "SmartThings", + description: "Connect With Withings", + category: "", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/withings.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png", + oauth: true +) { + appSetting "consumerKey" + appSetting "consumerSecret" +} + +// ======================================================== +// PAGES +// ======================================================== + +preferences { + page(name: "authPage") +} + +def authPage() { + + def installOptions = false + def description = "Required (tap to set)" + def authState + + if (oauth_token()) { + // TODO: Check if it's valid + if (true) { + description = "Saved (tap to change)" + installOptions = true + authState = "complete" + } else { + // Worth differentiating here? (no longer valid vs. non-existent state.externalAuthToken?) + description = "Required (tap to set)" + } + } + + + dynamicPage(name: "authPage", install: installOptions, uninstall: true) { + section { + + if (installOptions) { + input(name: "withingsLabel", type: "text", title: "Add a name", description: null, required: true) + } + + href url: shortUrl("authenticate"), style: "embedded", required: false, title: "Authenticate with Withings", description: description, state: authState + } + } +} + +// ======================================================== +// MAPPINGS +// ======================================================== + +mappings { + path("/authenticate") { + action: + [ + GET: "authenticate" + ] + } + path("/x") { + action: + [ + GET: "exchangeTokenFromWithings" + ] + } + path("/n") { + action: + [POST: "notificationReceived"] + } + + path("/test/:action") { + action: + [GET: "test"] + } +} + +def test() { + "${params.action}"() +} + +def authenticate() { + // do not hit userAuthorizationUrl when the page is executed. It will replace oauth_tokens + // instead, redirect through here so we know for sure that the user wants to authenticate + // plus, the short-lived tokens that are used during authentication are only valid for 2 minutes + // so make sure we give the user as much of that 2 minutes as possible to enter their credentials and deal with network latency + log.trace "starting Withings authentication flow" + redirect location: userAuthorizationUrl() +} + +def exchangeTokenFromWithings() { + // Withings hits us here during the oAuth flow +// log.trace "exchangeTokenFromWithings ${params}" + atomicState.userid = params.userid // TODO: restructure this for multi-user access + exchangeToken() +} + +def notificationReceived() { +// log.trace "notificationReceived params: ${params}" + + def notificationParams = [ + startdate: params.startdate, + userid : params.userid, + enddate : params.enddate, + ] + + def measures = wGetMeasures(notificationParams) + sendMeasureEvents(measures) + return [status: 0] +} + +// ======================================================== +// HANDLERS +// ======================================================== + + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + +// wRevokeAllNotifications() + + unsubscribe() + initialize() +} + +def initialize() { + if (!getChild()) { createChild() } + app.updateLabel(withingsLabel) + wCreateNotification() + backfillMeasures() +} + +// ======================================================== +// CHILD DEVICE +// ======================================================== + +private getChild() { + def children = childDevices + children.size() ? children.first() : null +} + +private void createChild() { + def child = addChildDevice("smartthings", "Withings User", userid(), null, [name: app.label, label: withingsLabel]) + atomicState.child = [dni: child.deviceNetworkId] +} + +// ======================================================== +// URL HELPERS +// ======================================================== + +def stBaseUrl() { + if (!atomicState.serverUrl) { + stToken() + atomicState.serverUrl = buildActionUrl("").split(/api\//).first() + } + return atomicState.serverUrl +} + +def stToken() { + atomicState.accessToken ?: createAccessToken() +} + +def shortUrl(path = "", urlParams = [:]) { + attachParams("${stBaseUrl()}api/t/${stToken()}/s/${app.id}/${path}", urlParams) +} + +def noTokenUrl(path = "", urlParams = [:]) { + attachParams("${stBaseUrl()}api/smartapps/installations/${app.id}/${path}", urlParams) +} + +def attachParams(url, urlParams = [:]) { + [url, toQueryString(urlParams)].findAll().join("?") +} + +String toQueryString(Map m = [:]) { +// log.trace "toQueryString. URLEncoder will be used on ${m}" + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +// ======================================================== +// WITHINGS MEASURES +// ======================================================== + +def unixTime(date = new Date()) { + def unixTime = date.time / 1000 as int +// log.debug "converting ${date.time} to ${unixTime}" + unixTime +} + +def backfillMeasures() { +// log.trace "backfillMeasures" + def measureParams = [startdate: unixTime(new Date() - 10)] + def measures = wGetMeasures(measureParams) + sendMeasureEvents(measures) +} + +// this is body measures. // TODO: get activity and others too +def wGetMeasures(measureParams = [:]) { + def baseUrl = "https://wbsapi.withings.net/measure" + def urlParams = [ + action : "getmeas", + userid : userid(), + startdate : unixTime(new Date() - 5), + enddate : unixTime(), + oauth_token: oauth_token() + ] + measureParams + def measureData = fetchDataFromWithings(baseUrl, urlParams) +// log.debug "measureData: ${measureData}" + measureData.body.measuregrps.collect { parseMeasureGroup(it) }.flatten() +} +/* +[ + body:[ + measuregrps:[ + [ + category:1, // 1 for real measurements, 2 for user objectives. + grpid:310040317, + measures:[ + [ + unit:0, // Power of ten the "value" parameter should be multiplied to to get the real value. Eg : value = 20 and unit=-1 means the value really is 2.0 + value:60, // Value for the measure in S.I units (kilogram, meters, etc.). Value should be multiplied by 10 to the power of "unit" (see below) to get the real value. + type:11 // 1 : Weight (kg), 4 : Height (meter), 5 : Fat Free Mass (kg), 6 : Fat Ratio (%), 8 : Fat Mass Weight (kg), 9 : Diastolic Blood Pressure (mmHg), 10 : Systolic Blood Pressure (mmHg), 11 : Heart Pulse (bpm), 54 : SP02(%) + ], + [ + unit:-3, + value:-1000, + type:18 + ] + ], + date:1422750210, + attrib:2 + ] + ], + updatetime:1422750227 + ], + status:0 +] +*/ + +def sendMeasureEvents(measures) { +// log.debug "measures: ${measures}" + measures.each { + if (it.name && it.value) { + sendEvent(userid(), it) + } + } +} + +def parseMeasureGroup(measureGroup) { + long time = measureGroup.date // must be long. INT_MAX is too small + time *= 1000 + measureGroup.measures.collect { parseMeasure(it) + [date: new Date(time)] } +} + +def parseMeasure(measure) { +// log.debug "parseMeasure($measure)" + [ + name : measureAttribute(measure), + value: measureValue(measure) + ] +} + +def measureValue(measure) { + def value = measure.value * 10.power(measure.unit) + if (measure.type == 1) { // Weight (kg) + value *= 2.20462262 // kg to lbs + } + value +} + +String measureAttribute(measure) { + def attribute = "" + switch (measure.type) { + case 1: attribute = "weight"; break; + case 4: attribute = "height"; break; + case 5: attribute = "leanMass"; break; + case 6: attribute = "fatRatio"; break; + case 8: attribute = "fatMass"; break; + case 9: attribute = "diastolicPressure"; break; + case 10: attribute = "systolicPressure"; break; + case 11: attribute = "heartPulse"; break; + case 54: attribute = "SP02"; break; + } + return attribute +} + +String measureDescription(measure) { + def description = "" + switch (measure.type) { + case 1: description = "Weight (kg)"; break; + case 4: description = "Height (meter)"; break; + case 5: description = "Fat Free Mass (kg)"; break; + case 6: description = "Fat Ratio (%)"; break; + case 8: description = "Fat Mass Weight (kg)"; break; + case 9: description = "Diastolic Blood Pressure (mmHg)"; break; + case 10: description = "Systolic Blood Pressure (mmHg)"; break; + case 11: description = "Heart Pulse (bpm)"; break; + case 54: description = "SP02(%)"; break; + } + return description +} + +// ======================================================== +// WITHINGS NOTIFICATIONS +// ======================================================== + +def wNotificationBaseUrl() { "https://wbsapi.withings.net/notify" } + +def wNotificationCallbackUrl() { shortUrl("n") } + +def wGetNotification() { + def userId = userid() + def url = wNotificationBaseUrl() + def params = [ + action: "subscribe" + ] + +} + +// TODO: keep track of notification expiration +def wCreateNotification() { + def baseUrl = wNotificationBaseUrl() + def urlParams = [ + action : "subscribe", + userid : userid(), + callbackurl: wNotificationCallbackUrl(), + oauth_token: oauth_token(), + comment : "hmm" // TODO: figure out what to do here. spaces seem to break the request + ] + + fetchDataFromWithings(baseUrl, urlParams) +} + +def wRevokeAllNotifications() { + def notifications = wListNotifications() + notifications.each { + wRevokeNotification([callbackurl: it.callbackurl]) // use the callbackurl Withings has on file + } +} + +def wRevokeNotification(notificationParams = [:]) { + def baseUrl = wNotificationBaseUrl() + def urlParams = [ + action : "revoke", + userid : userid(), + callbackurl: wNotificationCallbackUrl(), + oauth_token: oauth_token() + ] + notificationParams + + fetchDataFromWithings(baseUrl, urlParams) +} + +def wListNotifications() { + + /* + { + body: { + profiles: [ + { + appli: 1, + expires: 2147483647, + callbackurl: "https://graph.api.smartthings.com/api/t/72ab3e57-5839-4cca-9562-dcc818f83bc9/s/537757a0-c4c8-40ea-8cea-aa283915bbd9/n", + comment: "hmm" + } + ] + }, + status: 0 + }*/ + + def baseUrl = wNotificationBaseUrl() + def urlParams = [ + action : "list", + userid : userid(), + callbackurl: wNotificationCallbackUrl(), + oauth_token: oauth_token() + ] + + def notificationData = fetchDataFromWithings(baseUrl, urlParams) + notificationData.body.profiles +} + +def defaultOauthParams() { + defaultParameterKeys().inject([:]) { keyMap, currentKey -> + keyMap[currentKey] = "${currentKey}"() + keyMap + } +} + +// ======================================================== +// WITHINGS DATA FETCHING +// ======================================================== + +def fetchDataFromWithings(baseUrl, urlParams) { + +// log.debug "fetchDataFromWithings(${baseUrl}, ${urlParams})" + + def defaultParams = defaultOauthParams() + def paramStrings = buildOauthParams(urlParams + defaultParams) +// log.debug "paramStrings: $paramStrings" + def url = buildOauthUrl(baseUrl, paramStrings, oauth_token_secret()) + def json +// log.debug "about to make request to ${url}" + httpGet(uri: url, headers: ["Content-Type": "application/json"]) { response -> + json = new groovy.json.JsonSlurper().parse(response.data) + } + return json +} + +// ======================================================== +// WITHINGS OAUTH LOGGING +// ======================================================== + +def wLogEnabled() { false } // For troubleshooting Oauth flow + +void wLog(message = "") { + if (!wLogEnabled()) { return } + def wLogMessage = atomicState.wLogMessage + if (wLogMessage.length()) { + wLogMessage += "\n|" + } + wLogMessage += message + atomicState.wLogMessage = wLogMessage +} + +void wLogNew(seedMessage = "") { + if (!wLogEnabled()) { return } + def olMessage = atomicState.wLogMessage + if (oldMessage) { + log.debug "purging old wLogMessage: ${olMessage}" + } + atomicState.wLogMessage = seedMessage +} + +String wLogMessage() { + if (!wLogEnabled()) { return } + def wLogMessage = atomicState.wLogMessage + atomicState.wLogMessage = "" + wLogMessage +} + +// ======================================================== +// WITHINGS OAUTH DESCRIPTION +// >>>>>> The user opens the authPage for this SmartApp +// STEP 1 get a token to be used in the url the user taps +// STEP 2 generate the url to be tapped by the user +// >>>>>> The user taps the url and logs in to Withings +// STEP 3 generate a token to be used for accessing user data +// STEP 4 access user data +// ======================================================== + +// ======================================================== +// WITHINGS OAUTH STEP 1: get an oAuth "request token" +// ======================================================== + +def requestTokenUrl() { + wLogNew "WITHINGS OAUTH STEP 1: get an oAuth 'request token'" + + def keys = defaultParameterKeys() + "oauth_callback" + def paramStrings = buildOauthParams(keys.sort()) + + buildOauthUrl("https://oauth.withings.com/account/request_token", paramStrings, "") +} + +// ======================================================== +// WITHINGS OAUTH STEP 2: End-user authorization +// ======================================================== + +def userAuthorizationUrl() { + + // get url from Step 1 + def tokenUrl = requestTokenUrl() + + // collect token from Withings + collectTokenFromWithings(tokenUrl) + + wLogNew "WITHINGS OAUTH STEP 2: End-user authorization" + + def keys = defaultParameterKeys() + "oauth_token" + def paramStrings = buildOauthParams(keys.sort()) + + buildOauthUrl("https://oauth.withings.com/account/authorize", paramStrings, oauth_token_secret()) +} + +// ======================================================== +// WITHINGS OAUTH STEP 3: Generating access token +// ======================================================== + +def exchangeTokenUrl() { + wLogNew "WITHINGS OAUTH STEP 3: Generating access token" + + def keys = defaultParameterKeys() + ["oauth_token", "userid"] + def paramStrings = buildOauthParams(keys.sort()) + + buildOauthUrl("https://oauth.withings.com/account/access_token", paramStrings, oauth_token_secret()) +} + +def exchangeToken() { + + def tokenUrl = exchangeTokenUrl() +// log.debug "about to hit ${tokenUrl}" + + try { + // replace old token with a long-lived token + def token = collectTokenFromWithings(tokenUrl) +// log.debug "collected token from Withings: ${token}" + renderAction("authorized", "Withings Connection") + } + catch (Exception e) { + log.error e + renderAction("notAuthorized", "Withings Connection Failed") + } +} + +// ======================================================== +// OAUTH 1.0 +// ======================================================== + +def defaultParameterKeys() { + [ + "oauth_consumer_key", + "oauth_nonce", + "oauth_signature_method", + "oauth_timestamp", + "oauth_version" + ] +} + +def oauth_consumer_key() { consumerKey } + +def oauth_nonce() { nonce() } + +def nonce() { UUID.randomUUID().toString().replaceAll("-", "") } + +def oauth_signature_method() { "HMAC-SHA1" } + +def oauth_timestamp() { (int) (new Date().time / 1000) } + +def oauth_version() { 1.0 } + +def oauth_callback() { shortUrl("x") } + +def oauth_token() { atomicState.wToken?.oauth_token } + +def oauth_token_secret() { atomicState.wToken?.oauth_token_secret } + +def userid() { atomicState.userid } + +String hmac(String oAuthSignatureBaseString, String oAuthSecret) throws java.security.SignatureException { + if (!oAuthSecret.contains("&")) { log.warn "Withings requires \"&\" to be included no matter what" } + // get an hmac_sha1 key from the raw key bytes + def signingKey = new javax.crypto.spec.SecretKeySpec(oAuthSecret.getBytes(), "HmacSHA1") + // get an hmac_sha1 Mac instance and initialize with the signing key + def mac = javax.crypto.Mac.getInstance("HmacSHA1") + mac.init(signingKey) + // compute the hmac on input data bytes + byte[] rawHmac = mac.doFinal(oAuthSignatureBaseString.getBytes()) + return org.apache.commons.codec.binary.Base64.encodeBase64String(rawHmac) +} + +Map parseResponseString(String responseString) { +// log.debug "parseResponseString: ${responseString}" + responseString.split("&").inject([:]) { c, it -> + def parts = it.split('=') + def k = parts[0] + def v = parts[1] + c[k] = v + return c + } +} + +String applyParams(endpoint, oauthParams) { endpoint + "?" + oauthParams.sort().join("&") } + +String buildSignature(endpoint, oAuthParams, oAuthSecret) { + def oAuthSignatureBaseParts = ["GET", endpoint, oAuthParams.join("&")] + def oAuthSignatureBaseString = oAuthSignatureBaseParts.collect { URLEncoder.encode(it) }.join("&") + wLog " ==> oAuth signature base string : \n${oAuthSignatureBaseString}" + wLog " .. applying hmac-sha1 to base string, with secret : ${oAuthSecret} (notice the \"&\")" + wLog " .. base64 encode then url-encode the hmac-sha1 hash" + String hmacResult = hmac(oAuthSignatureBaseString, oAuthSecret) + def signature = URLEncoder.encode(hmacResult) + wLog " ==> oauth_signature = ${signature}" + return signature +} + +List buildOauthParams(List parameterKeys) { + wLog " .. adding oAuth parameters : " + def oauthParams = [] + parameterKeys.each { key -> + def value = "${key}"() + wLog " ${key} = ${value}" + oauthParams << "${key}=${URLEncoder.encode(value.toString())}" + } + + wLog " .. sorting all request parameters alphabetically " + oauthParams.sort() +} + +List buildOauthParams(Map parameters) { + wLog " .. adding oAuth parameters : " + def oauthParams = [] + parameters.each { k, v -> + wLog " ${k} = ${v}" + oauthParams << "${k}=${URLEncoder.encode(v.toString())}" + } + + wLog " .. sorting all request parameters alphabetically " + oauthParams.sort() +} + +String buildOauthUrl(String endpoint, List parameterStrings, String oAuthTokenSecret) { + wLog "Api endpoint : ${endpoint}" + + wLog "Signing request :" + def oAuthSecret = "${consumerSecret}&${oAuthTokenSecret}" + def signature = buildSignature(endpoint, parameterStrings, oAuthSecret) + + parameterStrings << "oauth_signature=${signature}" + + def finalUrl = applyParams(endpoint, parameterStrings) + wLog "Result: ${finalUrl}" + if (wLogEnabled()) { + log.debug wLogMessage() + } + return finalUrl +} + +def collectTokenFromWithings(tokenUrl) { + // get token from Withings using the url generated in Step 1 + def tokenString + httpGet(uri: tokenUrl) { resp -> // oauth_token=Your Withings scale is now connected to SmartThings!
+Click 'Done' to finish setup.
+There was an error connecting to SmartThings!
+Click 'Done' to try again.
+