Circadian Lighting v2 Node-Red Implementation for Philips Hue Lights

Hey all! After 3 years of successfully running my Circadian Lighting v1 implementation in Node-Red, I decided to rewrite the thing from scratch in a clean way that can be shared with and implemented by others with relative ease!

There are a lot of Circadian Lighting implementations out there, but what I struggled to find in the market was an implementation that effectively follows the sinusoidal pattern that Circadian Lights should, matches the movements of the sun each and every day of the year (no “inorganic” seasonal shifts - the pattern should reflect the days getting longer and shorter gradually throughout the year), and provides the necessary level of customization to tune the brightness and color temperature to my liking. I know - I’m picky :stuck_out_tongue_winking_eye:!

With that out of the way, allow me to introduce you to my approach!


CIRCADIAN LIGHTING APPROACH OVERVIEW

My Circadian Lighting implementation consists of 3 main components:

  • Set of input-numbers to control settings for brightness and color temperature at sunrise, solar noon, sunset, and solar midnight
  • Set of switches and input-booleans to control whether circadian lighting is turned on for a given room or grouping of rooms
  • Node-Red flow for calculating the circadian lighting curve regressions, applying them to the lights, and reporting data to my metrics database (I use InfluxDB, but you can use whatever you want)

In the below, I will go over each of these and how to set them up, but first requirements!


REQUIREMENTS

  • Node-Red Add-On for HA
  • HA Nodes for Node-Red: node-red-contrib-home-assistant-websocket
  • Credentials Node for Node-Red: node-red-contrib-credentials
  • A storage engine (e.g. InfluxDB) reporting your metrics
  • An account with a weather API service (I use a free Tomorrow.io account) that enables you to get sunrise and sunset times for yesterday, today and tomorrow and make at least 250 calls per day (the code makes around 150 in the worst-case, so 250 gives some padding and room for testing and debug work)

Note that the credentials node, above, is optional but strongly recommended, as it enables you to keep credentials out of your repositories.

Note also that you can choose to skip reporting data to a database if you want to, but I wouldn’t recommend it :wink:. Performance should always be traceable.


NODE-RED FLOW

Below is the entire Node-Red flow that I developed.

You can find the exported flow at the following gist (was too big to include in this post directly): https://gist.github.com/zkniebel/51e7725ff9705b57b20dce0f19b14244

It’s worth noting that I have intentionally left in several nodes that I use for debugging. Each of these nodes has been disabled. I prefer not to log excess messages when avoidable, and all data is currently being preserved and reported on via submission to InfluxDB.

There are six flows within this Circadian Lighting v2 flow in total (listed from top to bottom):

Regression Calculations:
At 4:00 AM each day, this part of the flow runs to update the regressions. First, the weather API key is used to build the weather URLs and retrieve the necessary weather data from your chosen weather API service. That data is then passed into the main function node for this implementation to update the regressions. The only weather data needed is the sunrise and sunset times for yesterday (historical), today (historical/forecast) and tomorrow (forecast). When the regression update function node runs, its output and all calculated data that may be relevant for reporting is stored in the flow context under the flow.circadian_values variable. Included in this is a computed function for returning the appropriate circadian values for any time from yesterday’s sunset through tomorrow’s sunrise.

Light Values Calculations:
Every 10 minutes, this part of the flow runs to update the lights with the latest circadian settings. Feel free to change the interval as you see fit - for my preferred settings 10 minutes is more than enough (and a bit more than enough is what I recommend :wink: More on that below). The flow first retrieves all of the switches that I use to control whether or not circadian lighting should be active in a given room, zone or group of rooms. If not, there’s no point in updating, so the loop stops. Otherwise, it continues on. The next stop is setting the timestamp to be used for the calculations (yes, I could’ve done this in the interval inject node as well, but I like having it explicitly called out as it’s critical to the flow and I find this to be more readable). Next, the flow retrieves the light entity IDs from all of the lights within zones where circadian lighting is currently on (having solid entity naming conventions helps here :wink:), and then stores that in the payload. Next up is the function node for getting the circadian values, which it does by first retrieving the flow.circadian_values object and calling the getCircadianValues function that was computed for it as part of the Regression Calculations. With those values in hand, the array of entity IDs for the lights currently on in a zone where circadian lighting is active is split and the lights are updated. Note that I have my circadian-enrolled lights running off a single Philips Hue Hub, and so I have to rate-limit the updates. Feel free to change this, and I’ll provide some additional suggestions, below, for how you might do so.

