Issue with Timer Delay in Sunrise Simulation (JS Scripting)

Hi everyone,

I’ve written a rule in JS Scripting that simulates a sunrise by gradually increasing the brightness of a group of lights. The animation runs in multiple steps using a timer that gets rescheduled after each step:

let duration = items.Time_GF_Bedroom_DurationSunriseSimulation.numericState;
let delay = Math.round(duration / steps);

and

timerItem.reschedule(time.toZDT().plusSeconds(delay));

Problem

  • Before editing the rule, the animation ran way too fast (steps executed almost instantly).
  • After editing the rule, it now runs with the correct delay.

Example logs (before, too fast):

2025-03-12 14:48:36.229 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 0/50
2025-03-12 14:48:39.622 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 0/50
2025-03-12 14:48:40.944 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 10/50

After editing the rule (correct delay):

2025-03-12 14:52:59.947 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 0/50
2025-03-12 14:53:20.015 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 10/50

Why could this be happening? Could delay have been calculated incorrectly, or was the timer not properly initialized? Has anyone experienced something similar?

Thanks for your help! :blush:

here is the complete rule



rules.JSRule({
    name: "start Sunset Animation",
    description: "Simuliert einen Sonnenaufgang durch Farb- und Helligkeitsänderung.",
    triggers: [
        triggers.ItemCommandTrigger("Switch_GF_Bedroom_SunriseSimulation", "ON") // Item to start the animation
    ],
    execute: (event) => {
		try{
			const triggerItem = event.itemName;     
			/*const timerMap = {
                                Switch_GF_Bedroom_SunriseSimulation: timerSunriseBedroom
                        };*/
                        const lightColorMap = {
                                Switch_GF_Bedroom_SunriseSimulation: "Group_GF_Bedroom_ColorLight",
                        };

                        if (!lightColorMap[triggerItem]) {
                                actions.Log.logWarn("start Sunset Animation", "Unknown trigger item: ${triggerItem}");
                        }else if (items.Switch_House_SmartHomeAutomation.state.toString()  == "ON") {

                                //const sceneValue = items.getItem(triggerItem).numericState;
				let timerItem = null;
				/*if ( triggerItem == "Switch_GF_Bedroom_SunriseSimulation" ) {
                                        actions.Log.logWarn("activate deConz Scene", "Uf: ${triggerItem}");
					timerItem = timerSunriseBedroom;
                                }*/
                                //let timerItem = timerMap[triggerItem];
				//actions.Log.logWarn("start Sunset Animation", `U: ${timerItem}`);
                                let lightColorGroupItem = items.getItem(lightColorMap[triggerItem]);

        			// Configurable Parameters
			        //const lightItem = "Group_GF_Bedroom_ColorLight"; // Name of the Hue Color Item
			        const steps = 50;                  // Number of steps in the simulation
			        let duration = items.Time_GF_Bedroom_DurationSunriseSimulation.numericState; // Duration in milliseconds
			        let delay = Math.round(duration / steps);   // Time per step
                                       // actions.Log.logInfo("start Sunset Animation",`Sonnenaufgangs-Animation uu  ${delay}`);

			        // Starting values
			        const startHue = 0;         // Initial Hue (Red)
				const startSaturation = 100; // Maximum saturation
			        const startBrightness = 0;  // Initial brightness

				// Target values
			        const endHue = 188;        // Target Hue
			        const endSaturation = 4;  // Target saturation
			        const endBrightness = 100; // Target brightness

				// Timer-based animation
			        let step = 0; // Start at step 0
			        timerItem = actions.ScriptExecution.createTimer(time.toZDT().plusSeconds(delay), () => {
			            if (step > steps) {
                			timerItem.cancel();
					items.getItem('Switch_GF_Bedroom_SunriseSimulation').sendCommand('OFF');
			                actions.Log.logInfo("start Sunset Animation","Sonnenaufgangs-Animation abgeschlossen.");
			                return;
            			    }

			        // Calculate interpolated values
				let currentHue = Math.round(startHue + (step / steps) * (endHue - startHue));
				let currentSaturation = Math.round(startSaturation + (step / steps) * (endSaturation - startSaturation));
				let currentBrightness = Math.round(startBrightness + (step / steps) * (endBrightness - startBrightness));

				// Apply the calculated settings
                                lightColorGroupItem.sendCommand(`${currentHue},${currentSaturation},${currentBrightness}`);
                                actions.Log.logInfo("start Sunset Animation",`Set light to: Hue=${currentHue}, Saturation=${currentSaturation}, Brightness=${currentBrightness}`);

           			 // Log progress at certain steps (optional)
            			if (step % 10 === 0) {
                			actions.Log.logInfo("start Sunset Animation",`Animation progress: Step ${step}/${steps}`);
            			}

			        // Increment the step and reschedule the timer
            			step++;
            			timerItem.reschedule(time.toZDT().plusSeconds(delay));
        			}); 
				// actions.Log.logWarn("start Sunset Animation", `U: ${timerSunriseBedroom}`);
				if ( triggerItem == "Switch_GF_Bedroom_SunriseSimulation" ) {
                                        //actions.Log.logWarn("activate deConz Scene", "Uf: ${triggerItem}");
                                        timerSunriseBedroom = timerItem;
                                }
				//actions.Log.logWarn("start Sunset Animation", `U: ${timerSunriseBedroom}`);


        			actions.Log.logInfo("start Sunset Animation","Sonnenaufgangs-Animation gestartet.");
			} else {
				actions.Log.logInfo("start Sunset Animation", "Master automation switch is off");
			}
		}
                catch(error) {
                        actions.Log.logError("start Sunrise Animation", "Some bad stuff happened in \"start Sunrise Animation\": " +error.toString());
                }
                finally {
                }

    		},
   tags: ["start Sunriseanimation"],
   id: "start Sunriseanimation"
});

