Update beacon-control.groovy
[smartapps.git] / official / lifx-connect.groovy
1 /**
2  *  LIFX
3  *
4  *  Copyright 2015 LIFX
5  *
6  */
7 include 'localization'
8
9 definition(
10                 name: "LIFX (Connect)",
11                 namespace: "smartthings",
12                 author: "LIFX",
13                 description: "Allows you to use LIFX smart light bulbs with SmartThings.",
14                 category: "Convenience",
15                 iconUrl: "https://cloud.lifx.com/images/lifx.png",
16                 iconX2Url: "https://cloud.lifx.com/images/lifx.png",
17                 iconX3Url: "https://cloud.lifx.com/images/lifx.png",
18                 oauth: true,
19                 singleInstance: true) {
20         appSetting "clientId"
21         appSetting "clientSecret"
22         appSetting "serverUrl" // See note below
23 }
24
25 // NOTE regarding OAuth settings. On NA01 (i.e. graph.api), NA01S, and NA01D the serverUrl app setting can be left
26 // Blank. For other shards is should be set to the callback URL registered with LIFX, which is:
27 //
28 // Production  -- https://graph.api.smartthings.com
29 // Staging     -- https://graph-na01s-useast1.smartthingsgdev.com
30 // Development -- https://graph-na01d-useast1.smartthingsgdev.com
31
32 preferences {
33         page(name: "Credentials", title: "LIFX", content: "authPage", install: true)
34 }
35
36 mappings {
37         path("/receivedToken") { action: [ POST: "oauthReceivedToken", GET: "oauthReceivedToken"] }
38         path("/receiveToken") { action: [ POST: "oauthReceiveToken", GET: "oauthReceiveToken"] }
39         path("/hookCallback") { action: [ POST: "hookEventHandler", GET: "hookEventHandler"] }
40         path("/oauth/callback") { action: [ GET: "oauthCallback" ] }
41         path("/oauth/initialize") { action: [ GET: "oauthInit"] }
42         path("/test") { action: [ GET: "oauthSuccess" ] }
43 }
44
45 def getServerUrl()               { return  appSettings.serverUrl ?: apiServerUrl }
46 def getCallbackUrl()             { return "${getServerUrl()}/oauth/callback" }
47 def apiURL(path = '/')                   { return "https://api.lifx.com/v1${path}" }
48 def getSecretKey()               { return appSettings.secretKey }
49 def getClientId()                { return appSettings.clientId }
50 private getVendorName() { "LIFX" }
51
52 def authPage() {
53         log.debug "authPage test1"
54         if (!state.lifxAccessToken) {
55                 log.debug "no LIFX access token"
56                 // This is the SmartThings access token
57                 if (!state.accessToken) {
58                         log.debug "no access token, create access token"
59                         state.accessToken = createAccessToken() // predefined method
60                 }
61                 def description = "Tap to enter LIFX credentials"
62                 def redirectUrl = "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}&apiServerUrl=${apiServerUrl}" // this triggers oauthInit() below
63                 // def redirectUrl = "${apiServerUrl}"
64                 // log.debug "app id: ${app.id}"
65                 // log.debug "redirect url: ${redirectUrl}"s
66                 return dynamicPage(name: "Credentials", title: "Connect to LIFX", nextPage: null, uninstall: true, install:true) {
67                         section {
68                                 href(url:redirectUrl, required:true, title:"Connect to LIFX", description:"Tap here to connect your LIFX account")
69                         }
70                 }
71         } else {
72                 log.debug "have LIFX access token"
73
74                 def options = locationOptions() ?: []
75                 def count = options.size()
76
77                 return dynamicPage(name:"Credentials", title:"", nextPage:"", install:true, uninstall: true) {
78                         section("Select your location") {
79                                 input "selectedLocationId", "enum", required:true, title:"Select location ({{count}} found)", messageArgs: [count: count], multiple:false, options:options, submitOnChange: true
80                                 paragraph "Devices will be added automatically from your ${vendorName} account. To add or delete devices please use the Official ${vendorName} App."
81                         }
82                 }
83         }
84 }
85
86 // OAuth
87
88 def oauthInit() {
89         def oauthParams = [client_id: "${appSettings.clientId}", scope: "remote_control:all", response_type: "code" ]
90         log.info("Redirecting user to OAuth setup")
91         redirect(location: "https://cloud.lifx.com/oauth/authorize?${toQueryString(oauthParams)}")
92 }
93
94 def oauthCallback() {
95         def redirectUrl = null
96         if (params.authQueryString) {
97                 redirectUrl = URLDecoder.decode(params.authQueryString.replaceAll(".+&redirect_url=", ""))
98         } else {
99                 log.warn "No authQueryString"
100         }
101
102         if (state.lifxAccessToken) {
103                 log.debug "Access token already exists"
104                 success()
105         } else {
106                 def code = params.code
107                 if (code) {
108                         if (code.size() > 6) {
109                                 // LIFX code
110                                 log.debug "Exchanging code for access token"
111                                 oauthReceiveToken(redirectUrl)
112                         } else {
113                                 // Initiate the LIFX OAuth flow.
114                                 oauthInit()
115                         }
116                 } else {
117                         log.debug "This code should be unreachable"
118                         success()
119                 }
120         }
121 }
122
123 def oauthReceiveToken(redirectUrl = null) {
124         // Not sure what redirectUrl is for
125         log.debug "receiveToken - params: ${params}"
126         def oauthParams = [ client_id: "${appSettings.clientId}", client_secret: "${appSettings.clientSecret}", grant_type: "authorization_code", code: params.code, scope: params.scope ] // how is params.code valid here?
127         def params = [
128                         uri: "https://cloud.lifx.com/oauth/token",
129                         body: oauthParams,
130                         headers: [
131                                         "User-Agent": "SmartThings Integration"
132                         ]
133         ]
134         httpPost(params) { response ->
135                 state.lifxAccessToken = response.data.access_token
136         }
137
138         if (state.lifxAccessToken) {
139                 oauthSuccess()
140         } else {
141                 oauthFailure()
142         }
143 }
144
145 def oauthSuccess() {
146         def message = """
147                 <p>Your LIFX Account is now connected to SmartThings!</p>
148                 <p>Click 'Done' to finish setup.</p>
149         """
150         oauthConnectionStatus(message)
151 }
152
153 def oauthFailure() {
154         def message = """
155                 <p>The connection could not be established!</p>
156                 <p>Click 'Done' to return to the menu.</p>
157         """
158         oauthConnectionStatus(message)
159 }
160
161 def oauthReceivedToken() {
162         def message = """
163                 <p>Your LIFX Account is already connected to SmartThings!</p>
164                 <p>Click 'Done' to finish setup.</p>
165         """
166         oauthConnectionStatus(message)
167 }
168
169 def oauthConnectionStatus(message, redirectUrl = null) {
170         def redirectHtml = ""
171         if (redirectUrl) {
172                 redirectHtml = """
173                         <meta http-equiv="refresh" content="3; url=${redirectUrl}" />
174                 """
175         }
176
177         def html = """
178                 <!DOCTYPE html>
179                 <html>
180                 <head>
181                 <meta name="viewport" content="width=device-width">
182                 <title>SmartThings Connection</title>
183                 <style type="text/css">
184                         @font-face {
185                                 font-family: 'Swiss 721 W01 Thin';
186                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
187                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
188                                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
189                                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
190                                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
191                                 font-weight: normal;
192                                 font-style: normal;
193                         }
194                         @font-face {
195                                 font-family: 'Swiss 721 W01 Light';
196                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
197                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
198                                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
199                                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
200                                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
201                                 font-weight: normal;
202                                 font-style: normal;
203                         }
204                         .container {
205                                 width: 280;
206                                 padding: 20px;
207                                 text-align: center;
208                         }
209                         img {
210                                 vertical-align: middle;
211                         }
212                         img:nth-child(2) {
213                                 margin: 0 15px;
214                         }
215                         p {
216                                 font-size: 1.2em;
217                                 font-family: 'Swiss 721 W01 Thin';
218                                 text-align: center;
219                                 color: #666666;
220                                 padding: 0 20px;
221                                 margin-bottom: 0;
222                         }
223                         span {
224                                 font-family: 'Swiss 721 W01 Light';
225                         }
226                 </style>
227                 ${redirectHtml}
228                 </head>
229                 <body>
230                         <div class="container">
231                                 <img src='https://cloud.lifx.com/images/lifx.png' alt='LIFX icon' width='100'/>
232                                 <img src='https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png' alt='connected device icon' width="40"/>
233                                 <img src='https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png' alt='SmartThings logo' width="100"/>
234                                 <p>
235                                         ${message}
236                                 </p>
237                         </div>
238                 </body>
239                 </html>
240         """
241         render contentType: 'text/html', data: html
242 }
243
244 String toQueryString(Map m) {
245         return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
246 }
247
248 // App lifecycle hooks
249
250 def installed() {
251         if (!state.accessToken) {
252                 createAccessToken()
253         } else {
254                 initialize()
255         }
256 }
257
258 // called after settings are changed
259 def updated() {
260         if (!state.accessToken) {
261                 createAccessToken()
262         } else {
263                 initialize()
264         }
265 }
266
267 def uninstalled() {
268         log.info("Uninstalling, removing child devices...")
269         unschedule('updateDevices')
270         removeChildDevices(getChildDevices())
271 }
272
273 private removeChildDevices(devices) {
274         devices.each {
275                 deleteChildDevice(it.deviceNetworkId) // 'it' is default
276         }
277 }
278
279 // called after Done is hit after selecting a Location
280 def initialize() {
281         log.debug "initialize"
282         updateDevices()
283         // Check for new devices and remove old ones every 3 hours
284         runEvery5Minutes('updateDevices')
285         setupDeviceWatch()
286 }
287
288 // Misc
289 private setupDeviceWatch() {
290         def hub = location.hubs[0]
291         // Make sure that all child devices are enrolled in device watch
292         getChildDevices().each {
293                 it.sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\", \"hubHardwareId\": \"${hub?.hub?.hardwareID}\"}")
294         }
295 }
296
297 Map apiRequestHeaders() {
298         return ["Authorization": "Bearer ${state.lifxAccessToken}",
299                         "Accept": "application/json",
300                         "Content-Type": "application/json",
301                         "User-Agent": "SmartThings Integration"
302         ]
303 }
304
305 // Requests
306
307 def logResponse(response) {
308         log.info("Status: ${response.status}")
309         log.info("Body: ${response.data}")
310 }
311
312 // API Requests
313 // logObject is because log doesn't work if this method is being called from a Device
314 def logErrors(options = [errorReturn: null, logObject: log], Closure c) {
315         try {
316                 return c()
317         } catch (groovyx.net.http.HttpResponseException e) {
318                 options.logObject.error("got error: ${e}, body: ${e.getResponse().getData()}")
319                 if (e.statusCode == 401) { // token is expired
320                         state.remove("lifxAccessToken")
321                         options.logObject.warn "Access token is not valid"
322                 }
323                 return options.errorReturn
324         } catch (java.net.SocketTimeoutException e) {
325                 options.logObject.warn "Connection timed out, not much we can do here"
326                 return options.errorReturn
327         }
328 }
329
330 def apiGET(path) {
331         try {
332                 httpGet(uri: apiURL(path), headers: apiRequestHeaders()) {response ->
333                         logResponse(response)
334                         return response
335                 }
336         } catch (groovyx.net.http.HttpResponseException e) {
337                 logResponse(e.response)
338                 return e.response
339         }
340 }
341
342 def apiPUT(path, body = [:]) {
343         try {
344                 log.debug("Beginning API PUT: ${path}, ${body}")
345                 httpPutJson(uri: apiURL(path), body: new groovy.json.JsonBuilder(body).toString(), headers: apiRequestHeaders(), ) {response ->
346                         logResponse(response)
347                         return response
348                 }
349         } catch (groovyx.net.http.HttpResponseException e) {
350                 logResponse(e.response)
351                 return e.response
352         }}
353
354 def devicesList(selector = '') {
355         logErrors([]) {
356                 def resp = apiGET("/lights/${selector}")
357                 if (resp.status == 200) {
358                         return resp.data
359                 } else {
360                         log.debug("No response from device list call. ${resp.status} ${resp.data}")
361                         return []
362                 }
363         }
364 }
365
366 Map locationOptions() {
367         def options = [:]
368         def devices = devicesList()
369         devices.each { device ->
370                 options[device.location.id] = device.location.name
371         }
372         log.debug("Locations: ${options}")
373         return options
374 }
375
376 def devicesInLocation() {
377         return devicesList("location_id:${settings.selectedLocationId}")
378 }
379
380 // ensures the devices list is up to date
381 def updateDevices() {
382         if (!state.devices) {
383                 state.devices = [:]
384         }
385         def devices = devicesInLocation()
386         def selectors = []
387
388         log.debug("All selectors: ${selectors}")
389
390         devices.each { device ->
391                 def childDevice = getChildDevice(device.id)
392                 selectors.add("${device.id}")
393                 if (!childDevice) {
394                         // log.info("Adding device ${device.id}: ${device.product}")
395                         if (device.product.capabilities.has_color) {
396                                 childDevice = addChildDevice(app.namespace, "LIFX Color Bulb", device.id, null, ["label": device.label, "completedSetup": true])
397                         } else {
398                                 childDevice = addChildDevice(app.namespace, "LIFX White Bulb", device.id, null, ["label": device.label, "completedSetup": true])
399                         }
400                 }
401
402                 if (device.product.capabilities.has_color) {
403                         childDevice.sendEvent(name: "color", value: colorUtil.hslToHex((device.color.hue / 3.6) as int, (device.color.saturation * 100) as int))
404                         childDevice.sendEvent(name: "hue", value: device.color.hue / 3.6)
405                         childDevice.sendEvent(name: "saturation", value: device.color.saturation * 100)
406                 }
407                 childDevice.sendEvent(name: "label", value: device.label)
408                 childDevice.sendEvent(name: "level", value: Math.round((device.brightness ?: 1) * 100))
409                 childDevice.sendEvent(name: "switch.setLevel", value: Math.round((device.brightness ?: 1) * 100))
410                 childDevice.sendEvent(name: "switch", value: device.power)
411                 childDevice.sendEvent(name: "colorTemperature", value: device.color.kelvin)
412                 childDevice.sendEvent(name: "model", value: device.product.name)
413
414                 if (state.devices[device.id] == null) {
415                         // State missing, add it and set it to opposite status as current status to provoke event below
416                         state.devices[device.id] = [online: !device.connected]
417                 }
418
419                 if (!state.devices[device.id]?.online && device.connected) {
420                         // Device came online after being offline
421                         childDevice?.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false)
422                         log.debug "$device is back Online"
423                 } else if (state.devices[device.id]?.online && !device.connected) {
424                         // Device went offline after being online
425                         childDevice?.sendEvent(name: "DeviceWatch-DeviceStatus", value: "offline", displayed: false)
426                         log.debug "$device went Offline"
427                 }
428                 state.devices[device.id] = [online: device.connected]
429         }
430         getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each {
431                 log.info("Deleting ${it.deviceNetworkId}")
432                 if (state.devices[it.deviceNetworkId])
433                         state.devices[it.deviceNetworkId] = null
434                 // The reason the implementation is trying to delete this bulb is because it is not longer connected to the LIFX location.
435                 // Adding "try" will prevent this exception from happening.
436                 // Ideally device health would show to the user that the device is not longer accessible so that the user can either force delete it or remove it from the SmartApp.
437                 try {
438                         deleteChildDevice(it.deviceNetworkId)
439                 } catch (Exception e) {
440                         log.debug("Can't remove this device because it's being used by an SmartApp")
441                 }
442         }
443 }