I thought this was worth looking at as an exercise.
There are I think three questions to answer
- how to get the dawn start and end time
- how to calculate the required brightness and colour at each action call to change the lamp
- how to run the lamp updates
Sun times: There are a number of ways to deal with each question. Since Home Assistant has the sun integration, the next dawn and sunrise times are available. There are also several contrib nodes available for sun position. However, I found a nice sun times module that does the work, and it is available as an npm package so can be loaded into Node-RED.
Using a function node, suncalc can be imported in the settings tab (as SunCalc or whatever you want) and then used in JavaScript. A simple call, with a date object for today, and latitude & longitude, will give an object with all the sun times as JS date objects. A nice extra feature of this module is the ability to add additional sun-time points by elevation. Civil dawn starts at -6 degrees, but nautical dawn is already ‘light’ from -12 degrees, so easy enough to set your own dawn at, say -8 degrees.
A little bit of code extracts the Unix millisecond time for ‘my dawn’ start and for sunrise start, and then the difference between them in minutes to use later. This can be generated anytime between midnight and dawn every morning, so going for 04:00 avoids being too early and hitting DST time changes, or too late and missing dawn.
This can be stored in context, but I have chosen to push the dawn start time to a Home Assistant entity sensor, so that I can also save the minutes (and everything else) in attributes, and then use this as a trigger for the ‘Time’ node.
Calculations: Sun (sky) brightness at dawn, from roughly the start of nautical dawn to full sunrise, can be approximated to a linear increase, so I would be happy to calculate this based on the minutes progression from the start of dawn to sunrise, as 0 to 100 percent of lamp brightness. I would also start by doing the same for colour change, as this makes it very easy to compute both values given the x-minutes into the dawn-to-sunrise time period (which I already have). I find JSONata a bit easier to code, so I have used this to generate an array, one entry per minute for the sunrise period, with linear progression for both brightness and colour between given start-end values.
Lamp action calls: I try to avoid loops in Node-RED since they can be very problematic. Node-RED flow programming focuses more on breaking a loop / iteration into parts, and it is very simple to split an array into individual messages. Then all it takes is a delay node set to rate-limit to one message per minute.
The WebSocket nodes ‘Time’ node (not the Time Entity node) is designed to trigger a new flow based on a future time taken from any other entity (state or property) so I can easily use my entity sensor created to hold the next dawn start time. Although this node is documented to accept Unix milliseconds, it does not appear to like them, so a bit of JSONata turns the Unix milliseconds into a timestamp string, which the Time node does accept.
When the Time node triggers at the start of (my) dawn, the period minutes in the attributes can be used to generate an array of minute changes, which can then be rate-limited to one per minute and used to update the lamp every minute giving a smooth transition.
There are, of course, many other ways to do this…
[{"id":"68503e970850910b","type":"function","z":"70e9a03d9a03cec2","name":"Get dawn and sunrise","func":"// use the following to add my own dawn between nautical and civil //\nSunCalc.addTime(-8, \"dawnchorus\", \"darkenough\");\n\n// get suntimes for my location for V today V Lat V Long //\nconst suntimes = SunCalc.getTimes(new Date(), 51.5, -0.1);\n\n// pick out my dawn start time, period to sunrise, timezone offset //\nlet dawntime = {\"dawn\": suntimes.dawnchorus.getTime(),\n \"rise\": suntimes.sunrise.getTime(),\n \"start\": suntimes.dawn.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}),\n \"offs\": new Date().getTimezoneOffset()};\n// add in dawn to sunrise time in minutes //\ndawntime.mins = Math.round((dawntime.rise - dawntime.dawn)/60000);\n\nmsg.payload = dawntime;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"SunCalc","module":"suncalc"}],"x":340,"y":100,"wires":[["433c562d8fabfe91"]]},{"id":"813d6848ff91a2c1","type":"inject","z":"70e9a03d9a03cec2","name":"Setup for dawn","props":[],"repeat":"","crontab":"00 04 * * *","once":false,"onceDelay":0.1,"topic":"","x":140,"y":100,"wires":[["68503e970850910b"]]},{"id":"4632ee250586c413","type":"debug","z":"70e9a03d9a03cec2","name":"debug 2","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1180,"y":180,"wires":[]},{"id":"2dc4627e3e9f8fbb","type":"change","z":"70e9a03d9a03cec2","name":"Calc brightness & colour","rules":[{"t":"set","p":"payload","pt":"msg","to":"(\t $mins:=data.attributes.minutes;\t $color:=[2200, 4000];\t $bright:=[0, 100];\t \t $cdif:=$color[1]-$color[0];\t $bdif:=$bright[1]-$bright[0];\t\t [0..$mins].(\t $inc:=$/$mins;\t {\"minute\": $,\t \"brightness\": $ceil($bright[0] + $bdif*$inc),\t \"colorTemp\": $round($color[0] + $cdif*$inc,-1)}\t )\t)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":630,"y":180,"wires":[["9613d4edbc3b659a"]]},{"id":"9613d4edbc3b659a","type":"split","z":"70e9a03d9a03cec2","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"","property":"payload","x":830,"y":180,"wires":[["572df05291613702"]]},{"id":"572df05291613702","type":"delay","z":"70e9a03d9a03cec2","name":"","pauseType":"rate","timeout":"5","timeoutUnits":"seconds","rate":"1","nbRateUnits":"","rateUnits":"minute","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":1010,"y":180,"wires":[["4632ee250586c413"]]},{"id":"4e275837cbecee7c","type":"inject","z":"70e9a03d9a03cec2","name":"Reset","props":[{"p":"reset","v":"true","vt":"bool"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":1010,"y":140,"wires":[["572df05291613702"]]},{"id":"dcf2ab61317a88a4","type":"ha-time","z":"70e9a03d9a03cec2","name":"Dawn Trigger","server":"","version":4,"exposeAsEntityConfig":"","entityId":"sensor.my_dawn_start","property":"state","offset":"0","offsetType":"num","offsetUnits":"minutes","randomOffset":false,"repeatDaily":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"sunday":true,"monday":true,"tuesday":true,"wednesday":true,"thursday":true,"friday":true,"saturday":true,"ignorePastDate":true,"x":410,"y":180,"wires":[["2dc4627e3e9f8fbb"]]},{"id":"433c562d8fabfe91","type":"ha-sensor","z":"70e9a03d9a03cec2","name":"My Dawn TS","entityConfig":"73da203c223e3a60","version":0,"state":"$fromMillis(payload.dawn)","stateType":"jsonata","attributes":[{"property":"start","value":"payload.start","valueType":"msg"},{"property":"minutes","value":"payload.mins","valueType":"msg"},{"property":"unixms","value":"payload.dawn","valueType":"msg"}],"inputOverride":"allow","outputProperties":[],"x":530,"y":100,"wires":[[]],"server":""},{"id":"73da203c223e3a60","type":"ha-entity-config","server":"","deviceConfig":"","name":"SC My Dawn","version":6,"entityType":"sensor","haConfig":[{"property":"name","value":"My Dawn Start"},{"property":"icon","value":""},{"property":"entity_picture","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":""},{"property":"unit_of_measurement","value":""},{"property":"state_class","value":""}],"resend":false,"debugEnabled":false}]
Here is the flow in testing - I added extra hours to the returned ‘next dawn’ as this was now in the past…