2 * Copyright 2015 SmartThings
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 * in compliance with the License. You may obtain a copy of the License at:
7 * http://www.apache.org/licenses/LICENSE-2.0
9 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
10 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11 * for the specific language governing permissions and limitations under the License.
13 * Withings Service Manager
21 namespace: "smartthings",
22 author: "SmartThings",
23 description: "Connect your Withings scale to SmartThings.",
24 category: "Connections",
25 iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/withings.png",
26 iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png",
31 appSetting "clientSecret"
32 appSetting "serverUrl"
36 page(name: "auth", title: "Withings", content:"authPage")
53 log.debug "authPage()"
54 dynamicPage(name: "auth", title: "Withings", install:false, uninstall:true) {
56 paragraph "This version is no longer supported. Please uninstall it."
62 def token = getToken()
63 //log.debug "initiateOauth got token: $token"
65 // store these for validate after the user takes the oauth journey
66 state.oauth_request_token = token.oauth_token
67 state.oauth_request_token_secret = token.oauth_token_secret
69 return buildOauthUrlWithToken(token.oauth_token, token.oauth_token_secret)
73 def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}"
75 oauth_callback:URLEncoder.encode(callback),
77 def requestTokenBaseUrl = "https://oauth.withings.com/account/request_token"
78 def url = buildSignedUrl(requestTokenBaseUrl, params)
79 //log.debug "getToken - url: $url"
81 return getJsonFromUrl(url)
84 def buildOauthUrlWithToken(String token, String tokenSecret) {
85 def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}"
87 oauth_callback:URLEncoder.encode(callback),
90 def authorizeBaseUrl = "https://oauth.withings.com/account/authorize"
92 return buildSignedUrl(authorizeBaseUrl, params, tokenSecret)
95 /////////////////////////////////////////
96 /////////////////////////////////////////
97 // vvv vvv OAuth 1.0 vvv vvv //
98 /////////////////////////////////////////
99 /////////////////////////////////////////
100 String buildSignedUrl(String baseUrl, Map urlParams, String tokenSecret="") {
102 oauth_consumer_key: smartThingsConsumerKey,
103 oauth_nonce: nonce(),
104 oauth_signature_method: "HMAC-SHA1",
105 oauth_timestamp: timestampInSeconds(),
108 def signatureBaseString = ["GET", baseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&")
110 params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), tokenSecret)
112 // query string is different from what is used in generating the signature above b/c it includes "oauth_signature"
113 def url = [baseUrl, toQueryString(params)].join('?')
118 return UUID.randomUUID().toString().replaceAll("-", "")
121 Integer timestampInSeconds() {
122 return (int)(new Date().time/1000)
125 String toQueryString(Map m) {
126 return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
129 String hmac(String dataString, String consumerSecret, String tokenSecret="") throws java.security.SignatureException {
132 def key = [consumerSecret, tokenSecret].join('&')
134 // get an hmac_sha1 key from the raw key bytes
135 def signingKey = new javax.crypto.spec.SecretKeySpec(key.getBytes(), "HmacSHA1")
137 // get an hmac_sha1 Mac instance and initialize with the signing key
138 def mac = javax.crypto.Mac.getInstance("HmacSHA1")
141 // compute the hmac on input data bytes
142 byte[] rawHmac = mac.doFinal(dataString.getBytes())
144 result = org.apache.commons.codec.binary.Base64.encodeBase64String(rawHmac)
148 /////////////////////////////////////////
149 /////////////////////////////////////////
150 // ^^^ ^^^ OAuth 1.0 ^^^ ^^^ //
151 /////////////////////////////////////////
152 /////////////////////////////////////////
154 /////////////////////////////////////////
155 /////////////////////////////////////////
156 // vvv vvv rest vvv vvv //
157 /////////////////////////////////////////
158 /////////////////////////////////////////
160 protected rest(Map params) {
161 new physicalgraph.device.RestAction(params)
164 /////////////////////////////////////////
165 /////////////////////////////////////////
166 // ^^^ ^^^ rest ^^^ ^^^ //
167 /////////////////////////////////////////
168 /////////////////////////////////////////
170 def exchangeToken() {
174 def newToken = params.oauth_token
175 def userid = params.userid
176 def tokenSecret = state.oauth_request_token_secret
179 oauth_token: newToken,
183 def requestTokenBaseUrl = "https://oauth.withings.com/account/access_token"
184 def url = buildSignedUrl(requestTokenBaseUrl, params, tokenSecret)
185 //log.debug "signed url: $url with secret $tokenSecret"
187 def token = getJsonFromUrl(url)
189 state.userid = userid
190 state.oauth_token = token.oauth_token
191 state.oauth_token_secret = token.oauth_token_secret
193 log.debug "swapped token"
195 def location = getServerUrl() + "/api/smartapps/installations/${app.id}/load?access_token=${state.accessToken}"
196 redirect(location:location)
200 def json = get(getMeasurement(new Date() - 30))
201 // removed logging of actual json payload. Can be put back for debugging
202 log.debug "swapped, then received json"
209 <meta name="viewport" content="width=640">
210 <title>Withings Connection</title>
211 <style type="text/css">
213 font-family: 'Swiss 721 W01 Thin';
214 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
215 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
216 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
217 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
218 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
223 font-family: 'Swiss 721 W01 Light';
224 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
225 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
226 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
227 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
228 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
235 /*background: #eee;*/
239 vertical-align: middle;
246 font-family: 'Swiss 721 W01 Thin';
258 font-family: 'Swiss 721 W01 Light';
263 <div class="container">
264 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/withings@2x.png" alt="withings icon" />
265 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
266 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
267 <p>Your Withings scale is now connected to SmartThings!</p>
268 <p>Click 'Done' to finish setup.</p>
274 render contentType: 'text/html', data: html
277 Map getJsonFromUrl(String url) {
278 return [:] // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
281 httpGet(uri: url) { resp ->
282 jsonString = resp.data.toString()
285 return getJsonFromText(jsonString)
288 Map getJsonFromText(String jsonString) {
289 def jsonMap = jsonString.split("&").inject([:]) { c, it ->
290 def parts = it.split('=')
300 def getMeasurement(Date since=null) {
301 return null // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
303 // TODO: add startdate and enddate ... esp. when in response to notify
306 oauth_consumer_key:getSmartThingsConsumerKey(),
308 oauth_signature_method:"HMAC-SHA1",
309 oauth_timestamp:timestampInSeconds(),
310 oauth_token:state.oauth_token,
317 params.startdate = dateToSeconds(since)
320 def requestTokenBaseUrl = "http://wbsapi.withings.net/measure"
321 def signatureBaseString = ["GET", requestTokenBaseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&")
323 params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), state.oauth_token_secret)
327 endpoint: "http://wbsapi.withings.net",
335 String get(measurementRestAction) {
336 return "" // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
338 def httpGetParams = [
339 uri: measurementRestAction.endpoint,
340 path: measurementRestAction.path,
341 query: measurementRestAction.query
345 httpGet(httpGetParams) {resp ->
346 json = resp.data.text.toString()
352 def parse(Map response) {
353 def json = new org.codehaus.groovy.grails.web.json.JSONObject(response.data)
357 def parseJson(json) {
358 log.debug "parseJson: $json"
360 def lastDataPointMillis = (state.lastDataPointMillis ?: 0).toLong()
365 log.debug "parseJson measure group size: ${json.body.measuregrps.size()}"
369 def childDni = getWithingsDevice(json.body.measuregrps).deviceNetworkId
371 def latestMillis = lastDataPointMillis
372 json.body.measuregrps.sort { it.date }.each { group ->
374 def measurementDateSeconds = group.date
375 def dataPointMillis = measurementDateSeconds * 1000L
377 if(dataPointMillis > lastDataPointMillis)
379 group.measures.each { measure ->
381 saveMeasurement(childDni, measure, measurementDateSeconds)
385 if(dataPointMillis > latestMillis)
387 latestMillis = dataPointMillis
392 if(latestMillis > lastDataPointMillis)
394 state.lastDataPointMillis = latestMillis
397 def weightData = state.findAll { it.key.startsWith("measure.") }
400 def old = "measure." + (new Date() - 30).format('yyyy-MM-dd')
401 state.findAll { it.key.startsWith("measure.") && it.key < old }.collect { it.key }.each { state.remove(it) }
405 def errorCount = (state.errorCount ?: 0).toInteger()
406 state.errorCount = errorCount + 1
408 // TODO: If we poll, consider waiting for a couple failures before showing an error
409 // But if we are only notified, then we need to raise the error right away
410 measurementError(json.status)
413 log.debug "Done adding $i measurements"
417 def measurementError(status) {
418 log.error "received api response status ${status}"
419 sendEvent(state.childDni, [name: "connection", value:"Connection error: ${status}", isStateChange:true, displayed:true])
422 def saveMeasurement(childDni, measure, measurementDateSeconds) {
423 def dateString = secondsToDate(measurementDateSeconds).format('yyyy-MM-dd')
425 def measurement = withingsEvent(measure)
426 sendEvent(state.childDni, measurement + [date:dateString], [dateCreated:secondsToDate(measurementDateSeconds)])
428 log.debug "sm: ${measure.type} (${measure.type == 1})"
430 if(measure.type == 6)
432 sendEvent(state.childDni, [name: "leanRatio", value:(100-measurement.value), date:dateString, isStateChange:true, display:true], [dateCreated:secondsToDate(measurementDateSeconds)])
434 else if(measure.type == 1)
436 state["measure." + dateString] = measurement.value
440 def eventValue(measure, roundDigits=1) {
441 def value = measure.value * 10.power(measure.unit)
443 if(roundDigits != null)
445 def significantDigits = 10.power(roundDigits)
446 value = (value * significantDigits).toInteger() / significantDigits
452 def withingsEvent(measure) {
453 def withingsTypes = [
462 def value = eventValue(measure, (measure.type == 4 ? null : 1))
464 if(measure.type == 1) {
466 } else if(measure.type == 4) {
470 log.debug "m:${measure.type}, v:${value}"
473 name: withingsTypes[measure.type],
478 Integer dateToSeconds(Date d) {
482 Date secondsToDate(Number seconds) {
483 return new Date(seconds * 1000L)
486 def getWithingsDevice(measuregrps=null) {
487 // unfortunately, Withings doesn't seem to give us enough information to know which device(s) they have,
488 // ... so we have to guess and create a single device
492 return getChildDevice(state.childDni)
496 def children = getChildDevices()
497 if(children.size() > 0)
503 // no child yet, create one
504 def dni = [app.id, UUID.randomUUID().toString()].join('.')
507 def childDeviceType = getBodyAnalyzerChildName()
511 def hasNoHeartRate = measuregrps.find { grp -> grp.measures.find { it.type == 11 } } == null
514 childDeviceType = getScaleChildName()
518 def child = addChildDevice(getChildNamespace(), childDeviceType, dni, null, [label:"Withings"])
519 state.childId = child.id
526 log.debug "Installed with settings: ${settings}"
531 log.debug "Updated with settings: ${settings}"
537 // TODO: subscribe to attributes, devices, locations, etc.
543 return getMeasurement()
550 def lastPollString = state.lastPollMillisString
551 def lastPoll = lastPollString?.isNumber() ? lastPollString.toLong() : 0
552 def ONE_HOUR = 60 * 60 * 1000
554 def time = new Date().time
556 if(time > (lastPoll + ONE_HOUR))
558 log.debug "Executing poll b/c (now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})"
559 state.lastPollMillisString = time
564 log.debug "skipping poll b/c !(now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})"
569 log.debug "Executing 'refresh'"
570 return getMeasurement()
573 def getChildNamespace() { "smartthings" }
574 def getScaleChildName() { "Wireless Scale" }
575 def getBodyAnalyzerChildName() { "Smart Body Analyzer" }
577 def getServerUrl() { appSettings.serverUrl }
578 def getSmartThingsConsumerKey() { appSettings.clientId }
579 def getSmartThingsConsumerSecret() { appSettings.clientSecret }