Update gentle-wake-up.groovy
[smartapps.git] / official / withings.groovy
1 /**
2  *  Copyright 2015 SmartThings
3  *
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:
6  *
7  *      http://www.apache.org/licenses/LICENSE-2.0
8  *
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.
12  *
13  *      Withings Service Manager
14  *
15  *      Author: SmartThings
16  *      Date: 2013-09-26
17  */
18
19 definition(
20     name: "Withings",
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",
27     oauth: true,
28     singleInstance: true
29 ) {
30         appSetting "clientId"
31         appSetting "clientSecret"
32     appSetting "serverUrl"
33 }
34
35 preferences {
36         page(name: "auth", title: "Withings", content:"authPage")
37 }
38
39 mappings {
40         path("/exchange") {
41                 action: [
42                         GET: "exchangeToken"
43                 ]
44         }
45         path("/load") {
46                 action: [
47                         GET: "load"
48                 ]
49         }
50 }
51
52 def authPage() {
53         log.debug "authPage()"
54         dynamicPage(name: "auth", title: "Withings", install:false, uninstall:true) {
55                 section {
56                         paragraph "This version is no longer supported. Please uninstall it."
57                 }
58         }
59 }
60
61 def oauthInitUrl() {
62         def token = getToken()
63         //log.debug "initiateOauth got token: $token"
64
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
68
69         return buildOauthUrlWithToken(token.oauth_token, token.oauth_token_secret)
70 }
71
72 def getToken() {
73         def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}"
74         def params = [
75                 oauth_callback:URLEncoder.encode(callback),
76         ]
77         def requestTokenBaseUrl = "https://oauth.withings.com/account/request_token"
78         def url = buildSignedUrl(requestTokenBaseUrl, params)
79         //log.debug "getToken - url: $url"
80
81         return getJsonFromUrl(url)
82 }
83
84 def buildOauthUrlWithToken(String token, String tokenSecret) {
85         def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}"
86         def params = [
87                 oauth_callback:URLEncoder.encode(callback),
88                 oauth_token:token
89         ]
90         def authorizeBaseUrl = "https://oauth.withings.com/account/authorize"
91
92         return buildSignedUrl(authorizeBaseUrl, params, tokenSecret)
93 }
94
95 /////////////////////////////////////////
96 /////////////////////////////////////////
97 // vvv vvv              OAuth 1.0          vvv vvv //
98 /////////////////////////////////////////
99 /////////////////////////////////////////
100 String buildSignedUrl(String baseUrl, Map urlParams, String tokenSecret="") {
101         def params = [
102                 oauth_consumer_key: smartThingsConsumerKey,
103                 oauth_nonce: nonce(),
104                 oauth_signature_method: "HMAC-SHA1",
105                 oauth_timestamp: timestampInSeconds(),
106                 oauth_version: 1.0
107         ] + urlParams
108         def signatureBaseString = ["GET", baseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&")
109
110         params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), tokenSecret)
111
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('?')
114         return url
115 }
116
117 String nonce() {
118         return UUID.randomUUID().toString().replaceAll("-", "")
119 }
120
121 Integer timestampInSeconds() {
122         return (int)(new Date().time/1000)
123 }
124
125 String toQueryString(Map m) {
126         return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
127 }
128
129 String hmac(String dataString, String consumerSecret, String tokenSecret="") throws java.security.SignatureException {
130         String result
131
132         def key = [consumerSecret, tokenSecret].join('&')
133         
134         // get an hmac_sha1 key from the raw key bytes
135         def signingKey = new javax.crypto.spec.SecretKeySpec(key.getBytes(), "HmacSHA1")
136         
137         // get an hmac_sha1 Mac instance and initialize with the signing key
138         def mac = javax.crypto.Mac.getInstance("HmacSHA1")
139         mac.init(signingKey)
140
141         // compute the hmac on input data bytes
142         byte[] rawHmac = mac.doFinal(dataString.getBytes())
143
144         result = org.apache.commons.codec.binary.Base64.encodeBase64String(rawHmac)
145
146         return result
147 }
148 /////////////////////////////////////////
149 /////////////////////////////////////////
150 // ^^^ ^^^              OAuth 1.0          ^^^ ^^^ //
151 /////////////////////////////////////////
152 /////////////////////////////////////////
153
154 /////////////////////////////////////////
155 /////////////////////////////////////////
156 // vvv vvv               rest              vvv vvv //
157 /////////////////////////////////////////
158 /////////////////////////////////////////
159
160 protected rest(Map params) {
161         new physicalgraph.device.RestAction(params)
162 }
163
164 /////////////////////////////////////////
165 /////////////////////////////////////////
166 // ^^^ ^^^               rest              ^^^ ^^^ //
167 /////////////////////////////////////////
168 /////////////////////////////////////////
169
170 def exchangeToken() {
171         //       oauth_token=abcd
172         //       &userid=123
173
174         def newToken = params.oauth_token
175         def userid = params.userid
176         def tokenSecret = state.oauth_request_token_secret
177
178         def params = [
179                 oauth_token: newToken,
180                 userid: userid
181         ]
182
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"
186
187         def token = getJsonFromUrl(url)
188
189         state.userid = userid
190         state.oauth_token = token.oauth_token
191         state.oauth_token_secret = token.oauth_token_secret
192
193         log.debug "swapped token"
194
195         def location = getServerUrl() + "/api/smartapps/installations/${app.id}/load?access_token=${state.accessToken}"
196         redirect(location:location)
197 }
198
199 def load() {
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"
203         parse(data:json)
204
205         def html = """
206 <!DOCTYPE html>
207 <html>
208 <head>
209 <meta name="viewport" content="width=640">
210 <title>Withings Connection</title>
211 <style type="text/css">
212         @font-face {
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');
219                 font-weight: normal;
220                 font-style: normal;
221         }
222         @font-face {
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');
229                 font-weight: normal;
230                 font-style: normal;
231         }
232         .container {
233                 width: 560px;
234                 padding: 40px;
235                 /*background: #eee;*/
236                 text-align: center;
237         }
238         img {
239                 vertical-align: middle;
240         }
241         img:nth-child(2) {
242                 margin: 0 30px;
243         }
244         p {
245                 font-size: 2.2em;
246                 font-family: 'Swiss 721 W01 Thin';
247                 text-align: center;
248                 color: #666666;
249                 padding: 0 40px;
250                 margin-bottom: 0;
251         }
252 /*
253         p:last-child {
254                 margin-top: 0px;
255         }
256 */
257         span {
258                 font-family: 'Swiss 721 W01 Light';
259         }
260 </style>
261 </head>
262 <body>
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>
269         </div>
270 </body>
271 </html>
272 """
273
274         render contentType: 'text/html', data: html
275 }
276
277 Map getJsonFromUrl(String url) {
278         return [:] // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
279
280         def jsonString
281         httpGet(uri: url) { resp ->
282                 jsonString = resp.data.toString()
283         }
284
285         return getJsonFromText(jsonString)
286 }
287
288 Map getJsonFromText(String jsonString) {
289         def jsonMap = jsonString.split("&").inject([:]) { c, it ->
290                 def parts = it.split('=')
291                 def k = parts[0]
292                 def v = parts[1]
293                 c[k] = v
294                 return c
295         }
296
297         return jsonMap
298 }
299
300 def getMeasurement(Date since=null) {
301         return null // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
302
303         // TODO: add startdate and enddate ... esp. when in response to notify
304         def params = [
305                 action:"getmeas",
306                 oauth_consumer_key:getSmartThingsConsumerKey(),
307                 oauth_nonce:nonce(),
308                 oauth_signature_method:"HMAC-SHA1",
309                 oauth_timestamp:timestampInSeconds(),
310                 oauth_token:state.oauth_token,
311                 oauth_version:1.0,
312                 userid: state.userid
313         ]
314
315         if(since)
316         {
317                 params.startdate = dateToSeconds(since)
318         }
319
320         def requestTokenBaseUrl = "http://wbsapi.withings.net/measure"
321         def signatureBaseString = ["GET", requestTokenBaseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&")
322
323         params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), state.oauth_token_secret)
324
325         return rest(
326                 method: 'GET',
327                 endpoint: "http://wbsapi.withings.net",
328                 path: "/measure",
329                 query: params,
330                 synchronous: true
331         )
332
333 }
334
335 String get(measurementRestAction) {
336         return "" // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
337
338         def httpGetParams = [
339                 uri: measurementRestAction.endpoint,
340                 path: measurementRestAction.path,
341                 query: measurementRestAction.query
342         ]
343
344         String json
345         httpGet(httpGetParams) {resp ->
346                 json = resp.data.text.toString()
347         }
348
349         return json
350 }
351
352 def parse(Map response) {
353         def json = new org.codehaus.groovy.grails.web.json.JSONObject(response.data)
354         parseJson(json)
355 }
356
357 def parseJson(json) {
358         log.debug "parseJson: $json"
359
360         def lastDataPointMillis = (state.lastDataPointMillis ?: 0).toLong()
361         def i = 0
362
363         if(json.status == 0)
364         {
365                 log.debug "parseJson measure group size: ${json.body.measuregrps.size()}"
366
367                 state.errorCount = 0
368
369                 def childDni = getWithingsDevice(json.body.measuregrps).deviceNetworkId
370
371                 def latestMillis = lastDataPointMillis
372                 json.body.measuregrps.sort { it.date }.each { group ->
373
374                         def measurementDateSeconds = group.date
375                         def dataPointMillis = measurementDateSeconds * 1000L
376
377                         if(dataPointMillis > lastDataPointMillis)
378                         {
379                                 group.measures.each { measure ->
380                                         i++
381                                         saveMeasurement(childDni, measure, measurementDateSeconds)
382                                 }
383                         }
384
385                         if(dataPointMillis > latestMillis)
386                         {
387                                 latestMillis = dataPointMillis
388                         }
389
390                 }
391
392                 if(latestMillis > lastDataPointMillis)
393                 {
394                         state.lastDataPointMillis = latestMillis
395                 }
396
397                 def weightData = state.findAll { it.key.startsWith("measure.") }
398
399                 // remove old data
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) }
402         }
403         else
404         {
405                 def errorCount = (state.errorCount ?: 0).toInteger()
406                 state.errorCount = errorCount + 1
407
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)
411         }
412
413         log.debug "Done adding $i measurements"
414         return
415 }
416
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])
420 }
421
422 def saveMeasurement(childDni, measure, measurementDateSeconds) {
423         def dateString = secondsToDate(measurementDateSeconds).format('yyyy-MM-dd')
424
425         def measurement = withingsEvent(measure)
426         sendEvent(state.childDni, measurement + [date:dateString], [dateCreated:secondsToDate(measurementDateSeconds)])
427
428         log.debug "sm: ${measure.type} (${measure.type == 1})"
429
430         if(measure.type == 6)
431         {
432                 sendEvent(state.childDni, [name: "leanRatio", value:(100-measurement.value), date:dateString, isStateChange:true, display:true], [dateCreated:secondsToDate(measurementDateSeconds)])
433         }
434         else if(measure.type == 1)
435         {
436                 state["measure." + dateString] = measurement.value
437         }
438 }
439
440 def eventValue(measure, roundDigits=1) {
441         def value = measure.value * 10.power(measure.unit)
442
443         if(roundDigits != null)
444         {
445                 def significantDigits = 10.power(roundDigits)
446                 value = (value * significantDigits).toInteger() / significantDigits
447         }
448
449         return value
450 }
451
452 def withingsEvent(measure) {
453         def withingsTypes = [
454                 (1):"weight",
455                 (4):"height",
456                 (5):"leanMass",
457                 (6):"fatRatio",
458                 (8):"fatMass",
459                 (11):"pulse"
460         ]
461
462         def value = eventValue(measure, (measure.type == 4 ? null : 1))
463
464         if(measure.type == 1) {
465                 value *= 2.20462
466         } else if(measure.type == 4) {
467                 value *= 39.3701
468         }
469
470         log.debug "m:${measure.type}, v:${value}"
471
472         return [
473                 name: withingsTypes[measure.type],
474                 value: value
475         ]
476 }
477
478 Integer dateToSeconds(Date d) {
479         return d.time / 1000
480 }
481
482 Date secondsToDate(Number seconds) {
483         return new Date(seconds * 1000L)
484 }
485
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
489
490         if(state.childDni)
491         {
492                 return getChildDevice(state.childDni)
493         }
494         else
495         {
496                 def children = getChildDevices()
497                 if(children.size() > 0)
498                 {
499                         return children[0]
500                 }
501                 else
502                 {
503                         // no child yet, create one
504                         def dni = [app.id, UUID.randomUUID().toString()].join('.')
505                         state.childDni = dni
506
507                         def childDeviceType = getBodyAnalyzerChildName()
508
509                         if(measuregrps)
510                         {
511                                 def hasNoHeartRate = measuregrps.find { grp -> grp.measures.find { it.type == 11 } } == null
512                                 if(hasNoHeartRate)
513                                 {
514                                         childDeviceType = getScaleChildName()
515                                 }
516                         }
517
518                         def child = addChildDevice(getChildNamespace(), childDeviceType, dni, null, [label:"Withings"])
519                         state.childId = child.id
520                         return child
521                 }
522         }
523 }
524
525 def installed() {
526         log.debug "Installed with settings: ${settings}"
527         initialize()
528 }
529
530 def updated() {
531         log.debug "Updated with settings: ${settings}"
532         unsubscribe()
533         initialize()
534 }
535
536 def initialize() {
537         // TODO: subscribe to attributes, devices, locations, etc.
538 }
539
540 def poll() {
541         if(shouldPoll())
542         {
543                 return getMeasurement()
544         }
545
546         return null
547 }
548
549 def shouldPoll() {
550         def lastPollString = state.lastPollMillisString
551         def lastPoll = lastPollString?.isNumber() ? lastPollString.toLong() : 0
552         def ONE_HOUR = 60 * 60 * 1000
553
554         def time = new Date().time
555
556         if(time > (lastPoll + ONE_HOUR))
557         {
558                 log.debug "Executing poll b/c (now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})"
559                 state.lastPollMillisString = time
560
561                 return true
562         }
563
564         log.debug "skipping poll b/c !(now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})"
565         return false
566 }
567
568 def refresh() {
569         log.debug "Executing 'refresh'"
570         return getMeasurement()
571 }
572
573 def getChildNamespace() { "smartthings" }
574 def getScaleChildName() { "Wireless Scale" }
575 def getBodyAnalyzerChildName() { "Smart Body Analyzer" }
576
577 def getServerUrl() { appSettings.serverUrl }
578 def getSmartThingsConsumerKey() { appSettings.clientId }
579 def getSmartThingsConsumerSecret() { appSettings.clientSecret }