InfluxDB Data Reporting:
This flow simply extracts the data to be reported to InfluxDB as metrics from the flow.circadian_values object, and submits it over via API call. There are three pieces to this in total: one that extracts the current light values (enables graphing and monitoring of light values over time, along with visualization of current light value), the one that extracts the regression calculation information (enables monitoring of regression functions calculated, as well as solar event times), and the one that actually submits the data. Each of the extraction flows is called from the corresponding flow for calculating and updating regressions and values, and in turn both call the submission flow to send the data to InfluxDB. As stated previously, feel free to change InfluxDB to your storage mechanism of choice.

Test Flow for Checking Circadian Value Calculation Results:
I’m a big fan of experimentation, and this flow simply helps me observe data results from changes made to settings.

Circadian Switches State Change Subscriptions:
This flow listens for state changes in the switches that I use to activate or deactivate Circadian Lighting for a set of lights. If a state changes to “on”, the flow fires and calls for the relevant lights to be updated immediately (because who wants to wait 10 minutes after they flip a switch or say a command? :stuck_out_tongue_winking_eye:).

Circadian Settings State Change Subscriptions:
This flow listens for state changes in the settings entities that I use to control my the circadian values for the different solar events of the day. For example, if the brightness setting for solar noon is updated, then the flow fires and re-runs the Regressions Calculations to match.

**Helpful Tips for Your Flows
Regarding the Light Values Calculations trigger interval, personally, I don’t like the experience as much if I actually notice the lights change, and so my suggestion is that you choose an interval that is frequent enough that you can’t tell when the lights are actually updated (note that right before or after a sunrise or a sunset is the best time to check if you can observe the change with then naked eye, as this is when the values’ rate of change is at its highest). The closer your solar noon and solar midnight brightness and color temperature values are to your sunrise and sunset values, the lower the rate of change of your lights will be and the less frequent you will need to update. Generally, I recommend an interval from 5-15 minutes.

Regarding the rate limits for updating Philips Hue Bulbs from a single Hue Hub, there are known rate limits for how many lights the Hue Hub can update per second. I have a lot of lights enrolled in my circadian feature, and so I have to rate-limit the updates. Other options are available, such as using Hue Scenes to significantly cut down the number of updates. However, because I also have a number of routines and automations that also leverage and may turn on or off some bulbs in a room, zone or group of rooms or zones, I prefer to update each light manually. You can do a hybrid option as well, with custom conditional logic, but for me that was additional complexity that I don’t need. Like I said previously, I prefer to not be able to notice my lights updating and have configured my update interval to accommodate this, so waiting a few seconds for other lights to finish updating isn’t noticeable for me unless I just turned circadian lighting on for a large group of lights that must be rate limited. I’m okay with that as an edge case :wink:.


CIRCADIAN SETTINGS ENTITIES

The following are the entity configurations from my configuration.yaml that I use to control the circadian values for the different solar events of the day:

input_number:
  # Circadian Lighting
  circadian_lighting_color_temperature_sunrisesunset:
    name: Circadian Lighting Sunrise/Sunset Color Temperature (Kelvin)
    initial: 2500
    min: 1000
    max: 6000
    step: 1
  circadian_lighting_color_temperature_max:
    name: Circadian Lighting Max Color Temperature (Kelvin)
    initial: 5500
    min: 1000
    max: 6000
    step: 1
  circadian_lighting_color_temperature_min:
    name: Circadian Lighting Min Color Temperature (Kelvin)
    initial: 1000
    min: 1000
    max: 6000
    step: 1
  circadian_lighting_brightness_sunrisesunset:
    name: Circadian Lighting Sunrise/Sunset Brightness (%)
    initial: 60
    min: 1
    max: 100
    step: 1
  circadian_lighting_brightness_max:
    name: Circadian Lighting Max Brightness (%)
    initial: 100
    min: 1
    max: 100
    step: 1
  circadian_lighting_brightness_min:
    name: Circadian Lighting Min Brightness (%)
    initial: 30
    min: 1
    max: 100
    step: 1

