node-red-contrib-power-saver 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-power-saver",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "A module for Node-RED that you can use to turn on and off a switch based on power prices",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -47,27 +47,26 @@
47
47
  "url": "https://github.com/ottopaulsen/node-red-contrib-power-saver.git"
48
48
  },
49
49
  "devDependencies": {
50
- "@vuepress/bundler-vite": "2.0.0-rc.19",
51
- "@vuepress/plugin-google-analytics": "2.0.0-rc.66",
52
- "@vuepress/plugin-register-components": "2.0.0-rc.66",
53
- "@vuepress/plugin-search": "2.0.0-rc.66",
54
- "@vuepress/theme-default": "2.0.0-rc.66",
55
- "@vuepress/utils": "2.0.0-rc.19",
56
- "chai": "^4.3.10",
57
- "eslint": "9.17.0",
58
- "expect": "29.7.0",
59
- "mocha": "^11.0.1",
60
- "node-red": "^4.0.8",
61
- "node-red-node-test-helper": "0.3.4",
62
- "sass-embedded": "^1.83.1",
63
- "sass-loader": "^16.0.4",
64
- "vuepress": "2.0.0-rc.19"
50
+ "@vuepress/bundler-vite": "2.0.0-rc.26",
51
+ "@vuepress/plugin-google-analytics": "2.0.0-rc.123",
52
+ "@vuepress/plugin-register-components": "2.0.0-rc.123",
53
+ "@vuepress/plugin-search": "2.0.0-rc.123",
54
+ "@vuepress/theme-default": "2.0.0-rc.123",
55
+ "@vuepress/utils": "2.0.0-rc.26",
56
+ "chai": "6.2.2",
57
+ "eslint": "10.0.0",
58
+ "expect": "30.2.0",
59
+ "mocha": "^11.7.5",
60
+ "node-red": "^4.1.5",
61
+ "node-red-node-test-helper": "0.3.6",
62
+ "sass-embedded": "^1.97.3",
63
+ "sass-loader": "^16.0.7",
64
+ "vuepress": "2.0.0-rc.26"
65
65
  },
66
66
  "dependencies": {
67
67
  "floating-vue": "5.2.2",
68
68
  "lodash.clonedeep": "4.5.0",
69
69
  "luxon": "3.5.0",
70
- "nano-time": "1.0.0",
71
- "node-fetch": "2.6.7"
70
+ "nano-time": "1.0.0"
72
71
  }
73
72
  }
@@ -1,4 +1,4 @@
1
- const fetch = require("node-fetch");
1
+ // const fetch = require("node-fetch");
2
2
 
