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 // There seems to be a bug in the Groovy compiler with the following line
116 //subscribe(location, "sunsetTime", scheduleTurnOn)
117 // rather than schedule a cron entry, fire a status update a little bit in the future recursively
121 def scheduleTurnOn() {
122 def int iterRate = 20
124 // get sunrise and sunset times
125 def sunRiseSet = getSunriseAndSunset()
126 def sunriseTime = sunRiseSet.sunrise
127 log.debug("sunrise time ${sunriseTime}")
128 def sunsetTime = sunRiseSet.sunset
129 log.debug("sunset time ${sunsetTime}")
131 if(sunriseTime.time > sunsetTime.time) {
132 sunriseTime = new Date(sunriseTime.time - (24 * 60 * 60 * 1000))
135 def runTime = new Date(now() + 60*15*1000)
136 for (def i = 0; i < iterRate; i++) {
137 def long uts = sunriseTime.time + (i * ((sunsetTime.time - sunriseTime.time) / iterRate))
138 def timeBeforeSunset = new Date(uts)
139 if(timeBeforeSunset.time > now()) {
140 runTime = timeBeforeSunset
145 log.debug "checking... ${runTime.time} : $runTime"
146 if(state.nextTime != runTime.time) {
147 state.nextTimer = runTime.time
148 log.debug "Scheduling next step at: $runTime (sunset is $sunsetTime) :: ${state.nextTimer}"
149 runOnce(runTime, modeHandler)
154 // Poll all bulbs, and modify the ones that differ from the expected state
155 def modeHandler(evt) {
156 for (dswitch in dswitches) {
157 if(dswitch.currentSwitch == "on") {
165 def bright = getBright()
167 for(ctbulb in ctbulbs) {
168 if(ctbulb.currentValue("switch") == "on") {
169 if((settings.dbright == true || location.mode in settings.smodes) && ctbulb.currentValue("level") != bright) {
170 ctbulb.setLevel(bright)
172 if(ctbulb.currentValue("colorTemperature") != ct) {
173 ctbulb.setColorTemperature(ct)
177 def color = [hex: hex, hue: hsv.h, saturation: hsv.s, level: bright]
179 if(bulb.currentValue("switch") == "on") {
180 def tmp = bulb.currentValue("color")
181 if(bulb.currentValue("color") != hex) {
182 if(settings.dbright == true || location.mode in settings.smodes) {
185 color.value = bulb.currentValue("level")
187 def ret = bulb.setColor(color)
191 for(dimmer in dimmers) {
192 if(dimmer.currentValue("switch") == "on") {
193 if(dimmer.currentValue("level") != bright) {
194 dimmer.setLevel(bright)
199 //scheduleTurnOn() (To avoid infinite run time)
203 def after = getSunriseAndSunset()
204 def midDay = after.sunrise.time + ((after.sunset.time - after.sunrise.time) / 2)
206 def currentTime = now()
207 def float brightness = 1
208 def int colorTemp = 2700
209 if(currentTime > after.sunrise.time && currentTime < after.sunset.time) {
210 if(currentTime < midDay) {
211 colorTemp = 2700 + ((currentTime - after.sunrise.time) / (midDay - after.sunrise.time) * 3800)
212 brightness = ((currentTime - after.sunrise.time) / (midDay - after.sunrise.time))
215 colorTemp = 6500 - ((currentTime - midDay) / (after.sunset.time - midDay) * 3800)
216 brightness = 1 - ((currentTime - midDay) / (after.sunset.time - midDay))
221 if(settings.dbright == false) {
225 if(location.mode in settings.smodes) {
226 if(currentTime > after.sunset.time) {
227 if(settings.dcamp == true) {
234 if(settings.ddim == false) {
240 ct = [colorTemp: colorTemp, brightness: Math.round(brightness * 100)]
245 def ctb = getCTBright()
246 //log.debug "Color Temperature: " + ctb.colorTemp
252 //log.debug "Hex: " + rgbToHex(ctToRGB(ct)).toUpperCase()
253 return rgbToHex(ctToRGB(ct)).toUpperCase()
258 //log.debug "HSV: " + rgbToHSV(ctToRGB(ct))
259 return rgbToHSV(ctToRGB(ct))
263 def ctb = getCTBright()
264 //log.debug "Brightness: " + ctb.brightness
265 return ctb.brightness
269 // Based on color temperature converter from
270 // http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/
271 // This will not work for color temperatures below 1000 or above 40000
274 if(ct < 1000) { ct = 1000 }
275 if(ct > 40000) { ct = 40000 }
281 if(ct <= 66) { r = 255 }
282 else { r = 329.698727446 * ((ct - 60) ** -0.1332047592) }
284 if(r > 255) { r = 255 }
288 if (ct <= 66) { g = 99.4708025861 * Math.log(ct) - 161.1195681661 }
289 else { g = 288.1221695283 * ((ct - 60) ** -0.0755148492) }
291 if(g > 255) { g = 255 }
295 if(ct >= 66) { b = 255 }
296 else if(ct <= 19) { b = 0 }
297 else { b = 138.5177312231 * Math.log(ct - 10) - 305.0447927307 }
299 if(b > 255) { b = 255 }
302 rgb = [r: r as Integer, g: g as Integer, b: b as Integer]
307 return "#" + Integer.toHexString(rgb.r).padLeft(2,'0') + Integer.toHexString(rgb.g).padLeft(2,'0') + Integer.toHexString(rgb.b).padLeft(2,'0')
310 //http://www.rapidtables.com/convert/color/rgb-to-hsv.htm
318 def max = [r, g, b].max()
319 def min = [r, g, b].min()
321 def delta = max - min
324 if(delta == 0) { h = 0}
326 double dub = (g - b) / delta
329 else if(max == g) { h = 60 * (((b - r) / delta) + 2) }
330 else if(max == b) { h = 60 * (((r - g) / delta) + 4) }
333 if(max == 0) { s = 0 }
334 else { s = (delta / max) * 100 }
339 def degreesRange = (360 - 0)
340 def percentRange = (100 - 0)
342 return [h: ((h * percentRange) / degreesRange) as Integer, s: ((s * percentRange) / degreesRange) as Integer, v: v as Integer]