2 * Circadian Daylight 2.6
4 * This SmartApp synchronizes your color changing lights with local perceived color
5 * temperature of the sky throughout the day. This gives your environment a more
6 * natural feel, with cooler whites during the midday and warmer tints near twilight
9 * In addition, the SmartApp sets your lights to a nice cool white at 1% in
10 * "Sleep" mode, which is far brighter than starlight but won't reset your
11 * circadian rhythm or break down too much rhodopsin in your eyes.
13 * Human circadian rhythms are heavily influenced by ambient light levels and
14 * hues. Hormone production, brainwave activity, mood and wakefulness are
15 * just some of the cognitive functions tied to cyclical natural light.
16 * http://en.wikipedia.org/wiki/Zeitgeber
18 * Here's some further reading:
20 * http://www.cambridgeincolour.com/tutorials/sunrise-sunset-calculator.htm
21 * http://en.wikipedia.org/wiki/Color_temperature
23 * Technical notes: I had to make a lot of assumptions when writing this app
24 * * The Hue bulbs are only capable of producing a true color spectrum from
25 * 2700K to 6000K. The Hue Pro application indicates the range is
26 * a little wider on each side, but I stuck with the Philips
28 * * I aligned the color space to CIE with white at D50. I suspect "true"
29 * white for this application might actually be D65, but I will have
30 * to recalculate the color temperature if I move it.
31 * * There are no considerations for weather or altitude, but does use your
32 * hub's zip code to calculate the sun position.
33 * * The app doesn't calculate a true "Blue Hour" -- it just sets the lights to
34 * 2700K (warm white) until your hub goes into Night mode
36 * Version 2.6: March 26, 2016 - Fixes issue with hex colors. Move your color changing bulbs to Color Temperature instead
37 * Version 2.5: March 14, 2016 - Add "disabled" switch
38 * Version 2.4: February 18, 2016 - Mode changes
39 * Version 2.3: January 23, 2016 - UX Improvements for publication, makes Campfire default instead of Moonlight
40 * Version 2.2: January 2, 2016 - Add better handling for off() schedules
41 * Version 2.1: October 27, 2015 - Replace motion sensors with time
42 * Version 2.0: September 19, 2015 - Update for Hub 2.0
43 * Version 1.5: June 26, 2015 - Merged with SANdood's optimizations, breaks unofficial LIGHTIFY support
44 * Version 1.4: May 21, 2015 - Clean up mode handling
45 * Version 1.3: April 8, 2015 - Reduced Hue IO, increased robustness
46 * Version 1.2: April 7, 2015 - Add support for LIGHTIFY bulbs, dimmers and user selected "Sleep"
47 * Version 1.1: April 1, 2015 - Add support for contact sensors
48 * Version 1.0: March 30, 2015 - Initial release
50 * The latest version of this file can be found at
51 * https://github.com/KristopherKubicki/smartapp-circadian-daylight/
56 name: "Circadian Daylight",
57 namespace: "KristopherKubicki",
58 author: "kristopher@acm.org",
59 description: "Sync your color changing lights and dimmers with natural daylight hues to improve your cognitive functions and restfulness.",
60 category: "Green Living",
61 iconUrl: "https://s3.amazonaws.com/smartapp-icons/MiscHacking/mindcontrol.png",
62 iconX2Url: "https://s3.amazonaws.com/smartapp-icons/MiscHacking/mindcontrol@2x.png"
66 section("Thank you for installing Circadian Daylight! This application dims and adjusts the color temperature of your lights to match the state of the sun, which has been proven to aid in cognitive functions and restfulness. The default options are well suited for most users, but feel free to tweak accordingly!") {
68 section("Control these bulbs; Select each bulb only once") {
69 input "ctbulbs", "capability.colorTemperature", title: "Which Temperature Changing Bulbs?", multiple:true, required: false
70 input "bulbs", "capability.colorControl", title: "Which Color Changing Bulbs?", multiple:true, required: false
71 input "dimmers", "capability.switchLevel", title: "Which Dimmers?", multiple:true, required: false
73 section("What are your 'Sleep' modes? The modes you pick here will dim your lights and filter light to a softer, yellower hue to help you fall asleep easier. Protip: You can pick 'Nap' modes as well!") {
74 input "smodes", "mode", title: "What are your Sleep modes?", multiple:true, required: false
76 section("Override Constant Brightness (default) with Dynamic Brightness? If you'd like your lights to dim as the sun goes down, override this option. Most people don't like it, but it can look good in some settings.") {
77 input "dbright","bool", title: "On or off?", required: false
79 section("Override night time Campfire (default) with Moonlight? Circadian Daylight by default is easier on your eyes with a yellower hue at night. However if you'd like a whiter light instead, override this option. Note: this will likely disrupt your circadian rhythm.") {
80 input "dcamp","bool", title: "On or off?", required: false
82 section("Override night time Dimming (default) with Rhodopsin Bleaching? Override this option if you would not like Circadian Daylight to dim your lights during your Sleep modes. This is definitely not recommended!") {
83 input "ddim","bool", title: "On or off?", required: false
85 section("Disable Circadian Daylight when the following switches are on:") {
86 input "dswitches","capability.switch", title: "Switches", multiple:true, required: false
102 private def initialize() {
103 log.debug("initialize() with settings: ${settings}")
104 if(ctbulbs) { subscribe(ctbulbs, "switch.on", modeHandler) }
105 if(bulbs) { subscribe(bulbs, "switch.on", modeHandler) }
106 if(dimmers) { subscribe(dimmers, "switch.on", modeHandler) }
107 if(dswitches) { subscribe(dswitches, "switch.off", modeHandler) }
108 subscribe(location, "mode", modeHandler)
110 // revamped for sunset handling instead of motion events
111 subscribe(location, "sunset", modeHandler)
112 subscribe(location, "sunrise", modeHandler)
113 schedule("0 */15 * * * ?", modeHandler)
114 subscribe(app,modeHandler)
115 subscribe(location, "sunsetTime", scheduleTurnOn)
116 // rather than schedule a cron entry, fire a status update a little bit in the future recursively
120 def scheduleTurnOn() {
121 def int iterRate = 20
123 // get sunrise and sunset times
124 def sunRiseSet = getSunriseAndSunset()
125 def sunriseTime = sunRiseSet.sunrise
126 log.debug("sunrise time ${sunriseTime}")
127 def sunsetTime = sunRiseSet.sunset
128 log.debug("sunset time ${sunsetTime}")
130 if(sunriseTime.time > sunsetTime.time) {
131 sunriseTime = new Date(sunriseTime.time - (24 * 60 * 60 * 1000))
134 def runTime = new Date(now() + 60*15*1000)
135 for (def i = 0; i < iterRate; i++) {
136 def long uts = sunriseTime.time + (i * ((sunsetTime.time - sunriseTime.time) / iterRate))
137 def timeBeforeSunset = new Date(uts)
138 if(timeBeforeSunset.time > now()) {
139 runTime = timeBeforeSunset
144 log.debug "checking... ${runTime.time} : $runTime"
145 if(state.nextTime != runTime.time) {
146 state.nextTimer = runTime.time
147 log.debug "Scheduling next step at: $runTime (sunset is $sunsetTime) :: ${state.nextTimer}"
148 runOnce(runTime, modeHandler)
153 // Poll all bulbs, and modify the ones that differ from the expected state
154 def modeHandler(evt) {
155 for (dswitch in dswitches) {
156 if(dswitch.currentSwitch == "on") {
164 def bright = getBright()
166 for(ctbulb in ctbulbs) {
167 if(ctbulb.currentValue("switch") == "on") {
168 if((settings.dbright == true || location.mode in settings.smodes) && ctbulb.currentValue("level") != bright) {
169 ctbulb.setLevel(bright)
171 if(ctbulb.currentValue("colorTemperature") != ct) {
172 ctbulb.setColorTemperature(ct)
176 def color = [hex: hex, hue: hsv.h, saturation: hsv.s, level: bright]
178 if(bulb.currentValue("switch") == "on") {
179 def tmp = bulb.currentValue("color")
180 if(bulb.currentValue("color") != hex) {
181 if(settings.dbright == true || location.mode in settings.smodes) {
184 color.value = bulb.currentValue("level")
186 def ret = bulb.setColor(color)
190 for(dimmer in dimmers) {
191 if(dimmer.currentValue("switch") == "on") {
192 if(dimmer.currentValue("level") != bright) {
193 dimmer.setLevel(bright)
202 def after = getSunriseAndSunset()
203 def midDay = after.sunrise.time + ((after.sunset.time - after.sunrise.time) / 2)
205 def currentTime = now()
206 def float brightness = 1
207 def int colorTemp = 2700
208 if(currentTime > after.sunrise.time && currentTime < after.sunset.time) {
209 if(currentTime < midDay) {
210 colorTemp = 2700 + ((currentTime - after.sunrise.time) / (midDay - after.sunrise.time) * 3800)
211 brightness = ((currentTime - after.sunrise.time) / (midDay - after.sunrise.time))
214 colorTemp = 6500 - ((currentTime - midDay) / (after.sunset.time - midDay) * 3800)
215 brightness = 1 - ((currentTime - midDay) / (after.sunset.time - midDay))
220 if(settings.dbright == false) {
224 if(location.mode in settings.smodes) {
225 if(currentTime > after.sunset.time) {
226 if(settings.dcamp == true) {
233 if(settings.ddim == false) {
239 ct = [colorTemp: colorTemp, brightness: Math.round(brightness * 100)]
244 def ctb = getCTBright()
245 //log.debug "Color Temperature: " + ctb.colorTemp
251 //log.debug "Hex: " + rgbToHex(ctToRGB(ct)).toUpperCase()
252 return rgbToHex(ctToRGB(ct)).toUpperCase()
257 //log.debug "HSV: " + rgbToHSV(ctToRGB(ct))
258 return rgbToHSV(ctToRGB(ct))
262 def ctb = getCTBright()
263 //log.debug "Brightness: " + ctb.brightness
264 return ctb.brightness
268 // Based on color temperature converter from
269 // http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/
270 // This will not work for color temperatures below 1000 or above 40000
273 if(ct < 1000) { ct = 1000 }
274 if(ct > 40000) { ct = 40000 }
280 if(ct <= 66) { r = 255 }
281 else { r = 329.698727446 * ((ct - 60) ** -0.1332047592) }
283 if(r > 255) { r = 255 }
287 if (ct <= 66) { g = 99.4708025861 * Math.log(ct) - 161.1195681661 }
288 else { g = 288.1221695283 * ((ct - 60) ** -0.0755148492) }
290 if(g > 255) { g = 255 }
294 if(ct >= 66) { b = 255 }
295 else if(ct <= 19) { b = 0 }
296 else { b = 138.5177312231 * Math.log(ct - 10) - 305.0447927307 }
298 if(b > 255) { b = 255 }
301 rgb = [r: r as Integer, g: g as Integer, b: b as Integer]
306 return "#" + Integer.toHexString(rgb.r).padLeft(2,'0') + Integer.toHexString(rgb.g).padLeft(2,'0') + Integer.toHexString(rgb.b).padLeft(2,'0')
309 //http://www.rapidtables.com/convert/color/rgb-to-hsv.htm
317 def max = [r, g, b].max()
318 def min = [r, g, b].min()
320 def delta = max - min
323 if(delta == 0) { h = 0}
325 double dub = (g - b) / delta
328 else if(max == g) { h = 60 * (((b - r) / delta) + 2) }
329 else if(max == b) { h = 60 * (((r - g) / delta) + 4) }
332 if(max == 0) { s = 0 }
333 else { s = (delta / max) * 100 }
338 def degreesRange = (360 - 0)
339 def percentRange = (100 - 0)
341 return [h: ((h * percentRange) / degreesRange) as Integer, s: ((s * percentRange) / degreesRange) as Integer, v: v as Integer]