The initial values configured for the entities are my preferred settings. Feel free to change them as you see fit.


CIRCADIAN SWITCHES ENTITIES

The following are the entities I’ve configured to act as my “circadian switches” in my configuration.yaml file:

input_boolean:
  # Circadian Lighting
  circadian_lighting_downstairs:
    name: Circadian Lighting Downstairs
    initial: off
  circadian_lighting_living_room:
    name: Circadian Lighting in the Living Room
    initial: off
  circadian_lighting_dining_room:
    name: Circadian Lighting in the Dining Room
    initial: off
  circadian_lighting_kitchen:
    name: Circadian Lighting in the Kitchen
    initial: off
  circadian_lighting_bar:
    name: Circadian Lighting in the Bar
    initial: off
  circadian_lighting_upstairs_hallway:
    name: Circadian Lighting in the Upstairs Hallway
    initial: off

switch:
  platform: template
  switches:
    # Circadian Lighting
    circadian_lighting_downstairs:
      friendly_name: Circadian Lighting Downstairs
      value_template: "{{ is_state('input_boolean.circadian_lighting_downstairs', 'on') }}"
      turn_on:
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_downstairs
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_living_room
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_dining_room
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_kitchen
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_bar
      turn_off:
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_downstairs 
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_living_room 
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_dining_room 
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_kitchen 
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_bar
      icon_template: >-
        {% if is_state('switch.circadian_lighting_downstairs', 'on') %}
          mdi:toggle-switch-outline
        {% else %}
          mdi:toggle-switch-off
        {% endif %}
    circadian_lighting_living_room:
      friendly_name: Circadian Lighting in the Living Room
      value_template: "{{ is_state('input_boolean.circadian_lighting_living_room', 'on') }}"
      turn_on:
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_living_room
      turn_off:
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_living_room 
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_downstairs 
      icon_template: >-
        {% if is_state('switch.circadian_lighting_living_room', 'on') %}
          mdi:toggle-switch-outline
        {% else %}
          mdi:toggle-switch-off
        {% endif %}
    circadian_lighting_dining_room:
      friendly_name: Circadian Lighting in the Dining Room
      value_template: "{{ is_state('input_boolean.circadian_lighting_dining_room', 'on') }}"
      turn_on:
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_dining_room
      turn_off:
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_dining_room 
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_downstairs 
      icon_template: >-
        {% if is_state('switch.circadian_lighting_dining_room', 'on') %}
          mdi:toggle-switch-outline
        {% else %}
          mdi:toggle-switch-off
        {% endif %}
    circadian_lighting_kitchen:
      friendly_name: Circadian Lighting in the Kitchen
      value_template: "{{ is_state('input_boolean.circadian_lighting_kitchen', 'on') }}"
      turn_on:
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_kitchen
      turn_off:
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_kitchen 
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_downstairs 
      icon_template: >-
        {% if is_state('switch.circadian_lighting_kitchen', 'on') %}
          mdi:toggle-switch-outline
        {% else %}
          mdi:toggle-switch-off
        {% endif %}
    circadian_lighting_bar:
      friendly_name: Circadian Lighting in the Bar
      value_template: "{{ is_state('input_boolean.circadian_lighting_bar', 'on') }}"
      turn_on:
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_bar
      turn_off:
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_bar
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_downstairs  
      icon_template: >-
        {% if is_state('switch.circadian_lighting_bar', 'on') %}
          mdi:toggle-switch-outline
        {% else %}
          mdi:toggle-switch-off
        {% endif %}
    circadian_lighting_upstairs_hallway:
      friendly_name: Circadian Lighting in the Upstairs Hallway
      value_template: "{{ is_state('input_boolean.circadian_lighting_upstairs_hallway', 'on') }}"
      turn_on:
        - service: input_boolean.turn_on
          entity_id: input_boolean.circadian_lighting_upstairs_hallway
      turn_off:
        - service: input_boolean.turn_off
          entity_id: input_boolean.circadian_lighting_upstairs_hallway
      icon_template: >-
        {% if is_state('switch.circadian_lighting_upstairs_hallway', 'on') %}
          mdi:toggle-switch-outline
        {% else %}
          mdi:toggle-switch-off
        {% endif %}

