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 = """ + + + + +
+ + ${message} +
+