Update circadian-daylight.groovy
[smartapps.git] / third-party / circadian-daylight.groovy
1 /**
2 * Circadian Daylight 2.6
3 *
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
7 * and dawn.
8 *
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.
12 *
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
17 *
18 * Here's some further reading:
19 *
20 * http://www.cambridgeincolour.com/tutorials/sunrise-sunset-calculator.htm
21 * http://en.wikipedia.org/wiki/Color_temperature
22 *
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
27 *  documentation
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
35 *
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
49 *
50 * The latest version of this file can be found at
51 * https://github.com/KristopherKubicki/smartapp-circadian-daylight/
52 *
53 */
54
55 definition(
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"
63 )
64
65 preferences {
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!") {
67     }
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
72     }
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
75     }
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
78     }
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
81     }
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
84     }
85     section("Disable Circadian Daylight when the following switches are on:") {
86         input "dswitches","capability.switch", title: "Switches", multiple:true, required: false
87     }
88 }
89
90 def installed() {
91     unsubscribe()
92     unschedule()
93     initialize()
94 }
95
96 def updated() {
97     unsubscribe()
98     unschedule()
99     initialize()
100 }
101
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)
109     
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
118     scheduleTurnOn()
119 }
120
121 def scheduleTurnOn() {
122     def int iterRate = 20
123     
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}")
130     
131     if(sunriseTime.time > sunsetTime.time) {
132         sunriseTime = new Date(sunriseTime.time - (24 * 60 * 60 * 1000))
133     }
134     
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
141             last
142         }
143     }
144     
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)
150     }
151 }
152
153
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") {
158             return
159         }
160     }
161     
162     def ct = getCT()
163     def hex = getHex()
164     def hsv = getHSV()
165     def bright = getBright()
166     
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)
171             }
172             if(ctbulb.currentValue("colorTemperature") != ct) {
173                 ctbulb.setColorTemperature(ct)
174             }
175         }
176     }
177     def color = [hex: hex, hue: hsv.h, saturation: hsv.s, level: bright]
178     for(bulb in bulbs) {
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) { 
183                         color.value = bright
184                 } else {
185                                         color.value = bulb.currentValue("level")
186                                 }
187                 def ret = bulb.setColor(color)
188                         }
189         }
190     }
191     for(dimmer in dimmers) {
192         if(dimmer.currentValue("switch") == "on") {
193                 if(dimmer.currentValue("level") != bright) {
194                 dimmer.setLevel(bright)
195             }
196         }
197     }
198     
199     //scheduleTurnOn() (To avoid infinite run time)
200 }
201
202 def getCTBright() {
203     def after = getSunriseAndSunset()
204     def midDay = after.sunrise.time + ((after.sunset.time - after.sunrise.time) / 2)
205     
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))
213         }
214         else {
215             colorTemp = 6500 - ((currentTime - midDay) / (after.sunset.time - midDay) * 3800)
216             brightness = 1 - ((currentTime - midDay) / (after.sunset.time - midDay))
217             
218         }
219     }
220     
221     if(settings.dbright == false) {
222         brightness = 1
223     }
224     
225         if(location.mode in settings.smodes) {
226                 if(currentTime > after.sunset.time) {
227                         if(settings.dcamp == true) {
228                                 colorTemp = 6500
229                         }
230                         else {
231                                 colorTemp = 2700
232                         }
233                 }
234                 if(settings.ddim == false) {
235                         brightness = 0.01
236                 }
237         }
238     
239     def ct = [:]
240     ct = [colorTemp: colorTemp, brightness: Math.round(brightness * 100)]
241     ct
242 }
243
244 def getCT() {
245         def ctb = getCTBright()
246     //log.debug "Color Temperature: " + ctb.colorTemp
247     return ctb.colorTemp
248 }
249
250 def getHex() {
251         def ct = getCT()
252     //log.debug "Hex: " + rgbToHex(ctToRGB(ct)).toUpperCase()
253     return rgbToHex(ctToRGB(ct)).toUpperCase()
254 }
255
256 def getHSV() {
257         def ct = getCT()
258     //log.debug "HSV: " + rgbToHSV(ctToRGB(ct))
259     return rgbToHSV(ctToRGB(ct))
260 }
261
262 def getBright() {
263         def ctb = getCTBright()
264     //log.debug "Brightness: " + ctb.brightness
265     return ctb.brightness
266 }
267
268
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
272 def ctToRGB(ct) {
273     
274     if(ct < 1000) { ct = 1000 }
275     if(ct > 40000) { ct = 40000 }
276     
277     ct = ct / 100
278     
279     //red
280     def r
281     if(ct <= 66) { r = 255 }
282     else { r = 329.698727446 * ((ct - 60) ** -0.1332047592) }
283     if(r < 0) { r = 0 }
284     if(r > 255) { r = 255 }
285     
286     //green
287     def g
288     if (ct <= 66) { g = 99.4708025861 * Math.log(ct) - 161.1195681661 }
289     else { g = 288.1221695283 * ((ct - 60) ** -0.0755148492) }
290     if(g < 0) { g = 0 }
291     if(g > 255) { g = 255 }
292     
293     //blue
294     def b
295     if(ct >= 66) { b = 255 }
296     else if(ct <= 19) { b = 0 }
297     else { b = 138.5177312231 * Math.log(ct - 10) - 305.0447927307 }
298     if(b < 0) { b = 0 }
299     if(b > 255) { b = 255 }
300     
301     def rgb = [:]
302     rgb = [r: r as Integer, g: g as Integer, b: b as Integer]
303     rgb
304 }
305
306 def rgbToHex(rgb) {
307         return "#" + Integer.toHexString(rgb.r).padLeft(2,'0') + Integer.toHexString(rgb.g).padLeft(2,'0') + Integer.toHexString(rgb.b).padLeft(2,'0')
308 }
309
310 //http://www.rapidtables.com/convert/color/rgb-to-hsv.htm
311 def rgbToHSV(rgb) {
312         def h, s, v
313     
314     def r = rgb.r / 255
315     def g = rgb.g / 255
316     def b = rgb.b / 255
317     
318     def max = [r, g, b].max()
319     def min = [r, g, b].min()
320     
321     def delta = max - min
322        
323     //hue
324     if(delta == 0) { h = 0}
325     else if(max == r) { 
326         double dub = (g - b) / delta
327         h = 60 * (dub % 6)
328         }
329     else if(max == g) { h = 60 * (((b - r) / delta) + 2) }
330     else if(max == b) { h = 60 * (((r - g) / delta) + 4) }
331     
332     //saturation
333     if(max == 0) { s = 0 }
334     else { s = (delta / max) * 100 }
335     
336     //value
337     v = max * 100
338     
339     def degreesRange = (360 - 0)
340     def percentRange = (100 - 0)
341     
342     return [h: ((h * percentRange) / degreesRange) as Integer, s: ((s * percentRange) / degreesRange) as Integer, v: v as Integer]
343 }