Note there is a rule template on the marketplace that does this. I don’t know if it’s been kept up to date though. It may not work. Simulate Sunrise

I’m not sure I completely understand. What did you change when you edited the rule?

The indentation is a little all over the place so it’s a little hard to read. I may have missed something.

It used ot be the case that an error would be thrown if you cancel a timer from inside the body of a timer. And there really is no reason to cancel it. It’s already too late. The timer ran. But that’s not the cause of the behavior you described, just something to watch out for.

I’m not sure that timerItem is being managed correctly. That might be an issue, or it might be working as is. The problem might be that when the rule runs a second time after it’s already created a timer, you’ll lose access to that first timer but that first timer will continue to run.

This is a case where using the cache has advantages. It stores the timer outside of the rule itself and the cache will handle cleaning up orphaned timers where necessary.

I can’t say if that explains the behavior you saw.

There are a few other things I would do differently but none of them would impact the behavior you described. Some examples:

Use console.info instead of actions.Log.logInfo. If you want to change the logger name you can do that using console.loggerName = "org.openhab.model.script.start Sunset Animation";. These will significantly reduce the lenth of the logger lines.

I’d use Item metadata instead of a hard coded map in the rule.

Change let timerItem = null to use the cache instead.

I’d make sure Time_GF_Bedroom_DurationSunriseSimulation is a Number:Time and get the quantityState. Then let delay = time.Duration.ofSeconds(duration.divide(steps).toUnit("s").int). Later you can use time.toZDT(delay) to create the time to schedule/reschedule the timer.

I’d use LoopingTimer from OHRT to handle the timer. It’s a little easier to use.

I’m not sure the try/catch is doing a whole lot for you here.

Applying all those changes the rule might looks something like this:

var {LoopingTimer} = require("openhab_rules_tools");

