Update loft.groovy
[smartapps.git] / official / withings-manager.groovy
1 /**
2  *  Title: Withings Service Manager
3  *      Description: Connect Your Withings Devices
4  *
5  *  Author: steve
6  *  Date: 1/9/15
7  *
8  *
9  *  Copyright 2015 steve
10  *
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:
13  *
14  *      http://www.apache.org/licenses/LICENSE-2.0
15  *
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.
19  *
20  */
21
22 definition(
23         name: "Withings Manager",
24         namespace: "smartthings",
25         author: "SmartThings",
26         description: "Connect With Withings",
27         category: "",
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",
31         oauth: true
32 ) {
33         appSetting "consumerKey"
34         appSetting "consumerSecret"
35 }
36
37 // ========================================================
38 // PAGES
39 // ========================================================
40
41 preferences {
42         page(name: "authPage")
43 }
44
45 def authPage() {
46
47         def installOptions = false
48         def description = "Required (tap to set)"
49         def authState
50
51         if (oauth_token()) {
52                 // TODO: Check if it's valid
53                 if (true) {
54                         description = "Saved (tap to change)"
55                         installOptions = true
56                         authState = "complete"
57                 } else {
58                         // Worth differentiating here? (no longer valid vs. non-existent state.externalAuthToken?)
59                         description = "Required (tap to set)"
60                 }
61         }
62
63
64         dynamicPage(name: "authPage", install: installOptions, uninstall: true) {
65                 section {
66
67                         if (installOptions) {
68                                 input(name: "withingsLabel", type: "text", title: "Add a name", description: null, required: true)
69                         }
70
71                         href url: shortUrl("authenticate"), style: "embedded", required: false, title: "Authenticate with Withings", description: description, state: authState
72                 }
73         }
74 }
75
76 // ========================================================
77 // MAPPINGS
78 // ========================================================
79
80 mappings {
81         path("/authenticate") {
82                 action:
83                 [
84                         GET: "authenticate"
85                 ]
86         }
87         path("/x") {
88                 action:
89                 [
90                         GET: "exchangeTokenFromWithings"
91                 ]
92         }
93         path("/n") {
94                 action:
95                 [POST: "notificationReceived"]
96         }
97
98         path("/test/:action") {
99                 action:
100                 [GET: "test"]
101         }
102 }
103
104 def test() {
105         "${params.action}"()
106 }
107
108 def authenticate() {
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()
115 }
116
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
121         exchangeToken()
122 }
123
124 def notificationReceived() {
125 //      log.trace "notificationReceived params: ${params}"
126
127         def notificationParams = [
128                 startdate: params.startdate,
129                 userid   : params.userid,
130                 enddate  : params.enddate,
131         ]
132
133         def measures = wGetMeasures(notificationParams)
134         sendMeasureEvents(measures)
135         return [status: 0]
136 }
137
138 // ========================================================
139 // HANDLERS
140 // ========================================================
141
142
143 def installed() {
144         log.debug "Installed with settings: ${settings}"
145
146         initialize()
147 }
148
149 def updated() {
150         log.debug "Updated with settings: ${settings}"
151
152 //      wRevokeAllNotifications()
153
154         unsubscribe()
155         initialize()
156 }
157
158 def initialize() {
159         if (!getChild()) { createChild() }
160         app.updateLabel(withingsLabel)
161         wCreateNotification()
162         backfillMeasures()
163 }
164
165 // ========================================================
166 // CHILD DEVICE
167 // ========================================================
168
169 private getChild() {
170         def children = childDevices
171         children.size() ? children.first() : null
172 }
173
174 private void createChild() {
175         def child = addChildDevice("smartthings", "Withings User", userid(), null, [name: app.label, label: withingsLabel])
176         atomicState.child = [dni: child.deviceNetworkId]
177 }
178
179 // ========================================================
180 // URL HELPERS
181 // ========================================================
182
183 def stBaseUrl() {
184         if (!atomicState.serverUrl) {
185                 stToken()
186                 atomicState.serverUrl = buildActionUrl("").split(/api\//).first()
187         }
188         return atomicState.serverUrl
189 }
190
191 def stToken() {
192         atomicState.accessToken ?: createAccessToken()
193 }
194
195 def shortUrl(path = "", urlParams = [:]) {
196         attachParams("${stBaseUrl()}api/t/${stToken()}/s/${app.id}/${path}", urlParams)
197 }
198
199 def noTokenUrl(path = "", urlParams = [:]) {
200         attachParams("${stBaseUrl()}api/smartapps/installations/${app.id}/${path}", urlParams)
201 }
202
203 def attachParams(url, urlParams = [:]) {
204         [url, toQueryString(urlParams)].findAll().join("?")
205 }
206
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("&")
210 }
211
212 // ========================================================
213 // WITHINGS MEASURES
214 // ========================================================
215
216 def unixTime(date = new Date()) {
217         def unixTime = date.time / 1000 as int
218 //      log.debug "converting ${date.time} to ${unixTime}"
219         unixTime
220 }
221
222 def backfillMeasures() {
223 //      log.trace "backfillMeasures"
224         def measureParams = [startdate: unixTime(new Date() - 10)]
225         def measures = wGetMeasures(measureParams)
226         sendMeasureEvents(measures)
227 }
228
229 // this is body measures. // TODO: get activity and others too
230 def wGetMeasures(measureParams = [:]) {
231         def baseUrl = "https://wbsapi.withings.net/measure"
232         def urlParams = [
233                 action     : "getmeas",
234                 userid     : userid(),
235                 startdate  : unixTime(new Date() - 5),
236                 enddate    : unixTime(),
237                 oauth_token: oauth_token()
238         ] + measureParams
239         def measureData = fetchDataFromWithings(baseUrl, urlParams)
240 //      log.debug "measureData: ${measureData}"
241         measureData.body.measuregrps.collect { parseMeasureGroup(it) }.flatten()
242 }
243 /*
244 [
245         body:[
246                 measuregrps:[
247                         [
248                                 category:1, // 1 for real measurements, 2 for user objectives.
249                                 grpid:310040317,
250                                 measures:[
251                                         [
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(%)
255                                         ],
256                                         [
257                                                 unit:-3,
258                                                 value:-1000,
259                                                 type:18
260                                         ]
261                                 ],
262                                 date:1422750210,
263                                 attrib:2
264                         ]
265                 ],
266                 updatetime:1422750227
267         ],
268         status:0
269 ]
270 */
271
272 def sendMeasureEvents(measures) {
273 //      log.debug "measures: ${measures}"
274         measures.each {
275                 if (it.name && it.value) {
276                         sendEvent(userid(), it)
277                 }
278         }
279 }
280
281 def parseMeasureGroup(measureGroup) {
282         long time = measureGroup.date // must be long. INT_MAX is too small
283         time *= 1000
284         measureGroup.measures.collect { parseMeasure(it) + [date: new Date(time)] }
285 }
286
287 def parseMeasure(measure) {
288 //      log.debug "parseMeasure($measure)"
289         [
290                 name : measureAttribute(measure),
291                 value: measureValue(measure)
292         ]
293 }
294
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
299         }
300         value
301 }
302
303 String measureAttribute(measure) {
304         def attribute = ""
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;
315         }
316         return attribute
317 }
318
319 String measureDescription(measure) {
320         def description = ""
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;
331         }
332         return description
333 }
334
335 // ========================================================
336 // WITHINGS NOTIFICATIONS
337 // ========================================================
338
339 def wNotificationBaseUrl() { "https://wbsapi.withings.net/notify" }
340
341 def wNotificationCallbackUrl() { shortUrl("n") }
342
343 def wGetNotification() {
344         def userId = userid()
345         def url = wNotificationBaseUrl()
346         def params = [
347                 action: "subscribe"
348         ]
349
350 }
351
352 // TODO: keep track of notification expiration
353 def wCreateNotification() {
354         def baseUrl = wNotificationBaseUrl()
355         def urlParams = [
356                 action     : "subscribe",
357                 userid     : userid(),
358                 callbackurl: wNotificationCallbackUrl(),
359                 oauth_token: oauth_token(),
360                 comment    : "hmm" // TODO: figure out what to do here. spaces seem to break the request
361         ]
362
363         fetchDataFromWithings(baseUrl, urlParams)
364 }
365
366 def wRevokeAllNotifications() {
367         def notifications = wListNotifications()
368         notifications.each {
369                 wRevokeNotification([callbackurl: it.callbackurl]) // use the callbackurl Withings has on file
370         }
371 }
372
373 def wRevokeNotification(notificationParams = [:]) {
374         def baseUrl = wNotificationBaseUrl()
375         def urlParams = [
376                 action     : "revoke",
377                 userid     : userid(),
378                 callbackurl: wNotificationCallbackUrl(),
379                 oauth_token: oauth_token()
380         ] + notificationParams
381
382         fetchDataFromWithings(baseUrl, urlParams)
383 }
384
385 def wListNotifications() {
386
387         /*
388         {
389                 body: {
390                         profiles: [
391                                 {
392                                         appli: 1,
393                                         expires: 2147483647,
394                                         callbackurl: "https://graph.api.smartthings.com/api/t/72ab3e57-5839-4cca-9562-dcc818f83bc9/s/537757a0-c4c8-40ea-8cea-aa283915bbd9/n",
395                                         comment: "hmm"
396                                 }
397                         ]
398                 },
399                 status: 0
400         }*/
401
402         def baseUrl = wNotificationBaseUrl()
403         def urlParams = [
404                 action     : "list",
405                 userid     : userid(),
406                 callbackurl: wNotificationCallbackUrl(),
407                 oauth_token: oauth_token()
408         ]
409
410         def notificationData = fetchDataFromWithings(baseUrl, urlParams)
411         notificationData.body.profiles
412 }
413
414 def defaultOauthParams() {
415         defaultParameterKeys().inject([:]) { keyMap, currentKey ->
416                 keyMap[currentKey] = "${currentKey}"()
417                 keyMap
418         }
419 }
420
421 // ========================================================
422 // WITHINGS DATA FETCHING
423 // ========================================================
424
425 def fetchDataFromWithings(baseUrl, urlParams) {
426
427 //      log.debug "fetchDataFromWithings(${baseUrl}, ${urlParams})"
428
429         def defaultParams = defaultOauthParams()
430         def paramStrings = buildOauthParams(urlParams + defaultParams)
431 //      log.debug "paramStrings: $paramStrings"
432         def url = buildOauthUrl(baseUrl, paramStrings, oauth_token_secret())
433         def json
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)
437         }
438         return json
439 }
440
441 // ========================================================
442 // WITHINGS OAUTH LOGGING
443 // ========================================================
444
445 def wLogEnabled() { false } // For troubleshooting Oauth flow
446
447 void wLog(message = "") {
448         if (!wLogEnabled()) { return }
449         def wLogMessage = atomicState.wLogMessage
450         if (wLogMessage.length()) {
451                 wLogMessage += "\n|"
452         }
453         wLogMessage += message
454         atomicState.wLogMessage = wLogMessage
455 }
456
457 void wLogNew(seedMessage = "") {
458         if (!wLogEnabled()) { return }
459         def olMessage = atomicState.wLogMessage
460         if (oldMessage) {
461                 log.debug "purging old wLogMessage: ${olMessage}"
462         }
463         atomicState.wLogMessage = seedMessage
464 }
465
466 String wLogMessage() {
467         if (!wLogEnabled()) { return }
468         def wLogMessage = atomicState.wLogMessage
469         atomicState.wLogMessage = ""
470         wLogMessage
471 }
472
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 // ========================================================
482
483 // ========================================================
484 // WITHINGS OAUTH STEP 1: get an oAuth "request token"
485 // ========================================================
486
487 def requestTokenUrl() {
488         wLogNew "WITHINGS OAUTH STEP 1: get an oAuth 'request token'"
489
490         def keys = defaultParameterKeys() + "oauth_callback"
491         def paramStrings = buildOauthParams(keys.sort())
492
493         buildOauthUrl("https://oauth.withings.com/account/request_token", paramStrings, "")
494 }
495
496 // ========================================================
497 // WITHINGS OAUTH STEP 2: End-user authorization
498 // ========================================================
499
500 def userAuthorizationUrl() {
501
502         // get url from Step 1
503         def tokenUrl = requestTokenUrl()
504
505         // collect token from Withings
506         collectTokenFromWithings(tokenUrl)
507
508         wLogNew "WITHINGS OAUTH STEP 2: End-user authorization"
509
510         def keys = defaultParameterKeys() + "oauth_token"
511         def paramStrings = buildOauthParams(keys.sort())
512
513         buildOauthUrl("https://oauth.withings.com/account/authorize", paramStrings, oauth_token_secret())
514 }
515
516 // ========================================================
517 // WITHINGS OAUTH STEP 3: Generating access token
518 // ========================================================
519
520 def exchangeTokenUrl() {
521         wLogNew "WITHINGS OAUTH STEP 3: Generating access token"
522
523         def keys = defaultParameterKeys() + ["oauth_token", "userid"]
524         def paramStrings = buildOauthParams(keys.sort())
525
526         buildOauthUrl("https://oauth.withings.com/account/access_token", paramStrings, oauth_token_secret())
527 }
528
529 def exchangeToken() {
530
531         def tokenUrl = exchangeTokenUrl()
532 //      log.debug "about to hit ${tokenUrl}"
533
534         try {
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")
539         }
540         catch (Exception e) {
541                 log.error e
542                 renderAction("notAuthorized", "Withings Connection Failed")
543         }
544 }
545
546 // ========================================================
547 // OAUTH 1.0
548 // ========================================================
549
550 def defaultParameterKeys() {
551         [
552                 "oauth_consumer_key",
553                 "oauth_nonce",
554                 "oauth_signature_method",
555                 "oauth_timestamp",
556                 "oauth_version"
557         ]
558 }
559
560 def oauth_consumer_key() { consumerKey }
561
562 def oauth_nonce() { nonce() }
563
564 def nonce() { UUID.randomUUID().toString().replaceAll("-", "") }
565
566 def oauth_signature_method() { "HMAC-SHA1" }
567
568 def oauth_timestamp() { (int) (new Date().time / 1000) }
569
570 def oauth_version() { 1.0 }
571
572 def oauth_callback() { shortUrl("x") }
573
574 def oauth_token() { atomicState.wToken?.oauth_token }
575
576 def oauth_token_secret() { atomicState.wToken?.oauth_token_secret }
577
578 def userid() { atomicState.userid }
579
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")
586         mac.init(signingKey)
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)
590 }
591
592 Map parseResponseString(String responseString) {
593 //      log.debug "parseResponseString: ${responseString}"
594         responseString.split("&").inject([:]) { c, it ->
595                 def parts = it.split('=')
596                 def k = parts[0]
597                 def v = parts[1]
598                 c[k] = v
599                 return c
600         }
601 }
602
603 String applyParams(endpoint, oauthParams) { endpoint + "?" + oauthParams.sort().join("&") }
604
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}"
614         return signature
615 }
616
617 List buildOauthParams(List parameterKeys) {
618         wLog "    .. adding oAuth parameters : "
619         def oauthParams = []
620         parameterKeys.each { key ->
621                 def value = "${key}"()
622                 wLog "        ${key} = ${value}"
623                 oauthParams << "${key}=${URLEncoder.encode(value.toString())}"
624         }
625
626         wLog "    .. sorting all request parameters alphabetically "
627         oauthParams.sort()
628 }
629
630 List buildOauthParams(Map parameters) {
631         wLog "    .. adding oAuth parameters : "
632         def oauthParams = []
633         parameters.each { k, v ->
634                 wLog "        ${k} = ${v}"
635                 oauthParams << "${k}=${URLEncoder.encode(v.toString())}"
636         }
637
638         wLog "    .. sorting all request parameters alphabetically "
639         oauthParams.sort()
640 }
641
642 String buildOauthUrl(String endpoint, List parameterStrings, String oAuthTokenSecret) {
643         wLog "Api endpoint : ${endpoint}"
644
645         wLog "Signing request :"
646         def oAuthSecret = "${consumerSecret}&${oAuthTokenSecret}"
647         def signature = buildSignature(endpoint, parameterStrings, oAuthSecret)
648
649         parameterStrings << "oauth_signature=${signature}"
650
651         def finalUrl = applyParams(endpoint, parameterStrings)
652         wLog "Result: ${finalUrl}"
653         if (wLogEnabled()) {
654                 log.debug wLogMessage()
655         }
656         return finalUrl
657 }
658
659 def collectTokenFromWithings(tokenUrl) {
660         // get token from Withings using the url generated in Step 1
661         def tokenString
662         httpGet(uri: tokenUrl) { resp -> // oauth_token=<token_key>&oauth_token_secret=<token_secret>
663                 tokenString = resp.data.toString()
664 //              log.debug "collectTokenFromWithings: ${tokenString}"
665         }
666         def token = parseResponseString(tokenString)
667         atomicState.wToken = token
668         return token
669 }
670
671 // ========================================================
672 // APP SETTINGS
673 // ========================================================
674
675 def getConsumerKey() { appSettings.consumerKey }
676
677 def getConsumerSecret() { appSettings.consumerSecret }
678
679 // figure out how to put this in settings
680 def getUserId() { atomicState.wToken?.userid }
681
682 // ========================================================
683 // HTML rendering
684 // ========================================================
685
686 def renderAction(action, title = "") {
687         log.debug "renderAction: $action"
688         renderHTML(title) {
689                 head { "${action}HtmlHead"() }
690                 body { "${action}HtmlBody"() }
691         }
692 }
693
694 def authorizedHtmlHead() {
695         log.trace "authorizedHtmlHead"
696         """
697                 <style type="text/css">
698                         @font-face {
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');
705                                 font-weight: normal;
706                                 font-style: normal;
707                         }
708                         @font-face {
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');
715                                 font-weight: normal;
716                                 font-style: normal;
717                         }
718                         .container {
719                                 /*width: 560px;
720                                 padding: 40px;*/
721                                 /*background: #eee;*/
722                                 text-align: center;
723                         }
724                         img {
725                                 vertical-align: middle;
726                                                         max-width:20%;
727                         }
728                         img:nth-child(2) {
729                                 margin: 0 30px;
730                         }
731                         p {
732                                 /*font-size: 1.2em;*/
733                                 font-family: 'Swiss 721 W01 Thin';
734                                 text-align: center;
735                                 color: #666666;
736                                 padding: 0 10px;
737                                 margin-bottom: 0;
738                         }
739                 /*
740                         p:last-child {
741                                 margin-top: 0px;
742                         }
743                 */
744                         span {
745                                 font-family: 'Swiss 721 W01 Light';
746                         }
747                 </style>
748                 """
749 }
750
751 def authorizedHtmlBody() {
752         """
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>
759                 </div>
760                 """
761 }
762
763 def notAuthorizedHtmlHead() {
764         log.trace "notAuthorizedHtmlHead"
765         authorizedHtmlHead()
766 }
767
768 def notAuthorizedHtmlBody() {
769         """
770                 <div class="container">
771                         <p>There was an error connecting to SmartThings!</p>
772                         <p>Click 'Done' to try again.</p>
773                 </div>
774                 """
775 }