10 name: "LIFX (Connect)",
11 namespace: "smartthings",
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",
19 singleInstance: true) {
21 appSetting "clientSecret"
22 appSetting "serverUrl" // See note below
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:
28 // Production -- https://graph.api.smartthings.com
29 // Staging -- https://graph-na01s-useast1.smartthingsgdev.com
30 // Development -- https://graph-na01d-useast1.smartthingsgdev.com
33 page(name: "Credentials", title: "LIFX", content: "authPage", install: true)
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" ] }
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" }
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
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) {
68 href(url:redirectUrl, required:true, title:"Connect to LIFX", description:"Tap here to connect your LIFX account")
72 log.debug "have LIFX access token"
74 def options = locationOptions() ?: []
75 def count = options.size()
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."
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)}")
95 def redirectUrl = null
96 if (params.authQueryString) {
97 redirectUrl = URLDecoder.decode(params.authQueryString.replaceAll(".+&redirect_url=", ""))
99 log.warn "No authQueryString"
102 if (state.lifxAccessToken) {
103 log.debug "Access token already exists"
106 def code = params.code
108 if (code.size() > 6) {
110 log.debug "Exchanging code for access token"
111 oauthReceiveToken(redirectUrl)
113 // Initiate the LIFX OAuth flow.
117 log.debug "This code should be unreachable"
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?
128 uri: "https://cloud.lifx.com/oauth/token",
131 "User-Agent": "SmartThings Integration"
134 httpPost(params) { response ->
135 state.lifxAccessToken = response.data.access_token
138 if (state.lifxAccessToken) {
147 <p>Your LIFX Account is now connected to SmartThings!</p>
148 <p>Click 'Done' to finish setup.</p>
150 oauthConnectionStatus(message)
155 <p>The connection could not be established!</p>
156 <p>Click 'Done' to return to the menu.</p>
158 oauthConnectionStatus(message)
161 def oauthReceivedToken() {
163 <p>Your LIFX Account is already connected to SmartThings!</p>
164 <p>Click 'Done' to finish setup.</p>
166 oauthConnectionStatus(message)
169 def oauthConnectionStatus(message, redirectUrl = null) {
170 def redirectHtml = ""
173 <meta http-equiv="refresh" content="3; url=${redirectUrl}" />
181 <meta name="viewport" content="width=device-width">
182 <title>SmartThings Connection</title>
183 <style type="text/css">
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');
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');
210 vertical-align: middle;
217 font-family: 'Swiss 721 W01 Thin';
224 font-family: 'Swiss 721 W01 Light';
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"/>
241 render contentType: 'text/html', data: html
244 String toQueryString(Map m) {
245 return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
248 // App lifecycle hooks
251 if (!state.accessToken) {
258 // called after settings are changed
260 if (!state.accessToken) {
268 log.info("Uninstalling, removing child devices...")
269 unschedule('updateDevices')
270 removeChildDevices(getChildDevices())
273 private removeChildDevices(devices) {
275 deleteChildDevice(it.deviceNetworkId) // 'it' is default
279 // called after Done is hit after selecting a Location
281 log.debug "initialize"
283 // Check for new devices and remove old ones every 3 hours
284 runEvery5Minutes('updateDevices')
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}\"}")
297 Map apiRequestHeaders() {
298 return ["Authorization": "Bearer ${state.lifxAccessToken}",
299 "Accept": "application/json",
300 "Content-Type": "application/json",
301 "User-Agent": "SmartThings Integration"
307 def logResponse(response) {
308 log.info("Status: ${response.status}")
309 log.info("Body: ${response.data}")
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) {
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"
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
332 httpGet(uri: apiURL(path), headers: apiRequestHeaders()) {response ->
333 logResponse(response)
336 } catch (groovyx.net.http.HttpResponseException e) {
337 logResponse(e.response)
342 def apiPUT(path, body = [:]) {
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)
349 } catch (groovyx.net.http.HttpResponseException e) {
350 logResponse(e.response)
354 def devicesList(selector = '') {
356 def resp = apiGET("/lights/${selector}")
357 if (resp.status == 200) {
360 log.debug("No response from device list call. ${resp.status} ${resp.data}")
366 Map locationOptions() {
368 def devices = devicesList()
369 devices.each { device ->
370 options[device.location.id] = device.location.name
372 log.debug("Locations: ${options}")
376 def devicesInLocation() {
377 return devicesList("location_id:${settings.selectedLocationId}")
380 // ensures the devices list is up to date
381 def updateDevices() {
382 if (!state.devices) {
385 def devices = devicesInLocation()
388 log.debug("All selectors: ${selectors}")
390 devices.each { device ->
391 def childDevice = getChildDevice(device.id)
392 selectors.add("${device.id}")
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])
398 childDevice = addChildDevice(app.namespace, "LIFX White Bulb", device.id, null, ["label": device.label, "completedSetup": true])
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)
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)
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]
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"
428 state.devices[device.id] = [online: device.connected]
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.
438 deleteChildDevice(it.deviceNetworkId)
439 } catch (Exception e) {
440 log.debug("Can't remove this device because it's being used by an SmartApp")