2 * Title: Withings Service Manager
3 * Description: Connect Your Withings Devices
11 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
12 * in compliance with the License. You may obtain a copy of the License at:
14 * http://www.apache.org/licenses/LICENSE-2.0
16 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
17 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
18 * for the specific language governing permissions and limitations under the License.
23 name: "Withings Manager",
24 namespace: "smartthings",
25 author: "SmartThings",
26 description: "Connect With Withings",
28 iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/withings.png",
29 iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png",
30 iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png",
33 appSetting "consumerKey"
34 appSetting "consumerSecret"
37 // ========================================================
39 // ========================================================
42 page(name: "authPage")
47 def installOptions = false
48 def description = "Required (tap to set)"
52 // TODO: Check if it's valid
54 description = "Saved (tap to change)"
56 authState = "complete"
58 // Worth differentiating here? (no longer valid vs. non-existent state.externalAuthToken?)
59 description = "Required (tap to set)"
64 dynamicPage(name: "authPage", install: installOptions, uninstall: true) {
68 input(name: "withingsLabel", type: "text", title: "Add a name", description: null, required: true)
71 href url: shortUrl("authenticate"), style: "embedded", required: false, title: "Authenticate with Withings", description: description, state: authState
76 // ========================================================
78 // ========================================================
81 path("/authenticate") {
90 GET: "exchangeTokenFromWithings"
95 [POST: "notificationReceived"]
98 path("/test/:action") {
109 // do not hit userAuthorizationUrl when the page is executed. It will replace oauth_tokens
110 // instead, redirect through here so we know for sure that the user wants to authenticate
111 // plus, the short-lived tokens that are used during authentication are only valid for 2 minutes
112 // so make sure we give the user as much of that 2 minutes as possible to enter their credentials and deal with network latency
113 log.trace "starting Withings authentication flow"
114 redirect location: userAuthorizationUrl()
117 def exchangeTokenFromWithings() {
118 // Withings hits us here during the oAuth flow
119 // log.trace "exchangeTokenFromWithings ${params}"
120 atomicState.userid = params.userid // TODO: restructure this for multi-user access
124 def notificationReceived() {
125 // log.trace "notificationReceived params: ${params}"
127 def notificationParams = [
128 startdate: params.startdate,
129 userid : params.userid,
130 enddate : params.enddate,
133 def measures = wGetMeasures(notificationParams)
134 sendMeasureEvents(measures)
138 // ========================================================
140 // ========================================================
144 log.debug "Installed with settings: ${settings}"
150 log.debug "Updated with settings: ${settings}"
152 // wRevokeAllNotifications()
159 if (!getChild()) { createChild() }
160 app.updateLabel(withingsLabel)
161 wCreateNotification()
165 // ========================================================
167 // ========================================================
170 def children = childDevices
171 children.size() ? children.first() : null
174 private void createChild() {
175 def child = addChildDevice("smartthings", "Withings User", userid(), null, [name: app.label, label: withingsLabel])
176 atomicState.child = [dni: child.deviceNetworkId]
179 // ========================================================
181 // ========================================================
184 if (!atomicState.serverUrl) {
186 atomicState.serverUrl = buildActionUrl("").split(/api\//).first()
188 return atomicState.serverUrl
192 atomicState.accessToken ?: createAccessToken()
195 def shortUrl(path = "", urlParams = [:]) {
196 attachParams("${stBaseUrl()}api/t/${stToken()}/s/${app.id}/${path}", urlParams)
199 def noTokenUrl(path = "", urlParams = [:]) {
200 attachParams("${stBaseUrl()}api/smartapps/installations/${app.id}/${path}", urlParams)
203 def attachParams(url, urlParams = [:]) {
204 [url, toQueryString(urlParams)].findAll().join("?")
207 String toQueryString(Map m = [:]) {
208 // log.trace "toQueryString. URLEncoder will be used on ${m}"
209 return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
212 // ========================================================
214 // ========================================================
216 def unixTime(date = new Date()) {
217 def unixTime = date.time / 1000 as int
218 // log.debug "converting ${date.time} to ${unixTime}"
222 def backfillMeasures() {
223 // log.trace "backfillMeasures"
224 def measureParams = [startdate: unixTime(new Date() - 10)]
225 def measures = wGetMeasures(measureParams)
226 sendMeasureEvents(measures)
229 // this is body measures. // TODO: get activity and others too
230 def wGetMeasures(measureParams = [:]) {
231 def baseUrl = "https://wbsapi.withings.net/measure"
235 startdate : unixTime(new Date() - 5),
236 enddate : unixTime(),
237 oauth_token: oauth_token()
239 def measureData = fetchDataFromWithings(baseUrl, urlParams)
240 // log.debug "measureData: ${measureData}"
241 measureData.body.measuregrps.collect { parseMeasureGroup(it) }.flatten()
248 category:1, // 1 for real measurements, 2 for user objectives.
252 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
253 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.
254 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(%)
266 updatetime:1422750227
272 def sendMeasureEvents(measures) {
273 // log.debug "measures: ${measures}"
275 if (it.name && it.value) {
276 sendEvent(userid(), it)
281 def parseMeasureGroup(measureGroup) {
282 long time = measureGroup.date // must be long. INT_MAX is too small
284 measureGroup.measures.collect { parseMeasure(it) + [date: new Date(time)] }
287 def parseMeasure(measure) {
288 // log.debug "parseMeasure($measure)"
290 name : measureAttribute(measure),
291 value: measureValue(measure)
295 def measureValue(measure) {
296 def value = measure.value * 10.power(measure.unit)
297 if (measure.type == 1) { // Weight (kg)
298 value *= 2.20462262 // kg to lbs
303 String measureAttribute(measure) {
305 switch (measure.type) {
306 case 1: attribute = "weight"; break;
307 case 4: attribute = "height"; break;
308 case 5: attribute = "leanMass"; break;
309 case 6: attribute = "fatRatio"; break;
310 case 8: attribute = "fatMass"; break;
311 case 9: attribute = "diastolicPressure"; break;
312 case 10: attribute = "systolicPressure"; break;
313 case 11: attribute = "heartPulse"; break;
314 case 54: attribute = "SP02"; break;
319 String measureDescription(measure) {
321 switch (measure.type) {
322 case 1: description = "Weight (kg)"; break;
323 case 4: description = "Height (meter)"; break;
324 case 5: description = "Fat Free Mass (kg)"; break;
325 case 6: description = "Fat Ratio (%)"; break;
326 case 8: description = "Fat Mass Weight (kg)"; break;
327 case 9: description = "Diastolic Blood Pressure (mmHg)"; break;
328 case 10: description = "Systolic Blood Pressure (mmHg)"; break;
329 case 11: description = "Heart Pulse (bpm)"; break;
330 case 54: description = "SP02(%)"; break;
335 // ========================================================
336 // WITHINGS NOTIFICATIONS
337 // ========================================================
339 def wNotificationBaseUrl() { "https://wbsapi.withings.net/notify" }
341 def wNotificationCallbackUrl() { shortUrl("n") }
343 def wGetNotification() {
344 def userId = userid()
345 def url = wNotificationBaseUrl()
352 // TODO: keep track of notification expiration
353 def wCreateNotification() {
354 def baseUrl = wNotificationBaseUrl()
356 action : "subscribe",
358 callbackurl: wNotificationCallbackUrl(),
359 oauth_token: oauth_token(),
360 comment : "hmm" // TODO: figure out what to do here. spaces seem to break the request
363 fetchDataFromWithings(baseUrl, urlParams)
366 def wRevokeAllNotifications() {
367 def notifications = wListNotifications()
369 wRevokeNotification([callbackurl: it.callbackurl]) // use the callbackurl Withings has on file
373 def wRevokeNotification(notificationParams = [:]) {
374 def baseUrl = wNotificationBaseUrl()
378 callbackurl: wNotificationCallbackUrl(),
379 oauth_token: oauth_token()
380 ] + notificationParams
382 fetchDataFromWithings(baseUrl, urlParams)
385 def wListNotifications() {
394 callbackurl: "https://graph.api.smartthings.com/api/t/72ab3e57-5839-4cca-9562-dcc818f83bc9/s/537757a0-c4c8-40ea-8cea-aa283915bbd9/n",
402 def baseUrl = wNotificationBaseUrl()
406 callbackurl: wNotificationCallbackUrl(),
407 oauth_token: oauth_token()
410 def notificationData = fetchDataFromWithings(baseUrl, urlParams)
411 notificationData.body.profiles
414 def defaultOauthParams() {
415 defaultParameterKeys().inject([:]) { keyMap, currentKey ->
416 keyMap[currentKey] = "${currentKey}"()
421 // ========================================================
422 // WITHINGS DATA FETCHING
423 // ========================================================
425 def fetchDataFromWithings(baseUrl, urlParams) {
427 // log.debug "fetchDataFromWithings(${baseUrl}, ${urlParams})"
429 def defaultParams = defaultOauthParams()
430 def paramStrings = buildOauthParams(urlParams + defaultParams)
431 // log.debug "paramStrings: $paramStrings"
432 def url = buildOauthUrl(baseUrl, paramStrings, oauth_token_secret())
434 // log.debug "about to make request to ${url}"
435 httpGet(uri: url, headers: ["Content-Type": "application/json"]) { response ->
436 json = new groovy.json.JsonSlurper().parse(response.data)
441 // ========================================================
442 // WITHINGS OAUTH LOGGING
443 // ========================================================
445 def wLogEnabled() { false } // For troubleshooting Oauth flow
447 void wLog(message = "") {
448 if (!wLogEnabled()) { return }
449 def wLogMessage = atomicState.wLogMessage
450 if (wLogMessage.length()) {
453 wLogMessage += message
454 atomicState.wLogMessage = wLogMessage
457 void wLogNew(seedMessage = "") {
458 if (!wLogEnabled()) { return }
459 def olMessage = atomicState.wLogMessage
461 log.debug "purging old wLogMessage: ${olMessage}"
463 atomicState.wLogMessage = seedMessage
466 String wLogMessage() {
467 if (!wLogEnabled()) { return }
468 def wLogMessage = atomicState.wLogMessage
469 atomicState.wLogMessage = ""
473 // ========================================================
474 // WITHINGS OAUTH DESCRIPTION
475 // >>>>>> The user opens the authPage for this SmartApp
476 // STEP 1 get a token to be used in the url the user taps
477 // STEP 2 generate the url to be tapped by the user
478 // >>>>>> The user taps the url and logs in to Withings
479 // STEP 3 generate a token to be used for accessing user data
480 // STEP 4 access user data
481 // ========================================================
483 // ========================================================
484 // WITHINGS OAUTH STEP 1: get an oAuth "request token"
485 // ========================================================
487 def requestTokenUrl() {
488 wLogNew "WITHINGS OAUTH STEP 1: get an oAuth 'request token'"
490 def keys = defaultParameterKeys() + "oauth_callback"
491 def paramStrings = buildOauthParams(keys.sort())
493 buildOauthUrl("https://oauth.withings.com/account/request_token", paramStrings, "")
496 // ========================================================
497 // WITHINGS OAUTH STEP 2: End-user authorization
498 // ========================================================
500 def userAuthorizationUrl() {
502 // get url from Step 1
503 def tokenUrl = requestTokenUrl()
505 // collect token from Withings
506 collectTokenFromWithings(tokenUrl)
508 wLogNew "WITHINGS OAUTH STEP 2: End-user authorization"
510 def keys = defaultParameterKeys() + "oauth_token"
511 def paramStrings = buildOauthParams(keys.sort())
513 buildOauthUrl("https://oauth.withings.com/account/authorize", paramStrings, oauth_token_secret())
516 // ========================================================
517 // WITHINGS OAUTH STEP 3: Generating access token
518 // ========================================================
520 def exchangeTokenUrl() {
521 wLogNew "WITHINGS OAUTH STEP 3: Generating access token"
523 def keys = defaultParameterKeys() + ["oauth_token", "userid"]
524 def paramStrings = buildOauthParams(keys.sort())
526 buildOauthUrl("https://oauth.withings.com/account/access_token", paramStrings, oauth_token_secret())
529 def exchangeToken() {
531 def tokenUrl = exchangeTokenUrl()
532 // log.debug "about to hit ${tokenUrl}"
535 // replace old token with a long-lived token
536 def token = collectTokenFromWithings(tokenUrl)
537 // log.debug "collected token from Withings: ${token}"
538 renderAction("authorized", "Withings Connection")
540 catch (Exception e) {
542 renderAction("notAuthorized", "Withings Connection Failed")
546 // ========================================================
548 // ========================================================
550 def defaultParameterKeys() {
552 "oauth_consumer_key",
554 "oauth_signature_method",
560 def oauth_consumer_key() { consumerKey }
562 def oauth_nonce() { nonce() }
564 def nonce() { UUID.randomUUID().toString().replaceAll("-", "") }
566 def oauth_signature_method() { "HMAC-SHA1" }
568 def oauth_timestamp() { (int) (new Date().time / 1000) }
570 def oauth_version() { 1.0 }
572 def oauth_callback() { shortUrl("x") }
574 def oauth_token() { atomicState.wToken?.oauth_token }
576 def oauth_token_secret() { atomicState.wToken?.oauth_token_secret }
578 def userid() { atomicState.userid }
580 String hmac(String oAuthSignatureBaseString, String oAuthSecret) throws java.security.SignatureException {
581 if (!oAuthSecret.contains("&")) { log.warn "Withings requires \"&\" to be included no matter what" }
582 // get an hmac_sha1 key from the raw key bytes
583 def signingKey = new javax.crypto.spec.SecretKeySpec(oAuthSecret.getBytes(), "HmacSHA1")
584 // get an hmac_sha1 Mac instance and initialize with the signing key
585 def mac = javax.crypto.Mac.getInstance("HmacSHA1")
587 // compute the hmac on input data bytes
588 byte[] rawHmac = mac.doFinal(oAuthSignatureBaseString.getBytes())
589 return org.apache.commons.codec.binary.Base64.encodeBase64String(rawHmac)
592 Map parseResponseString(String responseString) {
593 // log.debug "parseResponseString: ${responseString}"
594 responseString.split("&").inject([:]) { c, it ->
595 def parts = it.split('=')
603 String applyParams(endpoint, oauthParams) { endpoint + "?" + oauthParams.sort().join("&") }
605 String buildSignature(endpoint, oAuthParams, oAuthSecret) {
606 def oAuthSignatureBaseParts = ["GET", endpoint, oAuthParams.join("&")]
607 def oAuthSignatureBaseString = oAuthSignatureBaseParts.collect { URLEncoder.encode(it) }.join("&")
608 wLog " ==> oAuth signature base string : \n${oAuthSignatureBaseString}"
609 wLog " .. applying hmac-sha1 to base string, with secret : ${oAuthSecret} (notice the \"&\")"
610 wLog " .. base64 encode then url-encode the hmac-sha1 hash"
611 String hmacResult = hmac(oAuthSignatureBaseString, oAuthSecret)
612 def signature = URLEncoder.encode(hmacResult)
613 wLog " ==> oauth_signature = ${signature}"
617 List buildOauthParams(List parameterKeys) {
618 wLog " .. adding oAuth parameters : "
620 parameterKeys.each { key ->
621 def value = "${key}"()
622 wLog " ${key} = ${value}"
623 oauthParams << "${key}=${URLEncoder.encode(value.toString())}"
626 wLog " .. sorting all request parameters alphabetically "
630 List buildOauthParams(Map parameters) {
631 wLog " .. adding oAuth parameters : "
633 parameters.each { k, v ->
635 oauthParams << "${k}=${URLEncoder.encode(v.toString())}"
638 wLog " .. sorting all request parameters alphabetically "
642 String buildOauthUrl(String endpoint, List parameterStrings, String oAuthTokenSecret) {
643 wLog "Api endpoint : ${endpoint}"
645 wLog "Signing request :"
646 def oAuthSecret = "${consumerSecret}&${oAuthTokenSecret}"
647 def signature = buildSignature(endpoint, parameterStrings, oAuthSecret)
649 parameterStrings << "oauth_signature=${signature}"
651 def finalUrl = applyParams(endpoint, parameterStrings)
652 wLog "Result: ${finalUrl}"
654 log.debug wLogMessage()
659 def collectTokenFromWithings(tokenUrl) {
660 // get token from Withings using the url generated in Step 1
662 httpGet(uri: tokenUrl) { resp -> // oauth_token=<token_key>&oauth_token_secret=<token_secret>
663 tokenString = resp.data.toString()
664 // log.debug "collectTokenFromWithings: ${tokenString}"
666 def token = parseResponseString(tokenString)
667 atomicState.wToken = token
671 // ========================================================
673 // ========================================================
675 def getConsumerKey() { appSettings.consumerKey }
677 def getConsumerSecret() { appSettings.consumerSecret }
679 // figure out how to put this in settings
680 def getUserId() { atomicState.wToken?.userid }
682 // ========================================================
684 // ========================================================
686 def renderAction(action, title = "") {
687 log.debug "renderAction: $action"
689 head { "${action}HtmlHead"() }
690 body { "${action}HtmlBody"() }
694 def authorizedHtmlHead() {
695 log.trace "authorizedHtmlHead"
697 <style type="text/css">
699 font-family: 'Swiss 721 W01 Thin';
700 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
701 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
702 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
703 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
704 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
709 font-family: 'Swiss 721 W01 Light';
710 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
711 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
712 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
713 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
714 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
721 /*background: #eee;*/
725 vertical-align: middle;
732 /*font-size: 1.2em;*/
733 font-family: 'Swiss 721 W01 Thin';
745 font-family: 'Swiss 721 W01 Light';
751 def authorizedHtmlBody() {
753 <div class="container">
754 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/withings@2x.png" alt="withings icon" />
755 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
756 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
757 <p>Your Withings scale is now connected to SmartThings!</p>
758 <p>Click 'Done' to finish setup.</p>
763 def notAuthorizedHtmlHead() {
764 log.trace "notAuthorizedHtmlHead"
768 def notAuthorizedHtmlBody() {
770 <div class="container">
771 <p>There was an error connecting to SmartThings!</p>
772 <p>Click 'Done' to try again.</p>