Update thermostat-mode-director.groovy
[smartapps.git] / official / netatmo-connect.groovy
1 /**
2  * Netatmo Connect
3  */
4 import java.text.DecimalFormat
5 import groovy.json.JsonSlurper
6
7 private getApiUrl()                     { "https://api.netatmo.com" }
8 private getVendorAuthPath()     { "${apiUrl}/oauth2/authorize?" }
9 private getVendorTokenPath(){ "${apiUrl}/oauth2/token" }
10 private getVendorIcon()         { "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png" }
11 private getClientId()           { appSettings.clientId }
12 private getClientSecret()       { appSettings.clientSecret }
13 private getServerUrl()          { appSettings.serverUrl }
14 private getShardUrl()           { return getApiServerUrl() }
15 private getCallbackUrl()        { "${serverUrl}/oauth/callback" }
16 private getBuildRedirectUrl() { "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" }
17
18 definition(
19         name: "Netatmo (Connect)",
20         namespace: "dianoga",
21         author: "Brian Steere",
22         description: "Integrate your Netatmo devices with SmartThings",
23         category: "SmartThings Labs",
24         iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1.png",
25         iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png",
26         oauth: true,
27         singleInstance: true
28 ){
29         appSetting "clientId"
30         appSetting "clientSecret"
31         appSetting "serverUrl"
32 }
33
34 preferences {
35         page(name: "Credentials", title: "Fetch OAuth2 Credentials", content: "authPage", install: false)
36         page(name: "listDevices", title: "Netatmo Devices", content: "listDevices", install: false)
37 }
38
39 mappings {
40         path("/oauth/initialize") {action: [GET: "oauthInitUrl"]}
41         path("/oauth/callback") {action: [GET: "callback"]}
42 }
43
44 def authPage() {
45         // log.debug "running authPage()"
46
47         def description
48         def uninstallAllowed = false
49         def oauthTokenProvided = false
50
51         // If an access token doesn't exist, create one
52         if (!atomicState.accessToken) {
53                 atomicState.accessToken = createAccessToken()
54         log.debug "Created access token"
55         }
56
57         if (canInstallLabs()) {
58
59                 def redirectUrl = getBuildRedirectUrl()
60                 // log.debug "Redirect url = ${redirectUrl}"
61
62                 if (atomicState.authToken) {
63                         description = "Tap 'Next' to select devices"
64                         uninstallAllowed = true
65                         oauthTokenProvided = true
66                 } else {
67                         description = "Tap to enter credentials"
68                 }
69
70                 if (!oauthTokenProvided) {
71                         log.debug "Showing the login page"
72                         return dynamicPage(name: "Credentials", title: "Authorize Connection", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) {
73                                 section() {
74                                         paragraph "Tap below to login to Netatmo and authorize SmartThings access"
75                                         href url:redirectUrl, style:"embedded", required:false, title:"Connect to Netatmo", description:description
76                                 }
77                         }
78                 } else {
79                         log.debug "Showing the devices page"
80                         return dynamicPage(name: "Credentials", title: "Connected", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) {
81                                 section() {
82                                         input(name:"Devices", style:"embedded", required:false, title:"Netatmo is connected to SmartThings", description:description) 
83                                 }
84                         }
85                 }
86         } else {
87                 def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub"."""
88                 return dynamicPage(name:"Credentials", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) {
89                         section {
90                                 paragraph "$upgradeNeeded"
91                         }
92                 }
93
94         }
95 }
96
97 def oauthInitUrl() {
98         // log.debug "runing oauthInitUrl()"
99
100         atomicState.oauthInitState = UUID.randomUUID().toString()
101
102         def oauthParams = [
103                 response_type: "code",
104                 client_id: getClientId(),
105                 client_secret: getClientSecret(),
106                 state: atomicState.oauthInitState,
107                 redirect_uri: getCallbackUrl(),
108                 scope: "read_station"
109         ]
110
111         // log.debug "REDIRECT URL: ${getVendorAuthPath() + toQueryString(oauthParams)}"
112
113         redirect (location: getVendorAuthPath() + toQueryString(oauthParams))
114 }
115
116 def callback() {
117         // log.debug "running callback()"
118
119         def code = params.code
120         def oauthState = params.state
121
122         if (oauthState == atomicState.oauthInitState) {
123
124                 def tokenParams = [
125                 grant_type: "authorization_code",
126                         client_secret: getClientSecret(),
127                         client_id : getClientId(),
128                         code: code,
129                         scope: "read_station",
130             redirect_uri: getCallbackUrl()
131                 ]
132
133                 // log.debug "TOKEN URL: ${getVendorTokenPath() + toQueryString(tokenParams)}"
134
135                 def tokenUrl = getVendorTokenPath()
136                 def requestTokenParams = [
137                         uri: tokenUrl,
138                         requestContentType: 'application/x-www-form-urlencoded',
139                         body: tokenParams
140                 ]
141     
142                 // log.debug "PARAMS: ${requestTokenParams}"
143
144                 try {
145             httpPost(requestTokenParams) { resp ->
146                 //log.debug "Data: ${resp.data}"
147                 atomicState.refreshToken = resp.data.refresh_token
148                 atomicState.authToken = resp.data.access_token
149                 // resp.data.expires_in is in milliseconds so we need to convert it to seconds
150                 atomicState.tokenExpires = now() + (resp.data.expires_in * 1000)
151             }
152         } catch (e) {
153                               log.debug "callback() failed: $e"
154         }
155
156                 // If we successfully got an authToken run sucess(), else fail()
157                 if (atomicState.authToken) {
158                         success()
159                 } else {
160                         fail()
161                 }
162
163         } else {
164                 log.error "callback() failed oauthState != atomicState.oauthInitState"
165         }
166 }
167
168 def success() {
169         log.debug "OAuth flow succeeded"
170         def message = """
171         <p>Success!</p>
172         <p>Tap 'Done' to continue</p>
173         """
174         connectionStatus(message)
175 }
176
177 def fail() {
178         log.debug "OAuth flow failed"
179     atomicState.authToken = null
180         def message = """
181         <p>Error</p>
182         <p>Tap 'Done' to return</p>
183         """
184         connectionStatus(message)
185 }
186
187 def connectionStatus(message, redirectUrl = null) {
188         def redirectHtml = ""
189         if (redirectUrl) {
190                 redirectHtml = """
191                         <meta http-equiv="refresh" content="3; url=${redirectUrl}" />
192                 """
193         }
194         def html = """
195                 <!DOCTYPE html>
196                 <html>
197                 <head>
198                 <meta name="viewport" content="width=device-width, initial-scale=1">
199                 <title>Netatmo Connection</title>
200                 <style type="text/css">
201                         * { box-sizing: border-box; }
202                         @font-face {
203                                 font-family: 'Swiss 721 W01 Thin';
204                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
205                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
206                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
207                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
208                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
209                                 font-weight: normal;
210                                 font-style: normal;
211                         }
212                         @font-face {
213                                 font-family: 'Swiss 721 W01 Light';
214                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
215                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
216                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
217                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
218                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
219                                 font-weight: normal;
220                                 font-style: normal;
221                         }
222                         .container {
223                                 width: 100%;
224                                 padding: 40px;
225                                 text-align: center;
226                         }
227                         img {
228                                 vertical-align: middle;
229                         }
230                         img:nth-child(2) {
231                                 margin: 0 30px;
232                         }
233                         p {
234                                 font-size: 2.2em;
235                                 font-family: 'Swiss 721 W01 Thin';
236                                 text-align: center;
237                                 color: #666666;
238                                 margin-bottom: 0;
239                         }
240                         span {
241                                 font-family: 'Swiss 721 W01 Light';
242                         }
243                 </style>
244                 </head>
245                 <body>
246                         <div class="container">
247                                 <img src=""" + getVendorIcon() + """ alt="Vendor icon" />
248                                 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
249                                 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
250                                 ${message}
251                         </div>
252         </body>
253         </html>
254         """
255         render contentType: 'text/html', data: html
256 }
257
258 def refreshToken() {
259         // Check if atomicState has a refresh token
260         if (atomicState.refreshToken) {
261         log.debug "running refreshToken()"
262
263         def oauthParams = [
264             grant_type: "refresh_token",
265             refresh_token: atomicState.refreshToken,
266             client_secret: getClientSecret(),
267             client_id: getClientId(),
268         ]
269
270         def tokenUrl = getVendorTokenPath()
271         
272         def requestOauthParams = [
273             uri: tokenUrl,
274             requestContentType: 'application/x-www-form-urlencoded',
275             body: oauthParams
276         ]
277         
278         // log.debug "PARAMS: ${requestOauthParams}"
279
280         try {
281             httpPost(requestOauthParams) { resp ->
282                 //log.debug "Data: ${resp.data}"
283                 atomicState.refreshToken = resp.data.refresh_token
284                 atomicState.authToken = resp.data.access_token
285                 // resp.data.expires_in is in milliseconds so we need to convert it to seconds
286                 atomicState.tokenExpires = now() + (resp.data.expires_in * 1000)
287                 return true
288             }
289         } catch (e) {
290             log.debug "refreshToken() failed: $e"
291         }
292
293         // If we didn't get an authToken
294         if (!atomicState.authToken) {
295             return false
296         }
297         } else {
298         return false
299     }
300 }
301
302 String toQueryString(Map m) {
303         return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
304 }
305
306 def installed() {
307         log.debug "Installed with settings: ${settings}"
308         initialize()
309 }
310
311 def updated() {
312         log.debug "Updated with settings: ${settings}"
313         unsubscribe()
314         unschedule()
315         initialize()
316 }
317
318 def initialize() {
319         log.debug "Initialized with settings: ${settings}"
320     
321         // Pull the latest device info into state
322         getDeviceList()
323
324         settings.devices.each {
325                 def deviceId = it
326                 def detail = state?.deviceDetail[deviceId]
327
328                 try {
329                         switch(detail?.type) {
330                                 case 'NAMain':
331                                         log.debug "Base station"
332                                         createChildDevice("Netatmo Basestation", deviceId, "${detail.type}.${deviceId}", detail.module_name)
333                                         break
334                                 case 'NAModule1':
335                                         log.debug "Outdoor module"
336                                         createChildDevice("Netatmo Outdoor Module", deviceId, "${detail.type}.${deviceId}", detail.module_name)
337                                         break
338                                 case 'NAModule3':
339                                         log.debug "Rain Gauge"
340                                         createChildDevice("Netatmo Rain", deviceId, "${detail.type}.${deviceId}", detail.module_name)
341                                         break
342                                 case 'NAModule4':
343                                         log.debug "Additional module"
344                                         createChildDevice("Netatmo Additional Module", deviceId, "${detail.type}.${deviceId}", detail.module_name)
345                                         break
346                         }
347                 } catch (Exception e) {
348                         log.error "Error creating device: ${e}"
349                 }
350         }
351
352         // Cleanup any other devices that need to go away
353         def delete = getChildDevices().findAll { !settings.devices.contains(it.deviceNetworkId) }
354         log.debug "Delete: $delete"
355         delete.each { deleteChildDevice(it.deviceNetworkId) }
356     
357         // Run initial poll and schedule future polls
358         poll()
359         runEvery5Minutes("poll")
360 }
361
362 def uninstalled() {
363         log.debug "Uninstalling"
364         removeChildDevices(getChildDevices())
365 }
366
367 def getDeviceList() {
368         if (atomicState.authToken) {
369     
370         log.debug "Getting stations data"
371
372         def deviceList = [:]
373         state.deviceDetail = [:]
374         state.deviceState = [:]
375
376         apiGet("/api/getstationsdata") { resp ->
377             resp.data.body.devices.each { value ->
378                 def key = value._id
379                 deviceList[key] = "${value.station_name}: ${value.module_name}"
380                 state.deviceDetail[key] = value
381                 state.deviceState[key] = value.dashboard_data
382                 value.modules.each { value2 ->            
383                     def key2 = value2._id
384                     deviceList[key2] = "${value.station_name}: ${value2.module_name}"
385                     state.deviceDetail[key2] = value2
386                     state.deviceState[key2] = value2.dashboard_data            
387                 }
388             }
389         }
390         
391         return deviceList.sort() { it.value.toLowerCase() }
392         
393         } else {
394         return null
395   }
396 }
397
398 private removeChildDevices(delete) {
399         log.debug "Removing ${delete.size()} devices"
400         delete.each {
401                 deleteChildDevice(it.deviceNetworkId)
402         }
403 }
404
405 def createChildDevice(deviceFile, dni, name, label) {
406         try {
407                 def existingDevice = getChildDevice(dni)
408                 if(!existingDevice) {
409                         log.debug "Creating child"
410                         def childDevice = addChildDevice("dianoga", deviceFile, dni, null, [name: name, label: label, completedSetup: true])
411                 } else {
412                         log.debug "Device $dni already exists"
413                 }
414         } catch (e) {
415                 log.error "Error creating device: ${e}"
416         }
417 }
418
419 def listDevices() {
420         log.debug "Listing devices"
421
422         def devices = getDeviceList()
423
424         dynamicPage(name: "listDevices", title: "Choose Devices", install: true) {
425                 section("Devices") {
426                         input "devices", "enum", title: "Select Devices", required: false, multiple: true, options: devices
427                 }
428
429         section("Preferences") {
430                 input "rainUnits", "enum", title: "Rain Units", description: "Millimeters (mm) or Inches (in)", required: true, options: [mm:'Millimeters', in:'Inches']
431         }
432         }
433 }
434
435 def apiGet(String path, Map query, Closure callback) {
436         log.debug "running apiGet()"
437     
438     // If the current time is over the expiration time, request a new token
439         if(now() >= atomicState.tokenExpires) {
440         atomicState.authToken = null
441                 refreshToken()
442         }
443
444         def queryParam = [
445         access_token: atomicState.authToken
446     ]
447     
448         def apiGetParams = [
449                 uri: getApiUrl(),
450                 path: path,
451                 query: queryParam
452         ]
453     
454         // log.debug "apiGet(): $apiGetParams"
455
456         try {
457                 httpGet(apiGetParams) { resp ->
458                         callback.call(resp)
459                 }
460         } catch (e) {
461                 log.debug "apiGet() failed: $e"
462         // Netatmo API has rate limits so a failure here doesn't necessarily mean our token has expired, but we will check anyways
463         if(now() >= atomicState.tokenExpires) {
464                 atomicState.authToken = null
465                         refreshToken()
466                 }
467         }
468 }
469
470 def apiGet(String path, Closure callback) {
471         apiGet(path, [:], callback);
472 }
473
474 def poll() {
475         log.debug "Polling..."
476     
477         getDeviceList()
478     
479         def children = getChildDevices()
480     //log.debug "State: ${state.deviceState}"
481
482         settings.devices.each { deviceId ->
483                 def detail = state?.deviceDetail[deviceId]
484                 def data = state?.deviceState[deviceId]
485                 def child = children?.find { it.deviceNetworkId == deviceId }
486
487                 log.debug "Update: $child";
488                 switch(detail?.type) {
489                         case 'NAMain':
490                                 log.debug "Updating NAMain $data"
491                                 child?.sendEvent(name: 'temperature', value: cToPref(data['Temperature']) as float, unit: getTemperatureScale())
492                                 child?.sendEvent(name: 'carbonDioxide', value: data['CO2'])
493                                 child?.sendEvent(name: 'humidity', value: data['Humidity'])
494                                 child?.sendEvent(name: 'pressure', value: data['Pressure'])
495                                 child?.sendEvent(name: 'noise', value: data['Noise'])
496                                 break;
497                         case 'NAModule1':
498                                 log.debug "Updating NAModule1 $data"
499                                 child?.sendEvent(name: 'temperature', value: cToPref(data['Temperature']) as float, unit: getTemperatureScale())
500                                 child?.sendEvent(name: 'humidity', value: data['Humidity'])
501                                 break;
502                         case 'NAModule3':
503                                 log.debug "Updating NAModule3 $data"
504                                 child?.sendEvent(name: 'rain', value: rainToPref(data['Rain']) as float, unit: settings.rainUnits)
505                                 child?.sendEvent(name: 'rainSumHour', value: rainToPref(data['sum_rain_1']) as float, unit: settings.rainUnits)
506                                 child?.sendEvent(name: 'rainSumDay', value: rainToPref(data['sum_rain_24']) as float, unit: settings.rainUnits)
507                                 child?.sendEvent(name: 'units', value: settings.rainUnits)
508                                 break;
509                         case 'NAModule4':
510                                 log.debug "Updating NAModule4 $data"
511                                 child?.sendEvent(name: 'temperature', value: cToPref(data['Temperature']) as float, unit: getTemperatureScale())
512                                 child?.sendEvent(name: 'carbonDioxide', value: data['CO2'])
513                                 child?.sendEvent(name: 'humidity', value: data['Humidity'])
514                                 break;
515                 }
516         }
517 }
518
519 def cToPref(temp) {
520         if(getTemperatureScale() == 'C') {
521         return temp
522     } else {
523                 return temp * 1.8 + 32
524     }
525 }
526
527 def rainToPref(rain) {
528         if(settings.rainUnits == 'mm') {
529         return rain
530     } else {
531         return rain * 0.039370
532     }
533 }
534
535 def debugEvent(message, displayEvent) {
536         def results = [
537                 name: "appdebug",
538                 descriptionText: message,
539                 displayed: displayEvent
540         ]
541         log.debug "Generating AppDebug Event: ${results}"
542         sendEvent(results)
543 }
544
545 private Boolean canInstallLabs() {
546         return hasAllHubsOver("000.011.00603")
547 }
548
549 private Boolean hasAllHubsOver(String desiredFirmware) {
550         return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware }
551 }
552
553 private List getRealHubFirmwareVersions() {
554         return location.hubs*.firmwareVersionString.findAll { it }
555 }