/** * 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
withings icon connected device icon SmartThings logo

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 }