rules.JSRule({
    name: "start Sunset Animation",
    description: "Simuliert einen Sonnenaufgang durch Farb- und Helligkeitsänderung.",
    triggers: [
        triggers.ItemCommandTrigger("Switch_GF_Bedroom_SunriseSimulation", "ON") // Item to start the animation
    ],
    execute: (event) => {
      console.name = "org.openhab.model.script.start SunsetAnimation";

      const triggerItem = items[event.itemName];
      const sunsetSimMD = triggerItem.getMetadata("sunsetSim");
      if(sunsetSimMD === null || sunsetSimMD.value === undefined || sunsetSimMD.value === null) {
        console.warn("Trigger Item ${triggerItem.name} does not have valid sunsetSim metadata: ${sunsetSimMD}");
      } else if(items.Switch_House_SmartHomeAutomation.state == "ON") { // state is already a String
        const lightColorGroupItem = items[sunsetSimMD.value];

        // Timing Constants
        const steps = 50;
        const duration = items.Time_GF_Bedroom_DurationSunriseSimulation.quantityState;
        const delay = time.Duration.ofSeconds(duration.divide(steps).toUnit("s").int);

        // Start and End values
        const start = {hue: 0, sat: 100, brt: 0};
        const end = {hue: 188, sat: 4, brt: 100}; 

        if(cache.private.exists("timer") ) {
          console.warn("Sunset simulation is already running!");
        } else {
          const timer = LoopingTimer(); // Create the timer
          cache.private.put("timer", timer); // save the timer
          cache.private.put("step", 0); // Use the cache to keep track of the steps
          // start the loop, the looping stops when the function returns null
          timer.loop(() => {
            const step = cache.private.get("step");
            // Exit the loop
            if(step > steps) {
              items.Switch_GF_Bedroom_SunriseSimulation.sendCommand('OFF');
              console.info("Sonnenaufganga-Animation abgeschlossen.");
              cache.private.remove("timer");
              return null;
            }
            // Calculate the next values and reschedule next iteration of the loop
            let currHue = Math.round(start.hue + (step / steps) * (end.hue - start.hue));
            let currSat = Math.round(start.sat + (step / steps) * (end.sat - start.sat));
            let currBrt = Math.round(start.brt + (step / steps) * (end.brt - start.brt));

            lightColorGroupItem.sendCommand("${currHue},${currSat},${currBrt}");
            console.info("Set light to: Hue=${currHue}, Saturation=${currSat}, Brightness=${currBrt}");

            // Log progress at certain steps
            if(step % 10 == 0) {
              console.info("Animation progress: Step ${step}/${steps}");
           }
           cache.private.put("step", step++);
           return delay; // reschedule to delay from now
          }, delay);
          console.info("Sonnenaufgangs-Animation gestartet.");
        }
      } else {
        console.log("start Sunset Animation", "Master automation switch is off");
      }
    },
   tags: ["start Sunriseanimation"],
   id: "start Sunriseanimation"
});

I just typed in the above. There almost certainly are typos. I mainly just types it to show some techniques you or other readers of the forum may not know of.

If this were a managed rule, I’d move the tests to conditions which will simplify the code somewhat.

the item looks like

Number:Time          Time_GF_Bedroom_DurationSunriseSimulation              "Dauer Sunriseanimation [%d %unit%]" ["Setpoint", "Timestamp"]{stateDescription=" "[ pattern="%d %unit%"], unit="s"}

Do I still need to do this?

time.Duration.ofSeconds(duration.divide(steps).toUnit("s").int)

I’m not sure I completely understand. What did you change when you edited the rule?

Honestly I changed nothing just opend the file and closed it…

If I try your solution I get this erro

Failed to execute script: TypeError: Cannot load CommonJS module: 'openhab_rules_tools'

This line works no matter what unit the Item is using. For example, if the Item’s state is 1 h (one hour) the above line will work because we do the divide staying within UoM and then we convert the result to seconds. Tthen we get just a plain int to create the Duration with the seconds.

This is the most robust ways to work with the states of UoM Items. You never have to do any conversions between units yourself and you can just ask for the units you need in your rule. The only reason we had to get the int from the result is because time.Duration doesn’t know how to use Quantity and only supports primitives.

Using time.toZDT() with a time.Duration will add that duration to now. All of the openhab_rules_tools classes support being passed anything that can be converted by time.toZDT() natively so you don’t even need to convert the Duration into a ZonedDateTime. It’s done for you.