Note that the first switch, switch.circadian_lighting_downstairs, is an example of a group of multiple switches. If one of other downstairs switches turns off, switch.circadian_lighting_downstairs will turn off too. It it’s turned on, it will turn on the switches for all of the other downstairs zones.


GRAFANA DASHBOARD

Below is a screenshot of my main Grafana dashboard for Circadian Lighting. Note that more metrics are tracked for my implementation than I show on the dashboard - the few missing metrics are ones that I primarily track so that I have them for troubleshooting should the need arise. With that said, the vast majority of my troubleshooting needs are covered by the metrics in the dashboard, including logs of the settings configured, regressions calculated, current values at each point in time, value graphs for color temperature and brightness, time coordinates (hour of the day in decimal form) of each sun event, timestamp of each sun event, and friendly date and time of each sun event.


FREQUENTLY ASKED QUESTIONS

I have received a lot of similar questions about different pieces of this implementation from friends who have used it. I’ve added the most common, along with some other helpful answers below for you:

Why don’t you have separate settings for sunrise and sunset brightness and color temperature?
I prefer for my circadian lighting functionality to follow as close to a perfect sinusoidal curve (sine wave) as possible, as this is much of where the benefit of circadian lighting is supposed to come from. For this to be the case, the color temperature and brightness at sunrise and sunset should be the same. It is for this reason that my brightness and color temperature settings each share a single entity in HA, to keep the values consistent. However, that doesn’t mean that you have to do it that way for your implementation, if you don’t want to :wink:. It’s for that reason that I wrote my code in a way that enables you to simply set a different entity ID for each of those settings if you so choose.

Clearly the ideal regression is a sine wave. Why not use a sinusoidal regression instead of three second-order polynomial (parabola) regressions?
After extensive research, trial and error, I discovered and validated for myself that performing an actual sinusoidal regression was by far inferior to calculating and combining three different parabolas for the different periods of the day, with intersects around sunrise and sunset times. This does mean that there can be a change in the derivative (slope, describing the rate of change of the values) at these points in the day that is visible in the data should you graph it (I use Grafana). Unless your settings are rather extreme, you this change should not be perceptible from your lights. What I discovered from my own testing was that a sinusoidal regression ended up being too unreliable, and research confirmed that the parabola approach was the accepted practice. In my Circadian Lighting v1 implementation, I managed to create some smoothing features to keep the lines consistent through additional regressions, but they were far more complicated than they should’ve been and when I surveyed my family over a 2 week time period no one, including myself, could tell the difference. As such, my approach simply switches from one parabola to another at sunrise and sunset times.

I am noticing some variance in the data around sunrise/sunset times, when the logic switches from one parabola to another. Why is that? Is it a problem?
This is completely normal. Switching from parabola to parabola at sunrise and sunset times is technically a naïve solution that could be fairly easily improved (I even include the function needed to improve it, but I chose not to use in my v2 implementation until I have enough data to prove it is needed - something I am a bit of a stickler for :wink:). The best way would be to calculate the point of x-intersect of the parabolas and use that x-value as the time to switch from one to the other. Bear in mind that regressions are an inexact line of best-fit - basically a fancy average. As such, while a 3-coordinate subset of the coordinates for sunrise, solar noon, sunset and solar midnight are passed in to calculate each regression, the returned curve may not actually (and usually doesn’t) perfectly pass through supplied coordinates - “close but not perfect” is really the name of the game here :stuck_out_tongue_winking_eye:. From my testing, I’ve observed so far that for the different settings that I’ve tried, “close” is close enough to not be noticeable. With that being said, however, it should be noted that the curves you see each day may vary slightly - noticeably from a graph of the data but imperceptible from the lights - and may not actually hit the exact brightnesses that you configure in your settings.

