diff --git a/smartapps/smartthings/withings-manager.src/withings-manager.groovy b/smartapps/smartthings/withings-manager.src/withings-manager.groovy new file mode 100644 index 0000000..1e4d8ba --- /dev/null +++ b/smartapps/smartthings/withings-manager.src/withings-manager.groovy @@ -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=&oauth_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" + """ + + """ +} + +def authorizedHtmlBody() { + """ +
+ withings icon + connected device icon + SmartThings logo +

Your Withings scale is now connected to SmartThings!

+

Click 'Done' to finish setup.

+
+ """ +} + +def notAuthorizedHtmlHead() { + log.trace "notAuthorizedHtmlHead" + authorizedHtmlHead() +} + +def notAuthorizedHtmlBody() { + """ +
+

There was an error connecting to SmartThings!

+

Click 'Done' to try again.

+
+ """ +}