Circadian Lighting is syncing your color lights with the local perceived color of the sky throughout the day. This gives your space a natural open feel by using cooler whites during the midday and warmer tints near twilight and dawn. This improves mood, boosts productivity, alertness, and helps restore normal sleep patterns. This is a huge plus for environments lacking natural light.

In this post we are going to explore how I accomplished this using Home Assistant, Node-RED, and some smart color bulbs.

Requirements

  • Home Assistant
  • Node-RED
  • node-red-contrib-home-assistant-websocket installed in Node-RED and setup so it can communicate with Home Assistant.
  • node-red-contrib-sun-position installed in Node-RED
  • Some sort of color bulb(s) setup in Home Assistant (Hue and MagicLight are options I have tried)

I prefer using Hue color bulbs because they look really good, are really bright, and I already am heavily invested in Hue (being a renter it is the best choice for me). I have tried some cheap Magiclight bulbs and they work okay but I still prefer Hue (especially for this setup). If you have any type of bulb you recommend for this please feel free to share below in the comments.

Setup the automation

Rundown

You know, a rundown.

First we need a flow in Node-RED that will get the current sun position, get the solar noon sun position (time when sun is at it's highest point), and then use that to calculate the percent of the sun distance to solar noon. This will give us 0% if the sun is down and 100% if the sun is at solar noon. We map this range from 2500~5500 kelvin (0% being 2500k and solar noon 100% being 5500k). We run this every 30 seconds and store all of the resulting data in flow variables so the rest of the flows on this Node-RED tab can access it whenever they need it. If the circadian lighting switch is on in Home Assistant it will then send a function to update the color on all the on bulbs.

Node-RED Flow

Circadian Lighting using sun position in Node-RED
Yeah, she is a big one.
[{"id":"ee20edfa.32eb9","type":"api-call-service","z":"ff717302.0c688","name":"Living Room Light ON","server":"233a9c63.e2baf4","service_domain":"light","service":"turn_on","data":"{ \"entity_id\": \"light.living_room\" }","mergecontext":"","x":1860,"y":4120,"wires":[[]]},{"id":"e862fa2e.60e898","type":"api-current-state","z":"ff717302.0c688","name":"","server":"233a9c63.e2baf4","version":1,"outputs":2,"halt_if":"on","halt_if_type":"str","halt_if_compare":"is","override_topic":true,"entity_id":"light.living_room","state_type":"str","state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","blockInputOverrides":false,"x":1410,"y":4120,"wires":[["3abebfc7.7f5fe"],[]]},{"id":"73535ac9.065914","type":"api-call-service","z":"ff717302.0c688","name":"Office Light ON","server":"233a9c63.e2baf4","service_domain":"light","service":"turn_on","data":"{ \"entity_id\": \"light.office\" }","mergecontext":"","x":1840,"y":4160,"wires":[[]]},{"id":"71018d1.02d5774","type":"api-current-state","z":"ff717302.0c688","name":"","server":"233a9c63.e2baf4","version":"1","outputs":2,"halt_if":"on","halt_if_type":"str","halt_if_compare":"is","override_topic":true,"entity_id":"light.office","state_type":"str","state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","blockInputOverrides":false,"x":1430,"y":4160,"wires":[["74e4cb9b.63b9a4"],[]]},{"id":"a6d7e22b.24c2d","type":"api-call-service","z":"ff717302.0c688","name":"light.flux_bulb_2 Light ON","server":"233a9c63.e2baf4","service_domain":"light","service":"turn_on","data":"{\"entity_id\":\"light.office_desk_lamp\"}","mergecontext":"newMsg","output_location":"payload","output_location_type":"msg","mustacheAltTags":false,"x":1870,"y":4200,"wires":[[]]},{"id":"5ef3e681.e84bf8","type":"api-current-state","z":"ff717302.0c688","name":"","server":"233a9c63.e2baf4","version":1,"outputs":2,"halt_if":"on","halt_if_type":"str","halt_if_compare":"is","override_topic":true,"entity_id":"light.office_desk_lamp","state_type":"str","state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","blockInputOverrides":false,"x":1390,"y":4200,"wires":[["b51167a5.8e14e8"],[]]},{"id":"755a2a3e.1e76a4","type":"sun-position","z":"ff717302.0c688","name":"","positionConfig":"c94fc0b4.51c99","rules":[],"onlyOnChange":"true","topic":"","outputs":1,"start":"","startType":"none","startOffset":0,"startOffsetType":"none","startOffsetMultiplier":60000,"end":"","endType":"none","endOffset":0,"endOffsetType":"none","endOffsetMultiplier":60000,"x":490,"y":4040,"wires":[["22cd67ba.48dfd8"]]},{"id":"82cc9d8b.593c1","type":"http in","z":"ff717302.0c688","name":"","url":"/manual_circadian_poll","method":"get","upload":false,"swaggerDoc":"","x":260,"y":4040,"wires":[["755a2a3e.1e76a4"]]},{"id":"898664fc.97cad8","type":"http response","z":"ff717302.0c688","name":"","statusCode":"200","headers":{},"x":2340,"y":4040,"wires":[]},{"id":"80e0eef0.7cc1e","type":"sun-position","z":"ff717302.0c688","name":"sun-position solar noon","positionConfig":"c94fc0b4.51c99","rules":[],"onlyOnChange":"true","topic":"","outputs":1,"start":"","startType":"none","startOffset":0,"startOffsetType":"none","startOffsetMultiplier":60000,"end":"","endType":"none","endOffset":0,"endOffsetType":"none","endOffsetMultiplier":60000,"x":870,"y":4040,"wires":[["db2d15f3.d4d7c8"]]},{"id":"22cd67ba.48dfd8","type":"change","z":"ff717302.0c688","name":"","rules":[{"t":"set","p":"sun_position_current","pt":"flow","to":"payload","tot":"msg"},{"t":"set","p":"time","pt":"msg","to":"payload.times.solarNoon.value","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":660,"y":4040,"wires":[["80e0eef0.7cc1e"]]},{"id":"db2d15f3.d4d7c8","type":"change","z":"ff717302.0c688","name":"","rules":[{"t":"set","p":"sun_position_solarnoon","pt":"flow","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":1130,"y":4040,"wires":[["ac0934ec.4eedb8"]]},{"id":"ac0934ec.4eedb8","type":"function","z":"ff717302.0c688","name":"calculate sun percent","func":"// distance of sun altitude from solar noon altitude percent (100% being at solarnoon 0% being completely down)\n\nvar sun_position_solarnoon = flow.get('sun_position_solarnoon');\nvar sun_position = flow.get('sun_position_current');\n\nif (sun_position.altitude > 0) {\n\tmsg.payload.percent = (sun_position.altitude / sun_position_solarnoon.altitude) * 100;\n    msg.payload.kelvin = (sun_position.altitude / sun_position_solarnoon.altitude) * 100;\n    \n} else {\n\tmsg.payload.percent = 0;\n\tmsg.payload.kelvin = 0;\n}\n\nreturn msg;","outputs":1,"noerr":0,"x":1380,"y":4040,"wires":[["8a2e66d0.67f698"]]},{"id":"8a2e66d0.67f698","type":"range","z":"ff717302.0c688","minin":"0","maxin":"100","minout":"2500","maxout":"5500","action":"clamp","round":true,"property":"payload.kelvin","name":"percent to kelvin range","x":1600,"y":4040,"wires":[["1f409bc7.59e2d4"]]},{"id":"5be1c304.89ae6c","type":"change","z":"ff717302.0c688","name":"","rules":[{"t":"set","p":"circadian_kelvin","pt":"flow","to":"payload.kelvin","tot":"msg"},{"t":"set","p":"circadian_percent","pt":"flow","to":"payload.percent","tot":"msg"},{"t":"set","p":"circadian_rgb","pt":"flow","to":"payload.rgb","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":1980,"y":4040,"wires":[["942a231d.c152f","db30c0b4.6e91f"]]},{"id":"dd0aa960.c6c338","type":"inject","z":"ff717302.0c688","name":"every 30 seconds","topic":"","payload":"","payloadType":"date","repeat":"30","crontab":"","once":true,"onceDelay":"0.1","x":290,"y":4080,"wires":[["755a2a3e.1e76a4"]]},{"id":"942a231d.c152f","type":"switch","z":"ff717302.0c688","name":"is HTTP request","property":"res","propertyType":"msg","rules":[{"t":"istype","v":"object","vt":"object"}],"checkall":"true","repair":false,"outputs":1,"x":2180,"y":4040,"wires":[["898664fc.97cad8"]]},{"id":"1f409bc7.59e2d4","type":"function","z":"ff717302.0c688","name":"Kelvin to RGB","func":"// these functions were converted from Home Assistant's python version of it.\n// https://github.com/home-assistant/home-assistant/blob/05ecc5a1355c7af11b5d310470a6171528dc7a2b/homeassistant/util/color.py\n\nvar color_temperature_to_rgb = function(color_temperature_kelvin) {\n    /*\n    Return an RGB color from a color temperature in Kelvin.\n    This is a rough approximation based on the formula provided by T. Helland\n    http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/\n    */\n    // range check\n    if(color_temperature_kelvin < 1000) {\n        color_temperature_kelvin = 1000;\n    } else if(color_temperature_kelvin > 40000) {\n        color_temperature_kelvin = 40000;\n    }\n\n    tmp_internal = color_temperature_kelvin / 100.0;\n\n    red = _get_red(tmp_internal);\n    green = _get_green(tmp_internal);\n    blue = _get_blue(tmp_internal);\n\n    return [red, green, blue];\n};\n\nvar _get_red = function(temperature) {\n    // Get the red component of the temperature in RGB space.\n    if(temperature <= 66){\n        return 255;\n    }\n    tmp_red = 329.698727446 * Math.pow(temperature - 60, -0.1332047592);\n    return _bound(tmp_red);\n}\n\nvar _get_green = function(temperature) {\n    // Get the green component of the given color temp in RGB space.\n    if(temperature <= 66){\n        green = 99.4708025861 * Math.log(temperature) - 161.1195681661;\n    } else {\n        green = 288.1221695283 * Math.pow(temperature - 60, -0.0755148492);\n    }\n    return _bound(green);\n}\n\nvar _get_blue = function(temperature) {\n    // Get the blue component of the given color temperature in RGB space.\n    if(temperature >= 66){\n        return 255;\n    } else if(temperature <= 19){\n        return 0;\n    }\n    blue = 138.5177312231 * Math.log(temperature - 10) - 305.0447927307;\n    return _bound(blue);\n}\n\nvar _bound = function(color_component, minimum=0, maximum=255) {\n    /*\n    Bound the given color component value between the given min and max values.\n    The minimum and maximum values will be included in the valid output.\n    i.e. Given a color_component of 0 and a minimum of 10, the returned value\n    will be 10.\n    */\n    color_component_out = Math.max(color_component, minimum);\n    return Math.min(color_component_out, maximum);\n}\n\nmsg.payload.rgb = color_temperature_to_rgb(msg.payload.kelvin);\n\nreturn msg;","outputs":1,"noerr":0,"x":1800,"y":4040,"wires":[["5be1c304.89ae6c"]]},{"id":"b51167a5.8e14e8","type":"function","z":"ff717302.0c688","name":"kelvin to rgb","func":"return {\n    payload: {\n        \"data\": {\n            \"rgb_color\": flow.get('circadian_rgb')\n        }\n    }\n};","outputs":1,"noerr":0,"x":1650,"y":4200,"wires":[["a6d7e22b.24c2d"]]},{"id":"74e4cb9b.63b9a4","type":"function","z":"ff717302.0c688","name":"Color Temp Kelvin","func":"var newMsg =  {\n    payload: {\n        \"data\": {\n            \"kelvin\": flow.get('circadian_kelvin')\n        }\n    }\n}\n\nreturn newMsg;","outputs":1,"noerr":0,"x":1650,"y":4160,"wires":[["73535ac9.065914"]]},{"id":"3abebfc7.7f5fe","type":"function","z":"ff717302.0c688","name":"Color Temp Kelvin","func":"var newMsg =  {\n    payload: {\n        \"data\": {\n            \"kelvin\": flow.get('circadian_kelvin')\n        }\n    }\n}\n\nreturn newMsg;","outputs":1,"noerr":0,"x":1650,"y":4120,"wires":[["ee20edfa.32eb9"]]},{"id":"a0dc69f6.aad008","type":"api-call-service","z":"ff717302.0c688","name":"light.flux_bulb_2 Light ON","server":"233a9c63.e2baf4","service_domain":"light","service":"turn_on","data":"{\"entity_id\":\"light.living_room_couch_lamp\"}","mergecontext":"newMsg","output_location":"payload","output_location_type":"msg","mustacheAltTags":false,"x":1870,"y":4240,"wires":[[]]},{"id":"e3eadd8.3644b2","type":"api-current-state","z":"ff717302.0c688","name":"","server":"233a9c63.e2baf4","version":1,"outputs":2,"halt_if":"on","halt_if_type":"str","halt_if_compare":"is","override_topic":true,"entity_id":"light.living_room_couch_lamp","state_type":"str","state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","blockInputOverrides":false,"x":1350,"y":4240,"wires":[["65d04596.ffdd3c"],[]]},{"id":"65d04596.ffdd3c","type":"function","z":"ff717302.0c688","name":"kelvin to rgb","func":"return {\n    payload: {\n        \"data\": {\n            \"rgb_color\": flow.get('circadian_rgb')\n        }\n    }\n};","outputs":1,"noerr":0,"x":1650,"y":4240,"wires":[["a0dc69f6.aad008"]]},{"id":"db30c0b4.6e91f","type":"api-current-state","z":"ff717302.0c688","name":"","server":"233a9c63.e2baf4","version":1,"outputs":2,"halt_if":"on","halt_if_type":"str","halt_if_compare":"is","override_topic":true,"entity_id":"input_boolean.circadian_lighting","state_type":"str","state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","blockInputOverrides":false,"x":960,"y":4120,"wires":[["e862fa2e.60e898","71018d1.02d5774","5ef3e681.e84bf8","e3eadd8.3644b2"],[]]},{"id":"b935225.badb0e","type":"server-state-changed","z":"ff717302.0c688","name":"","server":"233a9c63.e2baf4","version":1,"entityidfilter":"input_boolean.circadian_lighting","entityidfiltertype":"exact","outputinitially":false,"state_type":"str","haltifstate":"on","halt_if_type":"str","halt_if_compare":"is","outputs":2,"output_only_on_state_change":false,"x":300,"y":4120,"wires":[["755a2a3e.1e76a4"],[]]},{"id":"233a9c63.e2baf4","type":"server","z":"","name":"Home Assistant","legacy":true,"hassio":false,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true},{"id":"c94fc0b4.51c99","type":"position-config","z":"","name":"","isValide":"true","longitude":"0","latitude":"0","angleType":"deg","timeZoneOffset":"99","timeZoneDST":"0","stateTimeFormat":"3","stateDateFormat":"12"}]

Import this flow. You will need to setup the sun-position node (there is two of them) by adding a new config with the latitude and longitude of the location you want tracked (you could sync your lights with some random location if you really wanted to).

You will also need to change the part of the flow that updates all the lights to match your own setup. I left mine in there just as an example to show you how I am doing it. Two of the bulbs are Hue color bulbs that can be set in Kelvin but the last two bulbs I have setup are Lifx/Magiclight bulbs that must be set using RGB. If you have issues with bulbs not updating later you will need to check which one of these your bulb supports.

Home Assistant Config

Once that is setup we need to add an input_boolean to Home Assistant so we can enable/disable this automation. That way if we want to change the colors of the bulbs we can without this flow changing it back to the circadian color. It will also update the bulbs immediately when the switch is turned back on. Here is what we need to add to our Home Assistant's configuration.yaml file:

input_boolean:
  circadian_lighting:
    name: Circadian Lighting
    icon: mdi:alarm-light

Update existing light flows

And lastly we need to update any existing automations for these lights to use the new color values when turning on the bulb. I use my office and living room bulbs with motion sensors so I had to update the motion sensor flow to set the circadian color when turning the light on. Here is an example of how to accomplish this when turning on a bulb from a flow:

[{"id":"9736af23.353b8","type":"api-call-service","z":"ff717302.0c688","name":"Living Room Light ON","server":"233a9c63.e2baf4","service_domain":"light","service":"turn_on","data":"{\"entity_id\":\"light.living_room\"}","mergecontext":"","output_location":"payload","output_location_type":"msg","x":2340,"y":300,"wires":[[]]},{"id":"8f9831fb.0c6e8","type":"function","z":"ff717302.0c688","name":"format kelvin","func":"if(msg.payload == 'on') {\n    var newMsg =  {\n        payload: {\n            \"data\": {\n                \"kelvin\": flow.get('circadian_kelvin')\n            }\n        }\n    }\n    \n    return newMsg;\n}\n\nreturn msg;\n","outputs":1,"noerr":0,"x":2150,"y":300,"wires":[["9736af23.353b8"]]},{"id":"f79821b.856bbe","type":"api-current-state","z":"ff717302.0c688","name":"","server":"233a9c63.e2baf4","version":1,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","override_topic":true,"entity_id":"input_boolean.circadian_lighting","state_type":"str","state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","blockInputOverrides":false,"x":1880,"y":300,"wires":[["8f9831fb.0c6e8"]]},{"id":"233a9c63.e2baf4","type":"server","z":"","name":"Home Assistant","legacy":true,"hassio":false,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true}]

This will only set the light color if the input_boolean we configured above is turned on otherwise it just turns on the light like normal without setting a color. If you are using RGB bulbs that don't support kelvin just copy my "kelvin to rgb" function block from the previous flow and swap it out in this flow.

Testing time

Now it's time to test it out! I included the ability to trigger this flow manually by hitting your Node-RED instance at path /manual_circadian_poll or you can just wait for the interval to trigger it. Now your lights should be using circadian lighting. Congratulations! (If things aren't working and you can't figure it out feel free to leave a comment below and I will help you figure it out)

Going further

You could go a step further and make an input_boolean for every room you are doing circadian lighting in. This way you could disable it in one room without disabling the entire house. I will be doing an update for this soon to my own setup.

I use this automation with motion sensors and always powered on bulbs. This way everything is handled in Node-RED for the bulb already and there is little to no latency in color changes. If you are turning bulbs on manually from the wall switch and not leaving them on all the time you may need to build flows around the on event from your bulbs to update their color once they become available. I switched my office to use motion bulbs after doing circadian lighting just because the color value wouldn't update the bulb until it was on for a few seconds (due to Home Assistant's 5 second poll time for Hue). You can get around this by using the MagicHue libarary in Node-RED to poll for changes every second just like I did in my post Fixing slow Hue motion sensors in Home Assistant using Node-RED.

Conclusion

Now everything should be working for your setup. I think Hue bulbs provide the best results but I have only tested Hue color bulbs vs Magiclight/Lifx bulbs. If you find any changes that works really well please feel free to post a comment. I would love to hear about any sort of changes people make to this setup.

I use this in my office and it makes a huge difference. My office has no natural light so having circadian lighting really helps make the space feel more lively. It's been really nice natural way to know if the sun is still up or not. I also feel like I am sleeping much better and have more productivity while working. I work half of my work days from home so this affects quite a bit of my day.

It is also very subtle. Whenever I have guests staying over at my place they don't even notice that the lights are changing color throughout the day. The lights get updated every 30 seconds so the changes are very small.

I am working on trying to make the flow shorter and easier to work with. There are two ways I am trying to accomplish this:

  • Trying to get the maintainer of node-red-contrib-sun-position to add a node that does everything in one call instead of two.
  • Get the ability to submit latitude and longitude to the sun-position nodes instead of only taking it from the config. This way we can just pass the information from Home Assistant to the node which simplifies install and gives the user less locations they have to keep latitude/longitude updated in.

I have opened this ticket to try and get these done and will update this article once progress has been made:
https://github.com/rdmtc/node-red-contrib-sun-position/issues/52

This is my new favorite automation and I hope you all like it as much as I do. If you liked this post, have a question, or just want to leave feedback please feel leave a comment. I love to hear back from my readers.