Merge branch 'production'

# Conflicts:
#	smartapps/smartthings/lifx-connect.src/lifx-connect.groovy
This commit is contained in:
bflorian
2015-09-09 08:30:56 -04:00
5 changed files with 793 additions and 561 deletions

View File

@@ -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 {
"""
<!-- Load c3.css -->
<link href="https://www.dropbox.com/s/m6ptp72cw4nx0sp/c3.css?dl=1" rel="stylesheet" type="text/css">
<!-- Load d3.js and c3.js -->
<script src="https://www.dropbox.com/s/9x22jyfu5qyacpp/d3.v3.min.js?dl=1" charset="utf-8"></script>
<script src="https://www.dropbox.com/s/to7dtcn403l7mza/c3.js?dl=1"></script>
<script>
function getDocumentHeight() {
var body = document.body;
var html = document.documentElement;
return html.clientHeight;
}
function getDocumentWidth() {
var body = document.body;
var html = document.documentElement;
return html.clientWidth;
}
</script>
<style>
.legend {
position: absolute;
width: 80%;
padding-left: 15%;
z-index: 999;
padding-top: 5px;
}
.legend span {
width: ${100 / yValues.size()}%;
display: inline-block;
text-align: center;
cursor: pointer;
color: white;
}
</style>
"""
}
html.body {
"""
<div class="legend"></div>
<div id="chart" style="max-height: 120px; position: relative;"></div>
<script>
// Generate the chart
var chart = c3.generate(${graphData as grails.converters.JSON});
// Resize the chart to the size of the device tile
chart.resize({height:getDocumentHeight(), width:getDocumentWidth()});
// Focus data if specified
${focusJS}
// Update the chart when ${attribute} events are received
function ${attribute}(evt) {
var newValue = ['${flowColumn}'];
newValue.push(evt.value);
var newX = ['x'];
newX.push(evt.unixTime);
chart.flow({
columns: [
newX,
newValue
]
});
}
// Build the custom legend
d3.select('.legend').selectAll('span')
.data(${legendData as grails.converters.JSON})
.enter().append('span')
.attr('data-id', function (id) { return id; })
.html(function (id) { return id; })
.each(function (id) {
d3.select(this).style('background-color', chart.color(id));
})
.on('mouseover', function (id) {
chart.focus(id);
})
.on('mouseout', function (id) {
chart.revert();
})
.on('click', function (id) {
chart.toggle(id);
});
</script>
"""
}
}
}
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")
}

View File

@@ -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()
}
}
}

View File

@@ -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") {

View File

@@ -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) {

View File

@@ -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=<token_key>&oauth_token_secret=<token_secret>
tokenString = resp.data.toString()
// log.debug "collectTokenFromWithings: ${tokenString}"
}
def token = parseResponseString(tokenString)
atomicState.wToken = token
return token
}
// ========================================================
// APP SETTINGS
// ========================================================
def getConsumerKey() { appSettings.consumerKey }
def getConsumerSecret() { appSettings.consumerSecret }
// figure out how to put this in settings
def getUserId() { atomicState.wToken?.userid }
// ========================================================
// HTML rendering
// ========================================================
def renderAction(action, title = "") {
log.debug "renderAction: $action"
renderHTML(title) {
head { "${action}HtmlHead"() }
body { "${action}HtmlBody"() }
}
}
def authorizedHtmlHead() {
log.trace "authorizedHtmlHead"
"""
<style type="text/css">
@font-face {
font-family: 'Swiss 721 W01 Thin';
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Swiss 721 W01 Light';
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
font-weight: normal;
font-style: normal;
}
.container {
/*width: 560px;
padding: 40px;*/
/*background: #eee;*/
text-align: center;
}
img {
vertical-align: middle;
max-width:20%;
}
img:nth-child(2) {
margin: 0 30px;
}
p {
/*font-size: 1.2em;*/
font-family: 'Swiss 721 W01 Thin';
text-align: center;
color: #666666;
padding: 0 10px;
margin-bottom: 0;
}
/*
p:last-child {
margin-top: 0px;
}
*/
span {
font-family: 'Swiss 721 W01 Light';
}
</style>
"""
}
def authorizedHtmlBody() {
"""
<div class="container">
<img src="https://s3.amazonaws.com/smartapp-icons/Partner/withings@2x.png" alt="withings icon" />
<img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
<img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
<p>Your Withings scale is now connected to SmartThings!</p>
<p>Click 'Done' to finish setup.</p>
</div>
"""
}
def notAuthorizedHtmlHead() {
log.trace "notAuthorizedHtmlHead"
authorizedHtmlHead()
}
def notAuthorizedHtmlBody() {
"""
<div class="container">
<p>There was an error connecting to SmartThings!</p>
<p>Click 'Done' to try again.</p>
</div>
"""
}