Sometimes the actual value is a little higher and other times it’s a little lower than what I set in my Circadian Settings. Why aren’t the values that I set being respected?
If you set 1 and 100 as your solar midnight (minimum) and solar noon (maximum) brightnesses, respectively, or the corresponding 1000 and 6000 for Kelvin color temperature, it should be noted that the variability of the regressions means that you could produce numbers that are out of the supported ranges for your lights. In order to protect against this, my implementation ensures 1 and 100 as well as 1000 and 6000 are the respective minimums and maximums for brightness and color temperature in Kelvin that get returned. However, I’m personally not a fan of flat-lining what should be a curve, and so I chose to use the aforementioned values as the minimums and maximums and not the configured values. In other words, if you set 20% as the minimum brightness at solar midnight, it’s possible that a regression ends up returning a brightness of 18% one day and 21% on the next. It was my preference to allow this behavior vs forcing the 18% up to 20% as an enforced minimum. Feel free to change this in your implementation, if you so choose.

Why is the minimum brightness 1% instead of 0% in the Circadian Settings?
As described in the answer to the previous question, the minimum enforced brightness percentage is 1%. The reason that 0% is not supported is because 0% turns off your lights and if the lights are off then they will not continue to receive Circadian Lighting updates - I don’t personally like my lights turning on and off all by themselves, unless we’re talking room presence detection of something of that nature. As such, the 1% minimum is enforced to ensure that your lights stay on and continue to receive updates. With that said, 1% brightness is incredibly dim and unless the solar noon brightness is also low your wave height will be very high and the amplitude relatively small (narrow), meaning that the derivative (slope describing the rate of change of your values) will be very high (steep) throughout your curve. This means that values would likely be so substantial that they’d be noticeable from the lights - something that I prefer to avoid.

I regularly see a strange blip in the data around the time that the circadian regressions calculate each day. Why is that and can I fix it?
The blip is totally normal. It’s important to understand that the greatest challenge with this or any accurate circadian lighting implementation is actually with handling expiration and recalculation. Keep in mind that the weather service you use to get the sunrise and sunset event times for the current and next day are actually just forecasts until those events have already taken place. That means that as you get closer to one of those events, the data may change to be more accurate, and as soon as one of those events actually occurs, the data you get from that service may change to reflect actuals. In turn, so too should the regressions that you calculated change. One might expect that the change should be so small that it’s relatively unnoticeable, even in the data, but remember that the regressions are actually just estimates, so in reality they already have variance and thus some days vary more and other days vary less than a more exact approach might. The way that this is handled in my approach is by recalculating the regressions at 4:00 AM every day, before I or anyone in my family generally wakes up. As a result, there is, occasionally, a totally normal blip in the data at this time of day. There are three ways to minimize this blip, if you so choose, but each adds additional complexity that I felt made the juice not worth the squeeze in my case :lemon:: (1) recalculate at sunrise, when the coordinates should be close to the known coordinates for that time; (2) recalculate before sunset, but ignore the newly calculated “last night curve” and continue ti use the existing until you switch to the “today curve”; (3) or mix the two by recalculating around midnight, but ignore the newly calculated “last night curve” and instead use an x-intercept calculation to decide when to switch to the “today curve”. If you go with either the second or third option, it is suggested that you also use run a “min” on the y-value calculations from your curves against the value in your settings to ensure the brightness/color temperature doesn’t pass the value on the next curve, resulting in brightness/color temperature going down when it should be going up (I.e. because it’s an estimate and you don’t update in real time, you don’t the “last night curve” value to pass the “today curve value” for the next update, which would result in your lights getting dimmer when they should be getting brighter). For me, I’m fine with the blip occurring at 4:00 AM, when I and my family are comfortably asleep and not looking at the lights :stuck_out_tongue_winking_eye:.

Uh…why the custom code for submitting data to InfluxDB when there are plenty of nodes that can do that?
I chose to create my own custom function for submitting metrics to InfluxDB. There were a number of reasons for this, including ease of switching between storage engines, bulk metric submission and beyond. This was a design decision based on the culmination of various factors. Feel free to change to whatever your preferred nodes are if you so choose, or go ahead and make your own if you prefer :wink:.

2 Likes