/**
* Copyright 2015 SmartThings
*
* 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.
*
* Withings Service Manager
*
* Author: SmartThings
* Date: 2013-09-26
*/
definition(
name: "Withings",
namespace: "smartthings",
author: "SmartThings",
description: "Connect your Withings scale to SmartThings.",
category: "Connections",
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/withings.png",
iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png",
oauth: true,
singleInstance: true
) {
appSetting "clientId"
appSetting "clientSecret"
appSetting "serverUrl"
}
preferences {
page(name: "auth", title: "Withings", content:"authPage")
}
mappings {
path("/exchange") {
action: [
GET: "exchangeToken"
]
}
path("/load") {
action: [
GET: "load"
]
}
}
def authPage() {
log.debug "authPage()"
dynamicPage(name: "auth", title: "Withings", install:false, uninstall:true) {
section {
paragraph "This version is no longer supported. Please uninstall it."
}
}
}
def oauthInitUrl() {
def token = getToken()
//log.debug "initiateOauth got token: $token"
// store these for validate after the user takes the oauth journey
state.oauth_request_token = token.oauth_token
state.oauth_request_token_secret = token.oauth_token_secret
return buildOauthUrlWithToken(token.oauth_token, token.oauth_token_secret)
}
def getToken() {
def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}"
def params = [
oauth_callback:URLEncoder.encode(callback),
]
def requestTokenBaseUrl = "https://oauth.withings.com/account/request_token"
def url = buildSignedUrl(requestTokenBaseUrl, params)
//log.debug "getToken - url: $url"
return getJsonFromUrl(url)
}
def buildOauthUrlWithToken(String token, String tokenSecret) {
def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}"
def params = [
oauth_callback:URLEncoder.encode(callback),
oauth_token:token
]
def authorizeBaseUrl = "https://oauth.withings.com/account/authorize"
return buildSignedUrl(authorizeBaseUrl, params, tokenSecret)
}
/////////////////////////////////////////
/////////////////////////////////////////
// vvv vvv OAuth 1.0 vvv vvv //
/////////////////////////////////////////
/////////////////////////////////////////
String buildSignedUrl(String baseUrl, Map urlParams, String tokenSecret="") {
def params = [
oauth_consumer_key: smartThingsConsumerKey,
oauth_nonce: nonce(),
oauth_signature_method: "HMAC-SHA1",
oauth_timestamp: timestampInSeconds(),
oauth_version: 1.0
] + urlParams
def signatureBaseString = ["GET", baseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&")
params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), tokenSecret)
// query string is different from what is used in generating the signature above b/c it includes "oauth_signature"
def url = [baseUrl, toQueryString(params)].join('?')
return url
}
String nonce() {
return UUID.randomUUID().toString().replaceAll("-", "")
}
Integer timestampInSeconds() {
return (int)(new Date().time/1000)
}
String toQueryString(Map m) {
return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
}
String hmac(String dataString, String consumerSecret, String tokenSecret="") throws java.security.SignatureException {
String result
def key = [consumerSecret, tokenSecret].join('&')
// get an hmac_sha1 key from the raw key bytes
def signingKey = new javax.crypto.spec.SecretKeySpec(key.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(dataString.getBytes())
result = org.apache.commons.codec.binary.Base64.encodeBase64String(rawHmac)
return result
}
/////////////////////////////////////////
/////////////////////////////////////////
// ^^^ ^^^ OAuth 1.0 ^^^ ^^^ //
/////////////////////////////////////////
/////////////////////////////////////////
/////////////////////////////////////////
/////////////////////////////////////////
// vvv vvv rest vvv vvv //
/////////////////////////////////////////
/////////////////////////////////////////
protected rest(Map params) {
new physicalgraph.device.RestAction(params)
}
/////////////////////////////////////////
/////////////////////////////////////////
// ^^^ ^^^ rest ^^^ ^^^ //
/////////////////////////////////////////
/////////////////////////////////////////
def exchangeToken() {
// oauth_token=abcd
// &userid=123
def newToken = params.oauth_token
def userid = params.userid
def tokenSecret = state.oauth_request_token_secret
def params = [
oauth_token: newToken,
userid: userid
]
def requestTokenBaseUrl = "https://oauth.withings.com/account/access_token"
def url = buildSignedUrl(requestTokenBaseUrl, params, tokenSecret)
//log.debug "signed url: $url with secret $tokenSecret"
def token = getJsonFromUrl(url)
state.userid = userid
state.oauth_token = token.oauth_token
state.oauth_token_secret = token.oauth_token_secret
log.debug "swapped token"
def location = getServerUrl() + "/api/smartapps/installations/${app.id}/load?access_token=${state.accessToken}"
redirect(location:location)
}
def load() {
def json = get(getMeasurement(new Date() - 30))
// removed logging of actual json payload. Can be put back for debugging
log.debug "swapped, then received json"
parse(data:json)
def html = """
Withings Connection
Your Withings scale is now connected to SmartThings!
Click 'Done' to finish setup.
"""
render contentType: 'text/html', data: html
}
Map getJsonFromUrl(String url) {
return [:] // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
def jsonString
httpGet(uri: url) { resp ->
jsonString = resp.data.toString()
}
return getJsonFromText(jsonString)
}
Map getJsonFromText(String jsonString) {
def jsonMap = jsonString.split("&").inject([:]) { c, it ->
def parts = it.split('=')
def k = parts[0]
def v = parts[1]
c[k] = v
return c
}
return jsonMap
}
def getMeasurement(Date since=null) {
return null // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
// TODO: add startdate and enddate ... esp. when in response to notify
def params = [
action:"getmeas",
oauth_consumer_key:getSmartThingsConsumerKey(),
oauth_nonce:nonce(),
oauth_signature_method:"HMAC-SHA1",
oauth_timestamp:timestampInSeconds(),
oauth_token:state.oauth_token,
oauth_version:1.0,
userid: state.userid
]
if(since)
{
params.startdate = dateToSeconds(since)
}
def requestTokenBaseUrl = "http://wbsapi.withings.net/measure"
def signatureBaseString = ["GET", requestTokenBaseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&")
params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), state.oauth_token_secret)
return rest(
method: 'GET',
endpoint: "http://wbsapi.withings.net",
path: "/measure",
query: params,
synchronous: true
)
}
String get(measurementRestAction) {
return "" // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
def httpGetParams = [
uri: measurementRestAction.endpoint,
path: measurementRestAction.path,
query: measurementRestAction.query
]
String json
httpGet(httpGetParams) {resp ->
json = resp.data.text.toString()
}
return json
}
def parse(Map response) {
def json = new org.codehaus.groovy.grails.web.json.JSONObject(response.data)
parseJson(json)
}
def parseJson(json) {
log.debug "parseJson: $json"
def lastDataPointMillis = (state.lastDataPointMillis ?: 0).toLong()
def i = 0
if(json.status == 0)
{
log.debug "parseJson measure group size: ${json.body.measuregrps.size()}"
state.errorCount = 0
def childDni = getWithingsDevice(json.body.measuregrps).deviceNetworkId
def latestMillis = lastDataPointMillis
json.body.measuregrps.sort { it.date }.each { group ->
def measurementDateSeconds = group.date
def dataPointMillis = measurementDateSeconds * 1000L
if(dataPointMillis > lastDataPointMillis)
{
group.measures.each { measure ->
i++
saveMeasurement(childDni, measure, measurementDateSeconds)
}
}
if(dataPointMillis > latestMillis)
{
latestMillis = dataPointMillis
}
}
if(latestMillis > lastDataPointMillis)
{
state.lastDataPointMillis = latestMillis
}
def weightData = state.findAll { it.key.startsWith("measure.") }
// remove old data
def old = "measure." + (new Date() - 30).format('yyyy-MM-dd')
state.findAll { it.key.startsWith("measure.") && it.key < old }.collect { it.key }.each { state.remove(it) }
}
else
{
def errorCount = (state.errorCount ?: 0).toInteger()
state.errorCount = errorCount + 1
// TODO: If we poll, consider waiting for a couple failures before showing an error
// But if we are only notified, then we need to raise the error right away
measurementError(json.status)
}
log.debug "Done adding $i measurements"
return
}
def measurementError(status) {
log.error "received api response status ${status}"
sendEvent(state.childDni, [name: "connection", value:"Connection error: ${status}", isStateChange:true, displayed:true])
}
def saveMeasurement(childDni, measure, measurementDateSeconds) {
def dateString = secondsToDate(measurementDateSeconds).format('yyyy-MM-dd')
def measurement = withingsEvent(measure)
sendEvent(state.childDni, measurement + [date:dateString], [dateCreated:secondsToDate(measurementDateSeconds)])
log.debug "sm: ${measure.type} (${measure.type == 1})"
if(measure.type == 6)
{
sendEvent(state.childDni, [name: "leanRatio", value:(100-measurement.value), date:dateString, isStateChange:true, display:true], [dateCreated:secondsToDate(measurementDateSeconds)])
}
else if(measure.type == 1)
{
state["measure." + dateString] = measurement.value
}
}
def eventValue(measure, roundDigits=1) {
def value = measure.value * 10.power(measure.unit)
if(roundDigits != null)
{
def significantDigits = 10.power(roundDigits)
value = (value * significantDigits).toInteger() / significantDigits
}
return value
}
def withingsEvent(measure) {
def withingsTypes = [
(1):"weight",
(4):"height",
(5):"leanMass",
(6):"fatRatio",
(8):"fatMass",
(11):"pulse"
]
def value = eventValue(measure, (measure.type == 4 ? null : 1))
if(measure.type == 1) {
value *= 2.20462
} else if(measure.type == 4) {
value *= 39.3701
}
log.debug "m:${measure.type}, v:${value}"
return [
name: withingsTypes[measure.type],
value: value
]
}
Integer dateToSeconds(Date d) {
return d.time / 1000
}
Date secondsToDate(Number seconds) {
return new Date(seconds * 1000L)
}
def getWithingsDevice(measuregrps=null) {
// unfortunately, Withings doesn't seem to give us enough information to know which device(s) they have,
// ... so we have to guess and create a single device
if(state.childDni)
{
return getChildDevice(state.childDni)
}
else
{
def children = getChildDevices()
if(children.size() > 0)
{
return children[0]
}
else
{
// no child yet, create one
def dni = [app.id, UUID.randomUUID().toString()].join('.')
state.childDni = dni
def childDeviceType = getBodyAnalyzerChildName()
if(measuregrps)
{
def hasNoHeartRate = measuregrps.find { grp -> grp.measures.find { it.type == 11 } } == null
if(hasNoHeartRate)
{
childDeviceType = getScaleChildName()
}
}
def child = addChildDevice(getChildNamespace(), childDeviceType, dni, null, [label:"Withings"])
state.childId = child.id
return child
}
}
}
def installed() {
log.debug "Installed with settings: ${settings}"
initialize()
}
def updated() {
log.debug "Updated with settings: ${settings}"
unsubscribe()
initialize()
}
def initialize() {
// TODO: subscribe to attributes, devices, locations, etc.
}
def poll() {
if(shouldPoll())
{
return getMeasurement()
}
return null
}
def shouldPoll() {
def lastPollString = state.lastPollMillisString
def lastPoll = lastPollString?.isNumber() ? lastPollString.toLong() : 0
def ONE_HOUR = 60 * 60 * 1000
def time = new Date().time
if(time > (lastPoll + ONE_HOUR))
{
log.debug "Executing poll b/c (now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})"
state.lastPollMillisString = time
return true
}
log.debug "skipping poll b/c !(now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})"
return false
}
def refresh() {
log.debug "Executing 'refresh'"
return getMeasurement()
}
def getChildNamespace() { "smartthings" }
def getScaleChildName() { "Wireless Scale" }
def getBodyAnalyzerChildName() { "Smart Body Analyzer" }
def getServerUrl() { appSettings.serverUrl }
def getSmartThingsConsumerKey() { appSettings.clientId }
def getSmartThingsConsumerSecret() { appSettings.clientSecret }