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 +17 -18
- package/src/elvia/elvia-api.js +1 -1
- package/src/light-saver-functions.js +125 -45
- package/src/light-saver.html +36 -6
- package/src/light-saver.js +19 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-power-saver",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
51
|
-
"@vuepress/plugin-google-analytics": "2.0.0-rc.
|
|
52
|
-
"@vuepress/plugin-register-components": "2.0.0-rc.
|
|
53
|
-
"@vuepress/plugin-search": "2.0.0-rc.
|
|
54
|
-
"@vuepress/theme-default": "2.0.0-rc.
|
|
55
|
-
"@vuepress/utils": "2.0.0-rc.
|
|
56
|
-
"chai": "
|
|
57
|
-
"eslint": "
|
|
58
|
-
"expect": "
|
|
59
|
-
"mocha": "^11.
|
|
60
|
-
"node-red": "^4.
|
|
61
|
-
"node-red-node-test-helper": "0.3.
|
|
62
|
-
"sass-embedded": "^1.
|
|
63
|
-
"sass-loader": "^16.0.
|
|
64
|
-
"vuepress": "2.0.0-rc.
|
|
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
|
}
|
package/src/elvia/elvia-api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
582
|
+
debugLog(config, node, 'All entities already have states');
|
|
515
583
|
} else {
|
|
516
|
-
node
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/light-saver.html
CHANGED
|
@@ -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
|
package/src/light-saver.js
CHANGED
|
@@ -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
|
}
|