openhab_rules_tools is a third party library that needs to be installed. It can be installed through openhabian_config or by navigating to $OH_CONF/automation/js and running the command npm install openhab_rules_tools.

now i get this error

duration.divide is not a function

when using this

let duration = items.Time_GF_Bedroom_DurationSunriseSimulation.numericState; // Duration in milliseconds
                                let delay = time.Duration.ofSeconds(duration.divide(steps).toUnit("s").int)

.quantityState

In JS Scripting, an Item has three ways to get the state.

  • .state: always a String
  • .numericState: always a float or null if it’s not a something that can be represented as a number
  • .quantityState: always a Quantity or null if it’s not a QuantityType.

See JavaScript Scripting - Automation | openHAB for more on Quantity.

That code is depending on the Quantity so we need to get the .quantityState.

Ok thanks…

let duration = items.Time_GF_Bedroom_DurationSunriseSimulation.quantityState; // Duration in milliseconds
                                let delay = time.Duration.ofSeconds(duration.divide(steps).toUnit("s").int)

this is running fine but what is the difference to

let duration = items.Time_GF_Bedroom_DurationSunriseSimulation.numericState; // Duration in milliseconds
			        let delay = Math.round(duration / steps);

The latter assumes the unit of the state of Time_GF_Bedroom_DurationSunriseSimulation to be milliseconds (based on the comment). That means you have to know this outside iof what’s in the rule and if you ever change that (e.g. to second or minutes) you have to remember to come back to this rule and change this code. If you use the UoM then it doesn’t matter what unit the state is stored as in the Item. It can be hours, minutes, seconds, or years. But you are able to get the value as any unit you want (in this case seconds) and all the necessary conversions are done for you.

This results in more self documenting code and it results in more robust code. If you decide that milliseconds is a bad choice becuase it’s hard to work with or want to put this in front of a user to change in the UI so change to use minutes (e.g.), all you need to do is change the unit on the Item and update the state to a value in minutes. The rule doesn’t need to change. The rule doesn’t care. It knows it’s going to get seconds.

And because it’s obvious that the number is in seconds since we explicitely convert it to seconds, you don’t need a comment saying // value in seconds. The code already tells you that.

The rounding comes for free when we strip the unit off of the value and get it as an int. Again this is a little more self documenting becuase you just need to indicate what you want, not have an operation to convert the value to what you want.

Using time.Duration tends to create more self documenting code as well. Like a Quantity, you can ask for the duration in any unit you want. But in this case, we don’t need anything except the Duration because time.toZDT() will return a ZonedDateTime after adding the time.Duration to now and, if using LoopingTimer, you don’t even need to explicitely call time.toZDT() and you can just pass the time.Duration when starting the looping Timer.

In summary, using the Quantity and time.Duration results in more self documenting code, more robust code that is resistence to changes made outside the rule, and often results in shorter code because it relies on the libraries to “do the math” instead of needing to do the math yourself in the rule.

I restarted openhab yesterday due to the fact that I have installed some more shelly’s in my house and also switch off the circuit breaker after putting the circuit breaker on again my smarthome restarted and after that now I have the same issue again

2025-03-16 07:50:26.674 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 0/50
2025-03-16 07:50:27.964 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 10/50
2025-03-16 07:50:29.159 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 20/50
2025-03-16 07:50:30.340 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 30/50
2025-03-16 07:50:31.544 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 40/50
2025-03-16 07:50:32.742 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 50/50

I realize that my router was not fully booted and so there was no network connection to my smarthome. After the network connection was established I restarted openhab and the timer are working correctly

2025-03-16 09:37:12.576 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 0/50
2025-03-16 09:37:32.696 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 10/50
2025-03-16 09:37:52.816 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 20/50
2025-03-16 09:38:12.915 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 30/50
2025-03-16 09:38:32.998 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 40/50
2025-03-16 09:38:53.080 [INFO ] [.model.script.start Sunset Animation] - Animation progress: Step 50/50

Log out the when the timer gets rescheduled. If the scheduled time is in the past then the timers run immediately.