From 78e25c695871c5f1b14eb69dd98013f8f060c3bb Mon Sep 17 00:00:00 2001 From: Vinay Rao Date: Thu, 3 Sep 2015 12:21:20 -0700 Subject: [PATCH] Lifx integration --- .../lifx-color-bulb.groovy | 203 +++++++++ .../lifx-white-bulb.groovy | 137 ++++++ smartapps/smartthings/lifx.src/lifx.groovy | 393 ++++++++++++++++++ 3 files changed, 733 insertions(+) create mode 100644 devicetypes/smartthings/lifx-color-bulb.src/lifx-color-bulb.groovy create mode 100644 devicetypes/smartthings/lifx-white-bulb.src/lifx-white-bulb.groovy create mode 100644 smartapps/smartthings/lifx.src/lifx.groovy diff --git a/devicetypes/smartthings/lifx-color-bulb.src/lifx-color-bulb.groovy b/devicetypes/smartthings/lifx-color-bulb.src/lifx-color-bulb.groovy new file mode 100644 index 0000000..c6e420b --- /dev/null +++ b/devicetypes/smartthings/lifx-color-bulb.src/lifx-color-bulb.groovy @@ -0,0 +1,203 @@ +/** + * LIFX Color Bulb + * + * Copyright 2015 LIFX + * + */ +metadata { + definition (name: "LIFX Color Bulb", namespace: "lifx", author: "Jack Chen") { + capability "Actuator" + capability "Color Control" + capability "Color Temperature" + capability "Switch" + capability "Switch Level" // brightness + capability "Polling" + capability "Refresh" + capability "Sensor" + } + + simulator { + // TODO: define status and reply messages here + } + + tiles { + standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) { + state "unreachable", label: "?", action:"refresh.refresh", icon:"st.switches.light.off", backgroundColor:"#666666" + state "on", label:'${name}', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#79b821", nextState:"turningOff" + state "off", label:'${name}', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn" + state "turningOn", label:'Turning on', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#79b821", nextState:"turningOff" + state "turningOff", label:'Turning off', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn" + } + standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + valueTile("null", "device.switch", inactiveLabel: false, decoration: "flat") { + state "default", label:'' + } + + controlTile("rgbSelector", "device.color", "color", height: 3, width: 3, inactiveLabel: false) { + state "color", action:"setColor" + } + + controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 3, inactiveLabel: false, range:"(0..100)") { + state "level", action:"switch level.setLevel" + } + valueTile("level", "device.level", inactiveLabel: false, icon: "st.illuminance.illuminance.light", decoration: "flat") { + state "level", label: '${currentValue}%' + } + + controlTile("colorTempSliderControl", "device.colorTemperature", "slider", height: 1, width: 2, inactiveLabel: false, range:"(2700..9000)") { + state "colorTemp", action:"color temperature.setColorTemperature" + } + valueTile("colorTemp", "device.colorTemperature", inactiveLabel: false, decoration: "flat") { + state "colorTemp", label: '${currentValue}K' + } + + main(["switch"]) + details(["switch", "refresh", "level", "levelSliderControl", "rgbSelector", "colorTempSliderControl", "colorTemp"]) + } +} + +// parse events into attributes +def parse(String description) { + if (description == 'updated') { + return // don't poll when config settings is being updated as it may time out + } + poll() +} + +// handle commands +def setHue(percentage) { + log.debug "setHue ${percentage}" + parent.logErrors(logObject: log) { + def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: "hue:${percentage * 3.6}"]) + if (resp.status < 300) { + sendEvent(name: "hue", value: percentage) + sendEvent(name: "switch", value: "on") + } else { + log.error("Bad setHue result: [${resp.status}] ${resp.data}") + } + } +} + +def setSaturation(percentage) { + log.debug "setSaturation ${percentage}" + parent.logErrors(logObject: log) { + def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: "saturation:${percentage / 100}"]) + if (resp.status < 300) { + sendEvent(name: "saturation", value: percentage) + sendEvent(name: "switch", value: "on") + } else { + log.error("Bad setSaturation result: [${resp.status}] ${resp.data}") + } + } +} + +def setColor(Map color) { + log.debug "setColor ${color}" + def attrs = [] + def events = [] + color.each { key, value -> + switch (key) { + case "hue": + attrs << "hue:${value * 3.6}" + events << createEvent(name: "hue", value: value) + break + case "saturation": + attrs << "saturation:${value / 100}" + events << createEvent(name: "saturation", value: value) + break + case "colorTemperature": + attrs << "kelvin:${value}" + events << createEvent(name: "colorTemperature", value: value) + break + } + } + parent.logErrors(logObject:log) { + def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: attrs.join(" ")]) + if (resp.status < 300) { + sendEvent(name: "color", value: color.hex) + sendEvent(name: "switch", value: "on") + events.each { sendEvent(it) } + } else { + log.error("Bad setColor result: [${resp.status}] ${resp.data}") + } + } +} + +def setLevel(percentage) { + log.debug "setLevel ${percentage}" + if (percentage < 1 && percentage > 0) { + percentage = 1 // clamp to 1% + } + if (percentage == 0) { + sendEvent(name: "level", value: 0) // Otherwise the level value tile does not update + return off() // if the brightness is set to 0, just turn it off + } + parent.logErrors(logObject:log) { + def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", ["color": "brightness:${percentage / 100}"]) + if (resp.status < 300) { + sendEvent(name: "level", value: percentage) + sendEvent(name: "switch", value: "on") + } else { + log.error("Bad setLevel result: [${resp.status}] ${resp.data}") + } + } +} + +def setColorTemperature(kelvin) { + log.debug "Executing 'setColorTemperature' to ${kelvin}" + parent.logErrors() { + def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: "kelvin:${kelvin}"]) + if (resp.status < 300) { + sendEvent(name: "colorTemperature", value: kelvin) + sendEvent(name: "color", value: "#ffffff") + sendEvent(name: "saturation", value: 0) + } else { + log.error("Bad setLevel result: [${resp.status}] ${resp.data}") + } + + } +} + +def on() { + log.debug "Device setOn" + parent.logErrors() { + if (parent.apiPUT("/lights/${device.deviceNetworkId}/power", [state: "on"]) != null) { + sendEvent(name: "switch", value: "on") + } + } +} + +def off() { + log.debug "Device setOff" + parent.logErrors() { + if (parent.apiPUT("/lights/${device.deviceNetworkId}/power", [state: "off"]) != null) { + sendEvent(name: "switch", value: "off") + } + } +} + +def poll() { + log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}" + def resp = parent.apiGET("/lights/${device.deviceNetworkId}") + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + return [] + } + def data = resp.data + + sendEvent(name: "level", value: sprintf("%.1f", (data.brightness ?: 1) * 100)) + sendEvent(name: "switch", value: data.connected ? data.power : "unreachable") + sendEvent(name: "color", value: colorUtil.hslToHex((data.color.hue / 3.6) as int, (data.color.saturation * 100) as int)) + sendEvent(name: "hue", value: data.color.hue / 3.6) + sendEvent(name: "saturation", value: data.color.saturation * 100) + sendEvent(name: "colorTemperature", value: data.color.kelvin) + + return [] +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} diff --git a/devicetypes/smartthings/lifx-white-bulb.src/lifx-white-bulb.groovy b/devicetypes/smartthings/lifx-white-bulb.src/lifx-white-bulb.groovy new file mode 100644 index 0000000..5619e11 --- /dev/null +++ b/devicetypes/smartthings/lifx-white-bulb.src/lifx-white-bulb.groovy @@ -0,0 +1,137 @@ +/** + * LIFX White Bulb + * + * Copyright 2015 LIFX + * + */ +metadata { + definition (name: "LIFX White Bulb", namespace: "lifx", author: "Jack Chen") { + capability "Actuator" + capability "Color Temperature" + capability "Switch" + capability "Switch Level" // brightness + capability "Polling" + capability "Refresh" + capability "Sensor" + } + + simulator { + // TODO: define status and reply messages here + } + + tiles { + standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) { + state "on", label:'${name}', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#79b821", nextState:"turningOff" + state "off", label:'${name}', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn" + state "turningOn", label:'Turning on', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#79b821", nextState:"turningOff" + state "turningOff", label:'Turning off', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn" + state "unreachable", label: "?", action:"refresh.refresh", icon:"st.switches.light.off", backgroundColor:"#666666" + } + standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + valueTile("null", "device.switch", inactiveLabel: false, decoration: "flat") { + state "default", label:'' + } + + controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 3, inactiveLabel: false, range:"(0..100)") { + state "level", action:"switch level.setLevel" + } + valueTile("level", "device.level", inactiveLabel: false, icon: "st.illuminance.illuminance.light", decoration: "flat") { + state "level", label: '${currentValue}%' + } + + controlTile("colorTempSliderControl", "device.colorTemperature", "slider", height: 1, width: 2, inactiveLabel: false, range:"(2700..6500)") { + state "colorTemp", action:"color temperature.setColorTemperature" + } + valueTile("colorTemp", "device.colorTemperature", inactiveLabel: false, decoration: "flat") { + state "colorTemp", label: '${currentValue}K' + } + + main(["switch"]) + details(["switch", "refresh", "level", "levelSliderControl", "colorTempSliderControl", "colorTemp"]) + } +} + +// parse events into attributes +def parse(String description) { + if (description == 'updated') { + return // don't poll when config settings is being updated as it may time out + } + poll() +} + +// handle commands +def setLevel(percentage) { + log.debug "setLevel ${percentage}" + if (percentage < 1 && percentage > 0) { + percentage = 1 // clamp to 1% + } + if (percentage == 0) { + sendEvent(name: "level", value: 0) // Otherwise the level value tile does not update + return off() // if the brightness is set to 0, just turn it off + } + parent.logErrors(logObject:log) { + def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", ["color": "brightness:${percentage / 100}"]) + if (resp.status < 300) { + sendEvent(name: "level", value: percentage) + sendEvent(name: "switch", value: "on") + } else { + log.error("Bad setLevel result: [${resp.status}] ${resp.data}") + } + } +} + +def setColorTemperature(kelvin) { + log.debug "Executing 'setColorTemperature' to ${kelvin}" + parent.logErrors() { + def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: "kelvin:${kelvin}"]) + if (resp.status < 300) { + sendEvent(name: "colorTemperature", value: kelvin) + sendEvent(name: "color", value: "#ffffff") + sendEvent(name: "saturation", value: 0) + sendEvent(name: "switch", value: "on") + } else { + log.error("Bad setColorTemperature result: [${resp.status}] ${resp.data}") + } + } +} + +def on() { + log.debug "Device setOn" + parent.logErrors() { + if (parent.apiPUT("/lights/${device.deviceNetworkId}/power", [state: "on"]) != null) { + sendEvent(name: "switch", value: "on") + } + } +} + +def off() { + log.debug "Device setOff" + parent.logErrors() { + if (parent.apiPUT("/lights/${device.deviceNetworkId}/power", [state: "off"]) != null) { + sendEvent(name: "switch", value: "off") + } + } +} + +def poll() { + log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}" + def resp = parent.apiGET("/lights/${device.deviceNetworkId}") + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + return [] + } + def data = resp.data + + sendEvent(name: "level", value: sprintf("%f", (data.brightness ?: 1) * 100)) + sendEvent(name: "switch", value: data.connected ? data.power : "unreachable") + sendEvent(name: "colorTemperature", value: data.color.kelvin) + + return [] +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} diff --git a/smartapps/smartthings/lifx.src/lifx.groovy b/smartapps/smartthings/lifx.src/lifx.groovy new file mode 100644 index 0000000..066651e --- /dev/null +++ b/smartapps/smartthings/lifx.src/lifx.groovy @@ -0,0 +1,393 @@ +/** + * LIFX + * + * Copyright 2015 LIFX + * + */ +definition( + name: "LIFX", + namespace: "lifx", + author: "LIFX", + description: "Allows you to use LIFX smart light bulbs with SmartThings.", + category: "Convenience", + iconUrl: "https://cloud.lifx.com/images/lifx.png", + iconX2Url: "https://cloud.lifx.com/images/lifx.png", + iconX3Url: "https://cloud.lifx.com/images/lifx.png", + oauth: true) { + appSetting "clientId" + appSetting "clientSecret" +} + + +preferences { + page(name: "Credentials", title: "LIFX", content: "authPage", install: false) +} + +mappings { + path("/receivedToken") { action: [ POST: "oauthReceivedToken", GET: "oauthReceivedToken"] } + path("/receiveToken") { action: [ POST: "oauthReceiveToken", GET: "oauthReceiveToken"] } + path("/hookCallback") { action: [ POST: "hookEventHandler", GET: "hookEventHandler"] } + path("/oauth/callback") { action: [ GET: "oauthCallback" ] } + path("/oauth/initialize") { action: [ GET: "oauthInit"] } + path("/test") { action: [ GET: "oauthSuccess" ] } +} + +def getServerUrl() { return "https://graph.api.smartthings.com" } +def apiURL(path = '/') { return "https://api.lifx.com/v1beta1${path}" } +def buildRedirectUrl(page) { + return "${serverUrl}/api/token/${state.accessToken}/smartapps/installations/${app.id}/${page}" +} + +def authPage() { + log.debug "authPage" + if (!state.lifxAccessToken) { + log.debug "no LIFX access token" + // This is the SmartThings access token + if (!state.accessToken) { + log.debug "no access token, create access token" + createAccessToken() // predefined method + } + def description = "Tap to enter LIFX credentials" + def redirectUrl = "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}" // this triggers oauthInit() below + log.debug "app id: ${app.id}" + log.debug "redirect url: ${redirectUrl}" + return dynamicPage(name: "Credentials", title: "Connect to LIFX", nextPage: null, uninstall: true, install:false) { + section { + href(url:redirectUrl, required:true, title:"Connect to LIFX", description:"Tap here to connect your LIFX account") + // href(url:buildRedirectUrl("test"), title: "Message test") + } + } + } else { + log.debug "have LIFX access token" + + def options = locationOptions() ?: [] + def count = options.size() + + return dynamicPage(name:"Credentials", title:"Select devices...", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) { + section("Select your location") { + input "selectedLocationId", "enum", required:true, title:"Select location (${count} found)", multiple:false, options:options + } + } + } +} + + +// OAuth + +def oauthInit() { + def oauthParams = [client_id: "${appSettings.clientId}", scope: "remote_control:all", response_type: "code" ] + log.info("Redirecting user to OAuth setup") + redirect(location: "https://cloud.lifx.com/oauth/authorize?${toQueryString(oauthParams)}") +} + +def oauthCallback() { + def redirectUrl = null + if (params.authQueryString) { + redirectUrl = URLDecoder.decode(params.authQueryString.replaceAll(".+&redirect_url=", "")) + } else { + log.warn "No authQueryString" + } + + if (state.lifxAccessToken) { + log.debug "Access token already exists" + success() + } else { + def code = params.code + if (code) { + if (code.size() > 6) { + // LIFX code + log.debug "Exchanging code for access token" + oauthReceiveToken(redirectUrl) + } else { + // Initiate the LIFX OAuth flow. + oauthInit() + } + } else { + log.debug "This code should be unreachable" + success() + } + } +} + +def oauthReceiveToken(redirectUrl = null) { + // no idea what redirectUrl is for + log.debug "receiveToken - params: ${params}" + 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? + def params = [ + uri: "https://cloud.lifx.com/oauth/token", + body: oauthParams, + headers: [ + "User-Agent": "SmartThings Integration" + ] + ] + httpPost(params) { response -> + state.lifxAccessToken = response.data.access_token + } + + if (state.lifxAccessToken) { + oauthSuccess() + } else { + oauthFailure() + } +} + +def oauthSuccess() { + def message = """ +

Your LIFX Account is now connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + oauthConnectionStatus(message) +} + +def oauthFailure() { + def message = """ +

The connection could not be established!

+

Click 'Done' to return to the menu.

+ """ + oauthConnectionStatus(message) +} + +def oauthReceivedToken() { + def message = """ +

Your LIFX Account is already connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + oauthConnectionStatus(message) +} + +def oauthConnectionStatus(message, redirectUrl = null) { + def redirectHtml = "" + if (redirectUrl) { + redirectHtml = """ + + """ + } + + def html = """ + + + + + SmartThings Connection + + ${redirectHtml} + + +
+ LIFX icon + connected device icon + SmartThings logo +

+ ${message} +

+
+ + + """ + render contentType: 'text/html', data: html +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +// App lifecycle hooks + +def installed() { + enableCallback() // wtf does this do? + if (!state.accessToken) { + createAccessToken() + } else { + initialize() + } + // Check for new devices and remove old ones every 3 hours + runEvery3Hours('updateDevices') +} + +// called after settings are changed +def updated() { + enableCallback() // not sure what this does + if (!state.accessToken) { + createAccessToken() + } else { + initialize() + } +} + +def uninstalled() { + log.info("Uninstalling, removing child devices...") + unschedule('updateDevices') + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(devices) { + devices.each { + deleteChildDevice(it.deviceNetworkId) // 'it' is default + } +} + +// called after Done is hit after selecting a Location +def initialize() { + log.debug "initialize" + updateDevices() +} + +// Misc + +Map apiRequestHeaders() { + return ["Authorization": "Bearer ${state.lifxAccessToken}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "SmartThings Integration" + ] +} + +// Requests + +def logResponse(response) { + log.info("Status: ${response.status}") + log.info("Body: ${response.data}") +} + +// API Requests +// logObject is because log doesn't work if this method is being called from a Device +def logErrors(options = [errorReturn: null, logObject: log], Closure c) { + try { + return c() + } catch (groovyx.net.http.HttpResponseException e) { + options.logObject.error("got error: ${e}, body: ${e.getResponse().getData()}") + if (e.statusCode == 401) { // token is expired + state.remove("lifxAccessToken") + options.logObject.warn "Access token is not valid" + } + return options.errerReturn + } catch (java.net.SocketTimeoutException e) { + options.logObject.warn "Connection timed out, not much we can do here" + return options.errerReturn + } +} + +def apiGET(path) { + httpGet(uri: apiURL(path), headers: apiRequestHeaders()) {response -> + logResponse(response) + return response + } +} + +def apiPUT(path, body = [:]) { + log.debug("Beginning API PUT: ${path}, ${body}") + httpPutJson(uri: apiURL(path), body: new groovy.json.JsonBuilder(body).toString(), headers: apiRequestHeaders(), ) {response -> + logResponse(response) + return response + } +} + +def devicesList(selector = '') { + logErrors([]) { + def resp = apiGET("/lights/${selector}") + if (resp.status == 200) { + return resp.data + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +Map locationOptions() { +// poll() // why do we call here? + def options = [:] + def devices = devicesList() + devices.each { device -> + options[device.location.id] = device.location.name + } + return options +} + +def devicesInLocation() { + return devicesList("location_id:${settings.selectedLocationId}") +} + +// ensures the devices list is up to date +def updateDevices() { + if (!state.devices) { + state.devices = [:] + } + def devices = devicesInLocation() + def deviceIds = devices*.id + devices.each { device -> + def childDevice = getChildDevice(device.id) + if (!childDevice) { + log.info("Adding device ${device.id}: ${device.capabilities}") + def data = [ + label: device.label, + level: sprintf("%f", (device.brightness ?: 1) * 100), + switch: device.connected ? device.power : "unreachable", + colorTemperature: device.color.kelvin + ] + if (device.capabilities.has_color) { + data["color"] = colorUtil.hslToHex((device.color.hue / 3.6) as int, (device.color.saturation * 100) as int) + data["hue"] = device.color.hue / 3.6 + data["saturation"] = device.color.saturation * 100 + childDevice = addChildDevice("lifx", "LIFX Color Bulb", device.id, null, data) + } else { + childDevice = addChildDevice("lifx", "LIFX White Bulb", device.id, null, data) + } + } + } + getChildDevices().findAll { !deviceIds.contains(it.deviceNetworkId) }.each { + log.info("Deleting ${it.deviceNetworkId}") + deleteChildDevice(it.deviceNetworkId) + } + runIn(1, 'refreshDevices') // Asynchronously refresh devices so we don't block +} + +def refreshDevices() { + log.info("Refreshing all devices...") + getChildDevices().each { device -> + device.refresh() + } +}