3
3
  function ping(node, subscriptionKey, setResultStatus = true) {
4
4
  const url = "https://elvia.azure-api.net/grid-tariff/Ping";
@@ -1,6 +1,18 @@
1
1
  // Business logic functions for light-saver node
2
2
  // These functions are exported for testing
3
3
 
4
+ /**
5
+ * Debug logging wrapper - only logs if debugLog is enabled in config
6
+ * @param {object} config - Configuration object with debugLog boolean
7
+ * @param {object} node - Node-RED node object for logging
8
+ * @param {string} message - Message to log
9
+ */
10
+ function debugLog(config, node, message) {
11
+ if (config && config.debugLog === true) {
12
+ node.log(message);
13
+ }
14
+ }
15
+
4
16
  /**
5
17
  * Parse a timestamp string as UTC, handling timezone offsets
6
18
  * Home Assistant may return timestamps with or without 'Z' or timezone offsets like +00:00
@@ -124,7 +136,7 @@ function isBrightnessAllowingLights(config) {
124
136
  function handleStateChange(event, config, state, node, homeAssistant, clock = null) {
125
137
  const now = clock ? clock.now() : new Date();
126
138
 
127
- node.log("State change event received: " + JSON.stringify(event).substring(0, 200));
139
+ debugLog(config, node, "State change event received: " + JSON.stringify(event).substring(0, 200));
128
140
 
129
141
  if (!event || !event.event) return;
130
142
 
@@ -136,7 +148,7 @@ function handleStateChange(event, config, state, node, homeAssistant, clock = nu
136
148
  return;
137
149
  }
138
150
 
139
- node.log(`Processing state change for ${entityId}: ${newState.state}`);
151
+ debugLog(config, node, `Processing state change for ${entityId}: ${newState.state}`);
140
152
 
141
153
  const timestamp = now.toISOString().substring(0, 19); // Format: yyyy-mm-ddTHH:MM:SS
142
154
  const timeOnly = now.toISOString().substring(11, 19); // Format: HH:MM:SS
@@ -147,24 +159,25 @@ function handleStateChange(event, config, state, node, homeAssistant, clock = nu
147
159
  trigger.lastChanged = timestamp;
148
160
  trigger.state = newState.state;
149
161
 
150
- node.log(`Updated trigger ${entityId}: state=${trigger.state}, lastChanged=${trigger.lastChanged}`);
162
+ debugLog(config, node, `Updated trigger ${entityId}: state=${trigger.state}, lastChanged=${trigger.lastChanged}`);
151
163
 
152
164
  // If trigger turned on and timedOut is true, activate lights
153
165
  if (newState.state === 'on' && state.timedOut === true) {
154
- node.log(`Trigger ${entityId} turned on while timedOut=true, checking brightness and activating lights`);
166
+ debugLog(config, node, `Trigger ${entityId} turned on while timedOut=true, checking brightness and activating lights`);
155
167
  state.timedOut = false; // Reset timedOut after activating lights
156
168
 
157
169
  // Check brightness limit before turning on lights
158
170
  if (isBrightnessAllowingLights(config)) {
159
171
  const level = findCurrentLevel(config, node, clock);
160
172
  if (level !== null) {
161
- controlLights(config.lights, level, node, homeAssistant);
173
+ controlLights(config, config.lights, level, node, homeAssistant);
162
174
  }
163
175
  } else {
164
- node.log('Brightness limit prevents lights from turning on');
176
+ debugLog(config, node, 'Brightness limit prevents lights from turning on');
165
177
  }
166
178
  }
167
179
 
180
+
168
181
  // Update timedOut status: if any trigger is on, timedOut = false
169
182
  if (newState.state === 'on') {
170
183
  state.timedOut = false;
@@ -184,7 +197,7 @@ function handleStateChange(event, config, state, node, homeAssistant, clock = nu
184
197
  config.nightSensor.lastChanged = timestamp;
185
198
  config.nightSensor.state = newState.state;
186
199
 
187
- node.log(`Updated night sensor ${entityId}: state=${config.nightSensor.state}, lastChanged=${config.nightSensor.lastChanged}`);
200
+ debugLog(config, node, `Updated night sensor ${entityId}: state=${config.nightSensor.state}, lastChanged=${config.nightSensor.lastChanged}`);
188
201
 
189
202
  node.status({
190
203
  fill: "green",
@@ -206,7 +219,7 @@ function handleStateChange(event, config, state, node, homeAssistant, clock = nu
206
219
  config.awaySensor.lastChanged = timestamp;
207
220
  config.awaySensor.state = newState.state;
208
221
 
209
- node.log(`Updated away sensor ${entityId}: state=${config.awaySensor.state}, lastChanged=${config.awaySensor.lastChanged}`);
222
+ debugLog(config, node, `Updated away sensor ${entityId}: state=${config.awaySensor.state}, lastChanged=${config.awaySensor.lastChanged}`);
210
223
 
211
224
  node.status({
212
225
  fill: "green",
@@ -229,7 +242,7 @@ function handleStateChange(event, config, state, node, homeAssistant, clock = nu
229
242
  config.brightnessSensor.lastChanged = timestamp;
230
243
  config.brightnessSensor.state = newState.state;
231
244
 
232
- node.log(`Updated brightness sensor ${entityId}: state=${config.brightnessSensor.state}, lastChanged=${config.brightnessSensor.lastChanged}`);
245
+ debugLog(config, node, `Updated brightness sensor ${entityId}: state=${config.brightnessSensor.state}, lastChanged=${config.brightnessSensor.lastChanged}`);
233
246
 
234
247
  node.status({
235
248
  fill: "green",
@@ -243,16 +256,16 @@ function handleStateChange(event, config, state, node, homeAssistant, clock = nu
243
256
  // Brightness crossed threshold - lights are now allowed
244
257
  // If lights are off and there was motion within timeout (timedOut is false), turn lights on
245
258
  if (state.timedOut === false) {
246
- node.log('Brightness crossed threshold and motion detected within timeout, turning lights on');
259
+ debugLog(config, node, 'Brightness crossed threshold and motion detected within timeout, turning lights on');
247
260
  const level = findCurrentLevel(config, node, clock);
248
261
  if (level !== null) {
249
- controlLights(config.lights, level, node, homeAssistant);
262
+ controlLights(config, config.lights, level, node, homeAssistant);
250
263
  }
251
264
  }
252
265
  } else if (wasBrightnessAllowing && !isNowBrightnessAllowing) {
253
266
  // Brightness crossed threshold - lights are no longer allowed
254
267
  // We don't turn lights off here, let the timeout mechanism handle it
255
- node.log('Brightness crossed threshold, lights no longer allowed to turn on (but staying on if already on)');
268
+ debugLog(config, node, 'Brightness crossed threshold, lights no longer allowed to turn on (but staying on if already on)');
256
269
  }
257
270
 
258
271
  return;
@@ -261,6 +274,43 @@ function handleStateChange(event, config, state, node, homeAssistant, clock = nu
261
274
  node.warn(`Received state change for ${entityId} but not found in triggers, nightSensor, awaySensor, or brightnessSensor`);
262
275
  }
263
276
 
277
+ /**
278
+ * Find the level config object for the current time
279
+ * @param {object} config - Configuration object with levels
280
+ * @param {object} clock - Clock abstraction for getting current time (for testing)
281
+ * @returns {object|null} The level config object or null if not found
282
+ */
283
+ function findLevelConfig(config, clock = null) {
284
+ const now = clock ? clock.now() : new Date();
285
+
286
+ if (!config.levels || config.levels.length === 0) {
287
+ return null;
288
+ }
289
+
290
+ const currentTime = now.getHours() * 60 + now.getMinutes();
291
+
292
+ // Sort levels by fromTime
293
+ const sortedLevels = config.levels.slice().sort((a, b) => {
294
+ const [aHour, aMin] = a.fromTime.split(':').map(Number);
295
+ const [bHour, bMin] = b.fromTime.split(':').map(Number);
296
+ const aMinutes = aHour * 60 + aMin;
297
+ const bMinutes = bHour * 60 + bMin;
298
+ return aMinutes - bMinutes;
299
+ });
300
+
301
+ // Find the latest level that started before current time
302
+ for (let i = sortedLevels.length - 1; i >= 0; i--) {
303
+ const [hour, min] = sortedLevels[i].fromTime.split(':').map(Number);
304
+ const levelTime = hour * 60 + min;
305
+
306
+ if (levelTime <= currentTime) {
307
+ return sortedLevels[i];
308
+ }
309
+ }
310
+
311
+ return null;
312
+ }
313
+
264
314
  /**
265
315
  * Find the appropriate light level based on time and night sensor
266
316
  * @param {object} config - Configuration object with awaySensor, nightSensor, levels
@@ -275,13 +325,13 @@ function findCurrentLevel(config, node, clock = null) {
275
325
 
276
326
  // If away sensor is active and awayLevel is set, use that
277
327
  if (isAwayMode(config) && config.awaySensor?.level !== null && config.awaySensor?.level !== undefined) {
278
- node.log(`Using away level: ${config.awaySensor.level}%`);
328
+ debugLog(config, node, `Using away level: ${config.awaySensor.level}%`);
279
329
  return config.awaySensor.level;
280
330
  }
281
331
 
282
332
  // If night sensor is active and nightLevel is set, use that
283
333
  if (isNightMode(config) && config.nightSensor?.level !== null && config.nightSensor?.level !== undefined) {
284
- node.log(`Using night level: ${config.nightSensor.level}%`);
334
+ debugLog(config, node, `Using night level: ${config.nightSensor.level}%`);
285
335
  return config.nightSensor.level;
286
336
  }
287
337
 
@@ -310,7 +360,7 @@ function findCurrentLevel(config, node, clock = null) {
310
360
 
311
361
  if (levelTime <= currentTime) {
312
362
  selectedLevel = sortedLevels[i].level;
313
- node.log(`Found level ${selectedLevel}% from ${sortedLevels[i].fromTime}`);
363
+ debugLog(config, node, `Found level ${selectedLevel}% from ${sortedLevels[i].fromTime}`);
314
364
  break;
315
365
  }
316
366
  }
@@ -318,7 +368,7 @@ function findCurrentLevel(config, node, clock = null) {
318
368
  // If no level found (current time is before all levels), use the last level (wraps from previous day)
319
369
  if (selectedLevel === null && sortedLevels.length > 0) {
320
370
  selectedLevel = sortedLevels[sortedLevels.length - 1].level;
321
- node.log(`Using last level ${selectedLevel}% (wrapped from previous day)`);
371
+ debugLog(config, node, `Using last level ${selectedLevel}% (wrapped from previous day)`);
322
372
  }
323
373
 
324
374
  return selectedLevel;
@@ -326,18 +376,18 @@ function findCurrentLevel(config, node, clock = null) {
326
376
 
327
377
  /**
328
378
  * Control lights by sending commands to Home Assistant
379
+ * @param {object} config - Configuration object with debugLog flag
329
380
  * @param {array} lights - Array of light entities
330
381
  * @param {number} level - Level to set (0-100)
331
382
  * @param {object} node - Node-RED node object for logging
332
383
  * @param {object} homeAssistant - Home Assistant integration object
333
384
  */
334
- function controlLights(lights, level, node, homeAssistant) {
385
+ function controlLights(config, lights, level, node, homeAssistant) {
335
386
  if (level === null || level === undefined) {
336
387
  node.warn('Cannot control lights: no valid level found');
337
388
  return;
338
389
  }
339
-
340
- node.log(`Controlling lights with level: ${level}%`);
390
+ debugLog(config, node, `Controlling lights with level: ${level}%`);
341
391
 
342
392
  lights.forEach(light => {
343
393
  const entityId = light.entity_id;
@@ -349,7 +399,7 @@ function controlLights(lights, level, node, homeAssistant) {
349
399
  if (domain === 'switch') {
350
400
  // For switches: turn off if level is 0, on if level > 0
351
401
  const service = level === 0 ? 'turn_off' : 'turn_on';
352
- node.log(`Calling ${domain}.${service} for ${entityId}`);
402
+ debugLog(config, node, `Calling ${domain}.${service} for ${entityId}`);
353
403
 
354
404
  homeAssistant.websocket.send({
355
405
  type: 'call_service',
@@ -362,7 +412,7 @@ function controlLights(lights, level, node, homeAssistant) {
362
412
  } else if (domain === 'light') {
363
413
  // For lights: set brightness percentage
364
414
  if (level === 0) {
365
- node.log(`Calling ${domain}.turn_off for ${entityId}`);
415
+ debugLog(config, node, `Calling ${domain}.turn_off for ${entityId}`);
366
416
  homeAssistant.websocket.send({
367
417
  type: 'call_service',
368
418
  domain: domain,
@@ -372,7 +422,7 @@ function controlLights(lights, level, node, homeAssistant) {
372
422
  }
373
423
  });
374
424
  } else {
375
- node.log(`Calling ${domain}.turn_on for ${entityId} with brightness ${level}%`);
425
+ debugLog(config, node, `Calling ${domain}.turn_on for ${entityId} with brightness ${level}%`);
376
426
  homeAssistant.websocket.send({
377
427
  type: 'call_service',
378
428
  domain: domain,
@@ -389,12 +439,13 @@ function controlLights(lights, level, node, homeAssistant) {
389
439
 
390
440
  /**
391
441
  * Turn off all lights
442
+ * @param {object} config - Configuration object with debugLog flag
392
443
  * @param {array} lights - Array of light entities
393
444
  * @param {object} node - Node-RED node object for logging
394
445
  * @param {object} homeAssistant - Home Assistant integration object
395
446
  */
396
- function turnOffAllLights(lights, node, homeAssistant) {
397
- node.log('Turning off all lights (timeout reached)');
447
+ function turnOffAllLights(config, lights, node, homeAssistant) {
448
+ debugLog(config, node, 'Turning off all lights (timeout reached)');
398
449
 
399
450
  lights.forEach(light => {
400
451
  const entityId = light.entity_id;
@@ -403,7 +454,7 @@ function turnOffAllLights(lights, node, homeAssistant) {
403
454
  // Store that we're setting level to 0
404
455
  light.setLevel = 0;
405
456
 
406
- node.log(`Calling ${domain}.turn_off for ${entityId}`);
457
+ debugLog(config, node, `Calling ${domain}.turn_off for ${entityId}`);
407
458
  homeAssistant.websocket.send({
408
459
  type: 'call_service',
409
460
  domain: domain,
@@ -430,12 +481,12 @@ function checkTimeouts(config, state, node, homeAssistant, clock = null) {
430
481
  const anyOn = config.triggers.some(t => t.state === 'on');
431
482
 
432
483
  if (anyOn) {
433
- node.log('At least one trigger is on, no timeout check needed');
484
+ debugLog(config, node, 'At least one trigger is on, no timeout check needed');
434
485
  return;
435
486
  }
436
487
 
437
488
  // All triggers are off, check if they've been off long enough
438
- node.log('All triggers are off, checking timeouts...');
489
+ debugLog(config, node, 'All triggers are off, checking timeouts...');
439
490
 
440
491
  let allTimedOut = true;
441
492
 
@@ -445,7 +496,7 @@ function checkTimeouts(config, state, node, homeAssistant, clock = null) {
445
496
 
446
497
  // If trigger has no state or lastChanged, we can't check timeout
447
498
  if (!trigger.state || !trigger.lastChanged) {
448
- node.log(`Trigger ${trigger.entity_id} has no state/lastChanged, skipping`);
499
+ debugLog(config, node, `Trigger ${trigger.entity_id} has no state/lastChanged, skipping`);
449
500
  allTimedOut = false;
450
501
  continue;
451
502
  }
@@ -460,20 +511,37 @@ function checkTimeouts(config, state, node, homeAssistant, clock = null) {
460
511
  const lastChangedTime = parseUTCTimestamp(trigger.lastChanged);
461
512
  const minutesOff = (now - lastChangedTime) / 1000 / 60;
462
513
 
463
- node.log(`Trigger ${trigger.entity_id}: off for ${minutesOff.toFixed(1)} minutes, timeout is ${timeoutMinutes} minutes`);
514
+ debugLog(config, node, `Trigger ${trigger.entity_id}: off for ${minutesOff.toFixed(1)} minutes, timeout is ${timeoutMinutes} minutes`);
464
515
 
465
516
  if (minutesOff < timeoutMinutes) {
466
517
  allTimedOut = false;
467
- node.log(`Trigger ${trigger.entity_id} has not timed out yet`);
518
+ debugLog(config, node, `Trigger ${trigger.entity_id} has not timed out yet`);
468
519
  }
469
520
  }
470
521
 
471
522
  if (allTimedOut && config.triggers.length > 0) {
472
- node.log('All triggers have timed out, turning off lights');
473
- turnOffAllLights(config.lights, node, homeAssistant);
523
+ debugLog(config, node, 'All triggers have timed out, turning off lights');
524
+ turnOffAllLights(config, config.lights, node, homeAssistant);
474
525
  state.timedOut = true;
475
526
  node.status({ fill: "yellow", shape: "ring", text: "Timed out - lights off" });
476
527
  }
528
+
529
+ // Check for immediate levels when motion is detected (timedOut = false)
530
+ if (!state.timedOut && !allTimedOut) {
531
+ const levelConfig = findLevelConfig(config, clock);
532
+ // Only apply immediate level if this is a NEW immediate period (fromTime changed)
533
+ if (levelConfig && levelConfig.immediate === true && levelConfig.fromTime !== state.lastImmediateTime && isBrightnessAllowingLights(config)) {
534
+ const currentLevel = findCurrentLevel(config, node, clock);
535
+ if (currentLevel !== null) {
536
+ debugLog(config, node, `Immediate level ${currentLevel}% found for time ${levelConfig.fromTime}, applying...`);
537
+ controlLights(config, config.lights, currentLevel, node, homeAssistant);
538
+ state.lastImmediateTime = levelConfig.fromTime; // Mark this immediate period as applied
539
+ }
540
+ }
541
+ } else if (state.timedOut) {
542
+ // Reset lastImmediateTime when timeout occurs (next immediate period will apply)
543
+ state.lastImmediateTime = null;
544
+ }
477
545
  }
478
546
 
479
547
  /**
@@ -511,19 +579,19 @@ function fetchMissingStates(config, state, node, homeAssistant, clock = null) {
511
579
  }
512
580
 
513
581
  if (entitiesToFetch.length === 0) {
514
- node.log('All entities already have states');
582
+ debugLog(config, node, 'All entities already have states');
515
583
  } else {
516
- node.log(`Fetching states for ${entitiesToFetch.length} entities: ${entitiesToFetch.map(e => e.id).join(', ')}`);
584
+ debugLog(config, node, `Fetching states for ${entitiesToFetch.length} entities: ${entitiesToFetch.map(e => e.id).join(', ')}`);
517
585
 
518
586
  try {
519
587
  // Access states from the websocket - it's stored as a flat object
520
588
  const states = homeAssistant.websocket.states;
521
- node.log(`States object type: ${typeof states}, keys count: ${states ? Object.keys(states).length : 0}`);
589
+ debugLog(config, node, `States object type: ${typeof states}, keys count: ${states ? Object.keys(states).length : 0}`);
522
590
 
523
591
  if (states && typeof states === 'object') {
524
592
  entitiesToFetch.forEach(entity => {
525
593
  const stateObj = states[entity.id];
526
- node.log(`Looking for ${entity.id}, found: ${stateObj ? 'yes' : 'no'}`);
594
+ debugLog(config, node, `Looking for ${entity.id}, found: ${stateObj ? 'yes' : 'no'}`);
527
595
 
528
596
  if (stateObj) {
529
597
  if (entity.type === 'trigger') {
@@ -531,20 +599,20 @@ function fetchMissingStates(config, state, node, homeAssistant, clock = null) {
531
599
  if (trigger) {
532
600
  trigger.state = stateObj.state;
533
601
  trigger.lastChanged = stateObj.last_changed || stateObj.last_updated;
534
- node.log(`Fetched state for trigger ${entity.id}: ${stateObj.state}`);
602
+ debugLog(config, node, `Fetched state for trigger ${entity.id}: ${stateObj.state}`);
535
603
  }
536
604
  } else if (entity.type === 'nightSensor') {
537
605
  config.nightSensor.state = stateObj.state;
538
606
  config.nightSensor.lastChanged = stateObj.last_changed || stateObj.last_updated;
539
- node.log(`Fetched state for night sensor ${entity.id}: ${stateObj.state}`);
607
+ debugLog(config, node, `Fetched state for night sensor ${entity.id}: ${stateObj.state}`);
540
608
  } else if (entity.type === 'awaySensor') {
541
609
  config.awaySensor.state = stateObj.state;
542
610
  config.awaySensor.lastChanged = stateObj.last_changed || stateObj.last_updated;
543
- node.log(`Fetched state for away sensor ${entity.id}: ${stateObj.state}`);
611
+ debugLog(config, node, `Fetched state for away sensor ${entity.id}: ${stateObj.state}`);
544
612
  } else if (entity.type === 'brightnessSensor') {
545
613
  config.brightnessSensor.state = stateObj.state;
546
614
  config.brightnessSensor.lastChanged = stateObj.last_changed || stateObj.last_updated;
547
- node.log(`Fetched state for brightness sensor ${entity.id}: ${stateObj.state}`);
615
+ debugLog(config, node, `Fetched state for brightness sensor ${entity.id}: ${stateObj.state}`);
548
616
  }
549
617
  } else {
550
618
  node.warn(`State not found for ${entity.id}`);
@@ -569,7 +637,7 @@ function fetchMissingStates(config, state, node, homeAssistant, clock = null) {
569
637
  // If any trigger is ON, not timed out
570
638
  if (trigger.state === 'on') {
571
639
  allTimedOut = false;
572
- node.log(`Trigger ${trigger.entity_id} is ON, not timed out`);
640
+ debugLog(config, node, `Trigger ${trigger.entity_id} is ON, not timed out`);
573
641
  break;
574
642
  }
575
643
 
@@ -581,21 +649,32 @@ function fetchMissingStates(config, state, node, homeAssistant, clock = null) {
581
649
 
582
650
  if (minutesOff < timeoutMinutes) {
583
651
  allTimedOut = false;
584
- node.log(`Trigger ${trigger.entity_id} off for ${minutesOff.toFixed(1)} min, timeout is ${timeoutMinutes} min - NOT timed out yet`);
652
+ debugLog(config, node, `Trigger ${trigger.entity_id} off for ${minutesOff.toFixed(1)} min, timeout is ${timeoutMinutes} min - NOT timed out yet`);
585
653
  break;
586
654
  } else {
587
- node.log(`Trigger ${trigger.entity_id} off for ${minutesOff.toFixed(1)} min, timeout is ${timeoutMinutes} min - timed out`);
655
+ debugLog(config, node, `Trigger ${trigger.entity_id} off for ${minutesOff.toFixed(1)} min, timeout is ${timeoutMinutes} min - timed out`);
588
656
  }
589
657
  } else if (!trigger.state) {
590
658
  // No state info, can't determine timeout - assume not timed out to be safe
591
659
  allTimedOut = false;
592
- node.log(`Trigger ${trigger.entity_id} has no state, assuming not timed out`);
660
+ debugLog(config, node, `Trigger ${trigger.entity_id} has no state, assuming not timed out`);
593
661
  break;
594
662
  }
595
663
  }
596
664
 
597
665
  state.timedOut = allTimedOut;
598
- node.log(`Initial timedOut set to ${state.timedOut} (all triggers actually timed out: ${allTimedOut})`);
666
+ debugLog(config, node, `Initial timedOut set to ${state.timedOut} (all triggers actually timed out: ${allTimedOut})`);
667
+
668
+ // If motion is detected at startup (timedOut is false), turn lights on
669
+ if (!allTimedOut) {
670
+ debugLog(config, node, 'Motion detected at startup, turning lights on');
671
+ const level = findCurrentLevel(config, node, clock);
672
+ if (level !== null && isBrightnessAllowingLights(config)) {
673
+ controlLights(config, config.lights, level, node, homeAssistant);
674
+ debugLog(config, node, `Lights turned on to ${level}% at startup (motion detected)`);
675
+ }
676
+ }
677
+
599
678
  return true; // Indicates that initial timedOut was set
600
679
  }
601
680
 
@@ -611,6 +690,7 @@ module.exports = {
611
690
  isBrightnessAllowingLights,
612
691
  handleStateChange,
613
692
  findCurrentLevel,
693
+ findLevelConfig,
614
694
  controlLights,
615
695
  turnOffAllLights,
616
696
  checkTimeouts,
@@ -19,7 +19,7 @@
19
19
  brightnessSensor: { value: null },
20
20
  brightnessLimit: { value: null },
21
21
  brightnessMode: { value: 'max' },
22
- levels: { value: [{ fromTime: "00:00", level: 100 }] },
22
+ levels: { value: [{ fromTime: "00:00", level: 100, immediate: false }] },
23
23
  override: { value: 'auto' },
24
24
  contextStorage: { value: "default", required: false },
25
25
  debugLog: { value: false }
@@ -502,11 +502,11 @@
502
502
  // Add existing levels
503
503
  if (node.levels && node.levels.length > 0) {
504
504
  node.levels.forEach(level => {
505
- addLevelRow($list, level.fromTime || '', level.level !== undefined ? level.level : '');
505
+ addLevelRow($list, level.fromTime || '', level.level !== undefined ? level.level : '', level.immediate === true);
506
506
  });
507
507
  } else {
508
508
  // Add one default row
509
- addLevelRow($list, '00:00', 100);
509
+ addLevelRow($list, '00:00', 100, false);
510
510
  }
511
511
 
512
512
  // Add button handler
@@ -756,7 +756,7 @@
756
756
  $container.append(sensorRow);
757
757
  };
758
758
 
759
- const addLevelRow = function($list, fromTime, level) {
759
+ const addLevelRow = function($list, fromTime, level, immediate) {
760
760
  const row = $('<li/>').css({
761
761
  padding: '5px',
762
762
  border: '1px solid #ccc',
@@ -876,6 +876,32 @@
876
876
 
877
877
  levelWrapper.append(levelLabel).append(levelSlider).append(levelInput).append(levelUnit);
878
878
 
879
+ // Immediate checkbox
880
+ const immediateWrapper = $('<div/>').css({
881
+ display: 'flex',
882
+ alignItems: 'center',
883
+ gap: '5px',
884
+ width: '70px'
885
+ });
886
+
887
+ const immediateCheckbox = $('<input type="checkbox" class="immediate-checkbox keeper-ignore"/>').css({
888
+ width: '16px',
889
+ height: '16px'
890
+ });
891
+
892
+ // Set checkbox state if immediate was passed
893
+ if (immediate === true) {
894
+ immediateCheckbox.prop('checked', true);
895
+ }
896
+
897
+ const immediateLabel = $('<label/>').text('Immediate').css({
898
+ fontSize: '11px',
899
+ whiteSpace: 'nowrap',
900
+ marginTop: '2px'
901
+ });
902
+
903
+ immediateWrapper.append(immediateCheckbox).append(immediateLabel);
904
+
879
905
  const deleteButton = $('<button type="button" class="editor-button editor-button-small" title="Delete Level"><i class="fa fa-trash"></i></button>');
880
906
 
881
907
  deleteButton.on('click', function(e) {
@@ -888,7 +914,7 @@
888
914
  }
889
915
  });
890
916
 
891
- row.append(fromTimeWrapper).append(levelWrapper).append(deleteButton);
917
+ row.append(fromTimeWrapper).append(levelWrapper).append(immediateWrapper).append(deleteButton);
892
918
  $list.append(row);
893
919
  };
894
920
 
@@ -1094,10 +1120,12 @@
1094
1120
  $("#levels-list li").each(function() {
1095
1121
  const fromTime = $(this).find('.from-time-input').val().trim();
1096
1122
  const level = $(this).find('.level-input').val();
1123
+ const immediate = $(this).find('.immediate-checkbox').prop('checked') === true;
1097
1124
  if (fromTime && level !== '') {
1098
1125
  levels.push({
1099
1126
  fromTime: fromTime,
1100
- level: parseInt(level)
1127
+ level: parseInt(level),
1128
+ immediate: immediate
1101
1129
  });
1102
1130
  }
1103
1131
  });
@@ -1262,6 +1290,7 @@
1262
1290
  - **Light levels**: Time-based brightness levels throughout the day
1263
1291
  - **From time**: Time when this level becomes active (HH:MM format, 24-hour)
1264
1292
  - **Level**: Brightness percentage (0-100%)
1293
+ - **Immediate**: When checked, this level is applied immediately when the time is reached (if motion was detected within the timeout period). When unchecked (default), level is only applied when motion is detected.
1265
1294
  - **Override**: Manual control to override automatic behavior
1266
1295
  - **Off**: Turn all lights off and block automatic control
1267
1296
  - **On**: Set lights to their normal automatic level
@@ -1281,6 +1310,7 @@
1281
1310
  - When away sensor activates, lights are set to away level after configured delay
1282
1311
  - When night sensor activates, lights are set to night level after configured delay
1283
1312
  - Both night and away sensors can be inverted to reverse their logic
1313
+ - **Immediate levels**: If a time-based level has "Immediate" checked and the scheduled time is reached while motion was detected within the timeout, that level is applied immediately without waiting for new motion
1284
1314
  - Override mode blocks all automatic behavior until returned to auto
1285
1315
  - If brightness limit is configured, lights only turn on when brightness passes the threshold
1286
1316
  - When brightness crosses threshold (lights allowed to be on) and motion was detected within timeout, lights turn on automatically
@@ -103,7 +103,8 @@ module.exports = function (RED) {
103
103
 
104
104
  // Mutable state
105
105
  const state = {
106
- timedOut: undefined // Tracks if all triggers are currently off
106
+ timedOut: undefined, // Tracks if all triggers are currently off
107
+ lastImmediateTime: null // Tracks the last fromTime when immediate level was applied
107
108
  };
108
109
 
109
110
  let timeoutCheckInterval = null; // Timer for checking timeouts every minute
@@ -188,7 +189,7 @@ module.exports = function (RED) {
188
189
  debugLog('Applying night level to lights');
189
190
  const level = funcs.findCurrentLevel(nodeConfig, nodeWrapper);
190
191
  if (level !== null) {
191
- funcs.controlLights(nodeConfig.lights, level, nodeWrapper, homeAssistant);
192
+ funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant);
192
193
  node.status({ fill: "blue", shape: "dot", text: `Night mode: ${level}%` });
193
194
  } else {
194
195
  node.warn('Could not determine level for night mode');
@@ -209,7 +210,7 @@ module.exports = function (RED) {
209
210
  awayActivationTimer = setTimeout(() => {
210
211
  debugLog('Applying away level to lights');
211
212
  const level = nodeConfig.awaySensor.level !== null && nodeConfig.awaySensor.level !== undefined ? nodeConfig.awaySensor.level : 0;
212
- funcs.controlLights(nodeConfig.lights, level, nodeWrapper, homeAssistant);
213
+ funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant);
213
214
  node.status({ fill: "yellow", shape: "dot", text: `Away mode: ${level}%` });
214
215
  }, nodeConfig.awaySensor.delay * 1000);
215
216
  }
@@ -396,24 +397,24 @@ module.exports = function (RED) {
396
397
  // Apply override actions immediately after config is sent
397
398
  if (payload.config.override !== undefined) {
398
399
  if (nodeConfig.override === 'off') {
399
- funcs.turnOffAllLights(nodeConfig.lights, nodeWrapper, homeAssistant);
400
+ funcs.turnOffAllLights(nodeConfig, nodeConfig.lights, nodeWrapper, homeAssistant);
400
401
  node.status({ fill: "red", shape: "dot", text: "Override: OFF" });
401
402
  debugLog('Override: OFF - lights turned off');
402
403
  } else if (nodeConfig.override === 'on') {
403
404
  // Set lights to the appropriate automatic level and keep them there
404
405
  const level = funcs.findCurrentLevel(nodeConfig, nodeWrapper);
405
406
  if (level !== null) {
406
- funcs.controlLights(nodeConfig.lights, level, nodeWrapper, homeAssistant);
407
+ funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant);
407
408
  node.status({ fill: "green", shape: "dot", text: `Override: ON (${level}%)` });
408
409
  debugLog(`Override: ON - lights set to ${level}% (locked)`);
409
410
  } else {
410
411
  node.warn('Override ON: Could not determine level, using 100%');
411
- funcs.controlLights(nodeConfig.lights, 100, nodeWrapper, homeAssistant);
412
+ funcs.controlLights(nodeConfig, nodeConfig.lights, 100, nodeWrapper, homeAssistant);
412
413
  node.status({ fill: "green", shape: "dot", text: "Override: ON (100%)" });
413
414
  debugLog('Override: ON - lights set to 100% (fallback)');
414
415
  }
415
416
  } else if (typeof nodeConfig.override === 'number' && nodeConfig.override >= 0 && nodeConfig.override <= 100) {
416
- funcs.controlLights(nodeConfig.lights, nodeConfig.override, nodeWrapper, homeAssistant);
417
+ funcs.controlLights(nodeConfig, nodeConfig.lights, nodeConfig.override, nodeWrapper, homeAssistant);
417
418
  node.status({ fill: "green", shape: "dot", text: `Override: ${nodeConfig.override}%` });
418
419
  debugLog(`Override: ${nodeConfig.override}% - lights set to ${nodeConfig.override}%`);
419
420
  } else if (nodeConfig.override === 'auto') {
@@ -425,13 +426,13 @@ module.exports = function (RED) {
425
426
  debugLog('Triggers active, setting lights to appropriate level');
426
427
  const level = funcs.findCurrentLevel(nodeConfig, nodeWrapper);
427
428
  if (level !== null) {
428
- funcs.controlLights(nodeConfig.lights, level, nodeWrapper, homeAssistant);
429
+ funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant);
429
430
  node.status({ fill: "green", shape: "dot", text: `AUTO: ${level}%` });
430
431
  debugLog(`Lights set to ${level}% (auto mode with active triggers)`);
431
432
  }
432
433
  } else if (state.timedOut === true) {
433
434
  debugLog('Triggers timed out, turning lights off');
434
- funcs.controlLights(nodeConfig.lights, 0, nodeWrapper, homeAssistant);
435
+ funcs.controlLights(nodeConfig, nodeConfig.lights, 0, nodeWrapper, homeAssistant);
435
436
  node.status({ fill: "yellow", shape: "ring", text: "AUTO: Timed out (off)" });
436
437
  debugLog('Lights turned off (auto mode with timed out triggers)');
437
438
  }
@@ -447,12 +448,12 @@ module.exports = function (RED) {
447
448
  debugLog(`Level input received: ${level}`);
448
449
 
449
450
  if (level === 'off') {
450
- funcs.turnOffAllLights(nodeConfig.lights, nodeWrapper, homeAssistant);
451
+ funcs.turnOffAllLights(nodeConfig, nodeConfig.lights, nodeWrapper, homeAssistant);
451
452
  debugLog('Level: OFF - lights turned off');
452
453
  } else if (level === 'on') {
453
454
  const currentLevel = funcs.findCurrentLevel(nodeConfig, nodeWrapper);
454
455
  if (currentLevel !== null) {
455
- funcs.controlLights(nodeConfig.lights, currentLevel, nodeWrapper, homeAssistant);
456
+ funcs.controlLights(nodeConfig, nodeConfig.lights, currentLevel, nodeWrapper, homeAssistant);
456
457
  debugLog(`Level: ON - lights set to ${currentLevel}%`);
457
458
  } else {
458
459
  node.warn('Level: ON - could not determine level');
@@ -462,15 +463,15 @@ module.exports = function (RED) {
462
463
  if (state.timedOut === false) {
463
464
  const currentLevel = funcs.findCurrentLevel(nodeConfig, nodeWrapper);
464
465
  if (currentLevel !== null) {
465
- funcs.controlLights(nodeConfig.lights, currentLevel, nodeWrapper, homeAssistant);
466
+ funcs.controlLights(nodeConfig, nodeConfig.lights, currentLevel, nodeWrapper, homeAssistant);
466
467
  debugLog(`Level: AUTO - lights set to ${currentLevel}% (triggers active)`);
467
468
  }
468
469
  } else {
469
- funcs.turnOffAllLights(nodeConfig.lights, nodeWrapper, homeAssistant);
470
+ funcs.turnOffAllLights(nodeConfig, nodeConfig.lights, nodeWrapper, homeAssistant);
470
471
  debugLog('Level: AUTO - lights turned off (timed out)');
471
472
  }
472
473
  } else if (typeof level === 'number' && level >= 0 && level <= 100) {
473
- funcs.controlLights(nodeConfig.lights, level, nodeWrapper, homeAssistant);
474
+ funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant);
474
475
  debugLog(`Level: ${level}% - lights set to ${level}%`);
475
476
  } else {
476
477
  node.warn(`Invalid level value: ${level}`);
@@ -606,24 +607,24 @@ module.exports = function (RED) {
606
607
  if (nodeConfig.override !== 'auto') {
607
608
  debugLog(`Applying override from config: ${nodeConfig.override}`);
608
609
  if (nodeConfig.override === 'off') {
609
- funcs.turnOffAllLights(nodeConfig.lights, nodeWrapper, homeAssistant);
610
+ funcs.turnOffAllLights(nodeConfig, nodeConfig.lights, nodeWrapper, homeAssistant);
610
611
  node.status({ fill: "red", shape: "dot", text: "Override: OFF" });
611
612
  debugLog('Override: OFF applied on startup');
612
613
  } else if (nodeConfig.override === 'on') {
613
614
  // Set lights to the appropriate automatic level and keep them there
614
615
  const level = funcs.findCurrentLevel(nodeConfig, nodeWrapper);
615
616
  if (level !== null) {
616
- funcs.controlLights(nodeConfig.lights, level, nodeWrapper, homeAssistant);
617
+ funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant);
617
618
  node.status({ fill: "green", shape: "dot", text: `Override: ON (${level}%)` });
618
619
  debugLog(`Override: ON applied on startup - ${level}% (locked)`);
619
620
  } else {
620
621
  node.warn('Override ON on startup: Could not determine level, using 100%');
621
- funcs.controlLights(nodeConfig.lights, 100, nodeWrapper, homeAssistant);
622
+ funcs.controlLights(nodeConfig, nodeConfig.lights, 100, nodeWrapper, homeAssistant);
622
623
  node.status({ fill: "green", shape: "dot", text: "Override: ON (100%)" });
623
624
  debugLog('Override: ON applied on startup - 100% (fallback)');
624
625
  }
625
626
  } else if (typeof nodeConfig.override === 'number') {
626
- funcs.controlLights(nodeConfig.lights, nodeConfig.override, nodeWrapper, homeAssistant);
627
+ funcs.controlLights(nodeConfig, nodeConfig.lights, nodeConfig.override, nodeWrapper, homeAssistant);
627
628
  node.status({ fill: "green", shape: "dot", text: `Override: ${nodeConfig.override}%` });
628
629
  debugLog(`Override: ${nodeConfig.override}% applied on startup`);
629
630
  }