node-red-contrib-timer-events 0.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.
@@ -0,0 +1,1528 @@
1
+ /**
2
+ * timer-events
3
+ * A Node-RED timer node with variable delay, pause/resume, persistence,
4
+ * ignored message handling, threshold actions, heartbeat, and a
5
+ * purpose-built 4-output event model:
6
+ * 1. Start - fires only on a true stopped/expired -> running transition
7
+ * 2. Stop - fires only on a true stop or natural expiry
8
+ * 3. Query - fires on an incoming query message, or on a heartbeat tick
9
+ * 4. Events - fires for every other event, including tagged copies of
10
+ * ignored/blocked commands
11
+ *
12
+ * Derived from stoptimer-varidelay-plus.
13
+ * Modifications copyright (C) 2025 mchristegh
14
+ * Modifications copyright (C) 2020 hamsando
15
+ * Copyright jbardi
16
+ *
17
+ * Licensed under the Apache License, Version 2.0 (the "License");
18
+ * you may not use this file except in compliance with the License.
19
+ * You may obtain a copy of the License at
20
+ *
21
+ * http://www.apache.org/licenses/LICENSE-2.0
22
+ *
23
+ * Unless required by applicable law or agreed to in writing, software
24
+ * distributed under the License is distributed on an "AS IS" BASIS,
25
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
26
+ * See the License for the specific language governing permissions and
27
+ * limitations under the License.
28
+ **/
29
+
30
+ module.exports = function(RED) {
31
+ "use strict";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Module-level constants
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const TIMER_STATE = {
38
+ RUNNING: "running",
39
+ PAUSED: "paused",
40
+ STOPPED: "stopped",
41
+ EXPIRED: "expired",
42
+ COOLDOWN: "cooldown"
43
+ };
44
+
45
+ // Canonical event-type list for output 4 (and output 3, for QUERY).
46
+ // Note: a "restart" (a new/duplicate start while the timer is already
47
+ // running or paused) is intentionally NOT the same as STARTED - it is
48
+ // treated as a larger sibling of TIMESET (see design notes on
49
+ // handleInputEvent) and never appears on output 1.
50
+ const TIMER_EVENT = {
51
+ STARTED: "started",
52
+ RESTARTED: "restarted",
53
+ STOPPED: "stopped",
54
+ EXPIRED: "expired",
55
+ PAUSED: "paused",
56
+ RESUMED: "resumed",
57
+ LOCKED: "locked",
58
+ UNLOCKED: "unlocked",
59
+ DISABLED: "disabled",
60
+ ENABLED: "enabled",
61
+ TIMEADJUSTED: "timeadjusted",
62
+ TIMESET: "timeset",
63
+ DURATIONSET: "durationset",
64
+ WARNING: "warning",
65
+ QUERY: "query",
66
+ COOLDOWNSTARTED: "cooldownstarted",
67
+ COOLDOWNENDED: "cooldownended"
68
+ };
69
+
70
+ // Identifies whether an event was triggered by a live incoming message
71
+ // ("external") or by the node itself ("internal") - e.g. a heartbeat
72
+ // tick or a threshold action firing on its own.
73
+ const EVENT_SOURCE = {
74
+ EXTERNAL: "external",
75
+ INTERNAL: "internal"
76
+ };
77
+
78
+ const UNITS = {
79
+ MILLISECOND: "Millisecond",
80
+ SECOND: "Second",
81
+ MINUTE: "Minute",
82
+ HOUR: "Hour"
83
+ };
84
+
85
+ const UNITS_INPUT = {
86
+ MILLISECOND: "millisecond",
87
+ SECOND: "second",
88
+ MINUTE: "minute",
89
+ HOUR: "hour"
90
+ };
91
+
92
+ // Threshold action config values. Note the RESET action results in the
93
+ // node emitting TIMER_EVENT.RESTARTED (not a "reset" event name) since a
94
+ // threshold-triggered reset is treated identically to a message-triggered
95
+ // restart - see handleThresholdAction().
96
+ const THRESHOLD_ACTION = {
97
+ DONOTHING: "donothing",
98
+ STOP: "stop",
99
+ PAUSE: "pause",
100
+ RESET: "reset",
101
+ ADDTIME: "addtime",
102
+ WARNING: "warning"
103
+ };
104
+
105
+ const PAYLOAD = {
106
+ STOP: "stop",
107
+ PAUSE: "pause",
108
+ RESUME: "resume",
109
+ QUERY: "query",
110
+ LOCK: "lock",
111
+ UNLOCK: "unlock",
112
+ DISABLE: "disable",
113
+ ENABLE: "enable",
114
+ ADJUSTTIME: "adjusttime",
115
+ SETTIME: "settime",
116
+ SETDURATION: "setduration"
117
+ };
118
+
119
+ const REPORTING_FORMAT = {
120
+ HUMAN: "human",
121
+ SECONDS: "seconds",
122
+ MINUTES: "minutes",
123
+ HOURS: "hours"
124
+ };
125
+
126
+ // Reporting only drives the node's status label now (see startReporting).
127
+ // It no longer produces its own output message - that role is served by
128
+ // the query output (manual query or heartbeat tick).
129
+ const REPORTING = {
130
+ NONE: "none",
131
+ EVERY_SECOND: "every_second",
132
+ LAST_MINUTE_SECONDS: "last_minute_seconds"
133
+ };
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Node definition
137
+ // ---------------------------------------------------------------------------
138
+
139
+ function TimerEvents(n) {
140
+ RED.nodes.createNode(this, n);
141
+ let fs = require('fs');
142
+ let path = require('path');
143
+ let nodefile = n.id.toString();
144
+ let nodepath = "";
145
+ require('./cycle.js');
146
+
147
+ if (n._alias != null) {
148
+ nodepath = n._flow.path.replace(/\//g, "-") + "-";
149
+ nodefile = n._alias;
150
+ }
151
+
152
+ const stvdtimersFile = path.join(RED.settings.userDir, "timerevents-timers", nodepath + nodefile);
153
+
154
+ // -------------------------------------------------------------------------
155
+ // Node property initialization
156
+ // -------------------------------------------------------------------------
157
+
158
+ this.units = n.units || UNITS.SECOND;
159
+ this.durationType = n.durationType;
160
+ this.duration = isNaN(Number(RED.util.evaluateNodeProperty(n.duration, this.durationType, this, null))) ? 5 : Number(RED.util.evaluateNodeProperty(n.duration, this.durationType, this, null));
161
+ this.reporting = n.reporting || REPORTING.NONE;
162
+ this.reportingformat = n.reportingformat || REPORTING_FORMAT.HUMAN;
163
+ this.persist = n.persist || false;
164
+ this.ignoretimerpass = n.ignoretimerpass || false;
165
+ this.donotresettimer = n.donotresettimer || false;
166
+ this.thresholdaction = n.thresholdaction || THRESHOLD_ACTION.DONOTHING;
167
+ this.thresholdcount = isNaN(Number(n.thresholdcount)) ? 0 : Number(n.thresholdcount);
168
+ this.thresholdaddtime = isNaN(Number(n.thresholdaddtime)) ? 0 : Number(n.thresholdaddtime);
169
+ this.thresholdaddtimeunits = n.thresholdaddtimeunits || UNITS.SECOND;
170
+ this.heartbeatinterval = isNaN(Number(n.heartbeatinterval)) ? 0 : Number(n.heartbeatinterval);
171
+ this.heartbeatintervalunits = n.heartbeatintervalunits || UNITS.SECOND;
172
+ this.cooldownduration = isNaN(Number(n.cooldownduration)) ? 0 : Number(n.cooldownduration);
173
+ this.cooldownunits = n.cooldownunits || UNITS.SECOND;
174
+
175
+ if (this.duration <= 0) {
176
+ this.duration = 0;
177
+ } else {
178
+ if (this.units === UNITS.SECOND) this.duration = this.duration * 1000;
179
+ if (this.units === UNITS.MINUTE) this.duration = this.duration * 1000 * 60;
180
+ if (this.units === UNITS.HOUR) this.duration = this.duration * 1000 * 60 * 60;
181
+ }
182
+
183
+ let node = this;
184
+
185
+ // -------------------------------------------------------------------------
186
+ // Runtime state variables
187
+ // -------------------------------------------------------------------------
188
+
189
+ let timeout = null;
190
+ let miniTimeout = null;
191
+ let countdown = null;
192
+ let heartbeatTimer = null; // setInterval handle for heartbeat, independent of clearAllTimers
193
+ let stopped = false;
194
+ let paused = false;
195
+ let disabled = false;
196
+ let delayRemainingDisplay = 0;
197
+ let delayFactor = 1000;
198
+ let reporting = this.reporting;
199
+ let reportingformat = this.reportingformat;
200
+
201
+ const maxTimeout = 2147483647;
202
+ let actualDelayInUse = 0;
203
+ let actualDelayRemaining = 0;
204
+
205
+ let ignoredCount = 0;
206
+ let lastIgnoredTime = null;
207
+ let timerRunning = false;
208
+ let timerState = TIMER_STATE.STOPPED;
209
+ let timerStartTime = null;
210
+ let timerDuration = 0;
211
+ let originalMsg = null; // last true start/restart's triggering msg; reused as the
212
+ // payload base for events that have no live triggering msg
213
+ // of their own (expiry, heartbeat, threshold actions, etc.)
214
+ let overrideDuration = null;
215
+
216
+ // Authoritative wall-clock remaining-time state. delayRemainingDisplay /
217
+ // cooldownRemainingDisplay above are display-only counters driven by the
218
+ // cosmetic reporting intervals and must never be used as a source of
219
+ // truth - with reporting set to "none" they do not decrement at all.
220
+ // getRemainingTime() derives the true remaining time from these instead:
221
+ let expiryTarget = null; // ms epoch timestamp the running timer will fire at; null when not running
222
+ let frozenRemaining = null; // exact remaining ms captured at the moment of pause; null when not paused
223
+ let frozenElapsed = null; // exact elapsed ms captured at the moment of pause; null when not paused
224
+ let cooldownExpiryTarget = null; // ms epoch timestamp the cooldown period ends at; null when not in cooldown
225
+
226
+ // Cooldown - a self-expiring, timed block on new starts that begins
227
+ // automatically after a natural expiry (never after an explicit stop).
228
+ // Deliberately kept on its own timer handles, fully independent of the
229
+ // main timeout/countdown/miniTimeout, so clearAllTimers() (used freely
230
+ // elsewhere) can never accidentally interrupt an in-progress cooldown.
231
+ let cooldownActive = false;
232
+ let cooldownRemainingDisplay = 0;
233
+ let cooldownTimeout = null;
234
+ let cooldownReportInterval = null;
235
+ let cooldownReportMiniTimeout = null;
236
+ let actualCooldownDelayInUse = 0;
237
+ let actualCooldownDelayRemaining = 0;
238
+
239
+ // -------------------------------------------------------------------------
240
+ // Persist restore
241
+ // -------------------------------------------------------------------------
242
+
243
+ if (this.persist === true) {
244
+ try {
245
+ if (fs.existsSync(stvdtimersFile)) {
246
+ let savedState = JSON.retrocycle(JSON.parse(readState()));
247
+ let targetMS = (new Date(savedState.time.toString())).getTime();
248
+ let nowMS = (new Date()).getTime();
249
+
250
+ // Note: reporting / reportingformat are deliberately NOT restored
251
+ // from the persisted file - the node's freshly-deployed config
252
+ // always wins, so changing the Status Reporting dropdown and
253
+ // redeploying mid-run takes effect immediately. (The fields are
254
+ // still written to disk for backward compatibility with old
255
+ // persist files; they are simply ignored on read.)
256
+
257
+ if (typeof savedState.ignoredCount !== 'undefined') ignoredCount = savedState.ignoredCount;
258
+ if (typeof savedState.lastIgnoredTime !== 'undefined' && savedState.lastIgnoredTime !== null) {
259
+ lastIgnoredTime = new Date(savedState.lastIgnoredTime);
260
+ }
261
+ if (typeof savedState.timerStartTime !== 'undefined' && savedState.timerStartTime !== null) {
262
+ timerStartTime = new Date(savedState.timerStartTime);
263
+ }
264
+ if (typeof savedState.timerState !== 'undefined') timerState = savedState.timerState;
265
+ if (typeof savedState.donotresettimer !== 'undefined') node.donotresettimer = savedState.donotresettimer;
266
+ if (typeof savedState.overrideDuration !== 'undefined' && savedState.overrideDuration !== null) {
267
+ overrideDuration = savedState.overrideDuration;
268
+ }
269
+ if (typeof savedState.disabled !== 'undefined') disabled = savedState.disabled;
270
+
271
+ if (savedState.cooldownActive === true) {
272
+ let remainingMS = targetMS - nowMS;
273
+ if (remainingMS <= 0) remainingMS = (Math.floor((Math.random() * 5) + 3) * 1000);
274
+ cooldownRemainingDisplay = remainingMS;
275
+ cooldownActive = true;
276
+ cooldownExpiryTarget = Date.now() + remainingMS;
277
+ timerState = TIMER_STATE.COOLDOWN;
278
+ originalMsg = savedState.origmsg;
279
+ node.status(buildStatus(displayTime(cooldownRemainingDisplay, node.reportingformat), TIMER_STATE.COOLDOWN));
280
+ startCooldownTimeout();
281
+ startCooldownReporting();
282
+ // Heartbeat restarts fresh after a restore - does not recalculate original schedule
283
+ startHeartbeat();
284
+ } else if (savedState.paused === true) {
285
+ // A paused timer is FROZEN - Node-RED downtime must not deduct
286
+ // from its remaining time (per the documented "restore as
287
+ // paused at the same remaining time"). Read the persisted
288
+ // frozen snapshot directly; fall back to the legacy
289
+ // target-minus-now calculation only for old persist files that
290
+ // predate the `remaining` field.
291
+ let remainingMS = typeof savedState.remaining === 'number'
292
+ ? savedState.remaining
293
+ : targetMS - nowMS;
294
+ if (remainingMS <= 0) remainingMS = (Math.floor((Math.random() * 5) + 3) * 1000);
295
+ delayRemainingDisplay = remainingMS;
296
+ frozenRemaining = remainingMS;
297
+ timerDuration = typeof savedState.timerDuration !== 'undefined' ? savedState.timerDuration : remainingMS;
298
+ // frozenElapsed is persisted independently because settime
299
+ // while paused changes remaining without touching elapsed -
300
+ // deriving it from duration-remaining would be wrong in that
301
+ // case. Derivation remains the fallback for old files.
302
+ frozenElapsed = typeof savedState.frozenElapsed === 'number'
303
+ ? savedState.frozenElapsed
304
+ : Math.max(0, timerDuration - remainingMS);
305
+ timerStartTime = new Date(nowMS - (timerDuration - remainingMS));
306
+ paused = true;
307
+ timerRunning = false;
308
+ timerState = TIMER_STATE.PAUSED;
309
+ originalMsg = savedState.origmsg;
310
+ node.status(buildStatus(displayTime(delayRemainingDisplay, node.reportingformat), TIMER_STATE.PAUSED));
311
+ // Heartbeat restarts fresh after a restore - does not recalculate original schedule
312
+ startHeartbeat();
313
+ } else {
314
+ if ((targetMS - nowMS) <= 3000) {
315
+ targetMS = (Math.floor((Math.random() * 5) + 3) * 1000);
316
+ } else {
317
+ targetMS = (Math.round((targetMS - nowMS) / 1000)) * 1000;
318
+ }
319
+ savedState.origmsg.units = UNITS_INPUT.MILLISECOND;
320
+ savedState.origmsg.delay = targetMS;
321
+ // Continuation semantics: this is the same run picking up where
322
+ // it left off, so the run's identity survives the restore.
323
+ // Same duration fallback and timerStartTime back-calculation as
324
+ // the paused branch above - Node-RED downtime counts as elapsed
325
+ // time, so elapsedTime + remainingTime reconciles with
326
+ // timerDuration immediately after restore.
327
+ timerDuration = typeof savedState.timerDuration !== 'undefined' ? savedState.timerDuration : targetMS;
328
+ timerStartTime = new Date(nowMS - (timerDuration - targetMS));
329
+ // A running restore is a true stopped/expired -> running transition,
330
+ // so it is treated as a start (output 1) with an internal source.
331
+ // handleInputEvent skips its fresh-run resets when isRestore is
332
+ // true, preserving the timerDuration / timerStartTime /
333
+ // ignoredCount / lastIgnoredTime restored above.
334
+ handleInputEvent(savedState.origmsg, true);
335
+ }
336
+ }
337
+ } catch (error) {
338
+ this.error("Error processing persistent file data for timer-events node " + n.id.toString() + "\n\n" + error.toString());
339
+ }
340
+ } else {
341
+ deleteState();
342
+ }
343
+
344
+ // -------------------------------------------------------------------------
345
+ // Event listeners
346
+ // -------------------------------------------------------------------------
347
+
348
+ this.on("input", function(msg) {
349
+ handleInputEvent(msg, false);
350
+ });
351
+
352
+ this.on("close", function(removed, done) {
353
+ if (timeout) clearTimeout(timeout);
354
+ if (countdown) clearInterval(countdown);
355
+ if (miniTimeout) clearTimeout(miniTimeout);
356
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
357
+ clearCooldownTimers();
358
+ node.status({});
359
+ if (removed) deleteState();
360
+ done();
361
+ });
362
+
363
+ // -------------------------------------------------------------------------
364
+ // Status helper
365
+ // -------------------------------------------------------------------------
366
+
367
+ function buildStatus(timeDisplay, state) {
368
+ let baseText = "";
369
+ let fill = "green";
370
+ let shape = "dot";
371
+
372
+ if (state === TIMER_STATE.STOPPED || state === TIMER_STATE.EXPIRED) {
373
+ fill = state === TIMER_STATE.STOPPED ? "red" : "blue";
374
+ shape = state === TIMER_STATE.STOPPED ? "ring" : "square";
375
+ if (node.donotresettimer) {
376
+ let lastStr = lastIgnoredTime ? formatIgnoredTime(lastIgnoredTime) : "--";
377
+ let stateLabel = state === TIMER_STATE.STOPPED ? "Stopped" : "Expired";
378
+ baseText = stateLabel + " | Ignored: " + ignoredCount + ", Last: " + lastStr;
379
+ } else {
380
+ baseText = state === TIMER_STATE.STOPPED ? "stopped" : "expired";
381
+ }
382
+ } else if (state === TIMER_STATE.PAUSED) {
383
+ fill = "yellow";
384
+ shape = "ring";
385
+ if (node.donotresettimer) {
386
+ let lastStr = lastIgnoredTime ? formatIgnoredTime(lastIgnoredTime) : "--";
387
+ baseText = "Paused: " + timeDisplay + " | Ignored: " + ignoredCount + ", Last: " + lastStr;
388
+ } else {
389
+ baseText = "Paused: " + timeDisplay;
390
+ }
391
+ } else if (state === TIMER_STATE.COOLDOWN) {
392
+ // Deliberately short, no ignored-count/last-ignored detail here -
393
+ // ignored messages during cooldown aren't actionable the way they
394
+ // are while running, so surfacing them would just add clutter.
395
+ fill = "yellow";
396
+ shape = "dot";
397
+ baseText = "Cooldown: " + timeDisplay;
398
+ } else {
399
+ fill = "green";
400
+ shape = "dot";
401
+ if (node.donotresettimer) {
402
+ let lastStr = lastIgnoredTime ? formatIgnoredTime(lastIgnoredTime) : "--";
403
+ baseText = "Remaining: " + timeDisplay + " | Ignored: " + ignoredCount + ", Last: " + lastStr;
404
+ } else {
405
+ baseText = timeDisplay;
406
+ }
407
+ }
408
+
409
+ if (disabled) {
410
+ if (state === TIMER_STATE.COOLDOWN) {
411
+ return { fill: "grey", shape: "ring", text: "Disabled" };
412
+ }
413
+ return { fill: "grey", shape: "ring", text: "Disabled | " + baseText };
414
+ }
415
+
416
+ return { fill: fill, shape: shape, text: baseText };
417
+ }
418
+
419
+ // -------------------------------------------------------------------------
420
+ // Utility helpers
421
+ // -------------------------------------------------------------------------
422
+
423
+ function formatIgnoredTime(date) {
424
+ const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
425
+ return months[date.getMonth()] + " " +
426
+ String(date.getDate()).padStart(2, "0") + " " +
427
+ String(date.getHours()).padStart(2, "0") + ":" +
428
+ String(date.getMinutes()).padStart(2, "0") + ":" +
429
+ String(date.getSeconds()).padStart(2, "0");
430
+ }
431
+
432
+ /**
433
+ * State-aware elapsed time, the mirror image of getRemainingTime():
434
+ * - running: wall-clock time since timerStartTime
435
+ * - paused: the exact snapshot frozen at the moment of pause
436
+ * - cooldown: time INTO the cooldown period (so during cooldown
437
+ * elapsedTime + remainingTime ~= the cooldown duration,
438
+ * symmetric with remainingTime reporting cooldown time)
439
+ * - idle (stopped/expired): 0 - there is no current run. The genuine
440
+ * stopped/expired events still carry the run's final elapsed value,
441
+ * snapshotted just before the state flips (see their dispatch sites).
442
+ */
443
+ function getElapsedTime() {
444
+ if (timerState === TIMER_STATE.COOLDOWN) {
445
+ let cooldownFullMS = convertToMilliseconds(node.cooldownduration, node.cooldownunits);
446
+ return Math.max(0, cooldownFullMS - getRemainingTime());
447
+ }
448
+ if (paused) {
449
+ return frozenElapsed !== null ? frozenElapsed : 0;
450
+ }
451
+ if (timerRunning && timerStartTime !== null) {
452
+ return Date.now() - timerStartTime.getTime();
453
+ }
454
+ return 0;
455
+ }
456
+
457
+ /**
458
+ * The single authoritative source for "remaining time right now",
459
+ * computed from wall-clock targets rather than the display counters
460
+ * (which only tick when Status Reporting is enabled):
461
+ * - cooldown: time until cooldownExpiryTarget
462
+ * - paused: the exact snapshot frozen at the moment of pause
463
+ * - running: time until expiryTarget
464
+ * - idle (stopped/expired): 0
465
+ * Returns exact milliseconds - rounding for the status label happens
466
+ * only at the display boundary (see displayRemaining).
467
+ */
468
+ function getRemainingTime() {
469
+ if (timerState === TIMER_STATE.COOLDOWN) {
470
+ return cooldownExpiryTarget !== null ? Math.max(0, cooldownExpiryTarget - Date.now()) : 0;
471
+ }
472
+ if (paused) {
473
+ return frozenRemaining !== null ? frozenRemaining : 0;
474
+ }
475
+ if (timerRunning && expiryTarget !== null) {
476
+ return Math.max(0, expiryTarget - Date.now());
477
+ }
478
+ return 0;
479
+ }
480
+
481
+ /**
482
+ * Display-boundary rounding only: the raw ms value from
483
+ * getRemainingTime() goes on outgoing messages untouched; the status
484
+ * label gets the nearest whole second so displayTime()'s HH:MM:SS
485
+ * formatting never renders fractional seconds.
486
+ */
487
+ function displayRemaining(fmt) {
488
+ return displayTime(Math.round(getRemainingTime() / 1000) * 1000, fmt);
489
+ }
490
+
491
+ function convertToMilliseconds(value, units) {
492
+ switch (units) {
493
+ case UNITS.SECOND: return value * 1000;
494
+ case UNITS.MINUTE: return value * 1000 * 60;
495
+ case UNITS.HOUR: return value * 1000 * 60 * 60;
496
+ case UNITS.MILLISECOND: return value;
497
+ default: return value;
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Strict numeric validation for values arriving on incoming messages.
503
+ * Returns a finite number, or NaN for anything unusable: missing
504
+ * properties, non-numeric strings ("5s", "abc"), empty/whitespace
505
+ * strings (Number("") is 0, which would silently mean "fire now"),
506
+ * booleans, objects, and +/-Infinity. NaN <= 0 is false, which is how
507
+ * NaN slipped past the settime/setduration validity checks - callers
508
+ * must test the result with Number.isFinite before using it.
509
+ */
510
+ function toFiniteNumber(value) {
511
+ if (typeof value === 'number') {
512
+ return Number.isFinite(value) ? value : NaN;
513
+ }
514
+ if (typeof value === 'string' && value.trim() !== '') {
515
+ let num = Number(value);
516
+ return Number.isFinite(num) ? num : NaN;
517
+ }
518
+ return NaN;
519
+ }
520
+
521
+ function normalizeUnits(units) {
522
+ return typeof units === 'string' ? units.toLowerCase().replace(/s$/, '') : null;
523
+ }
524
+
525
+ function msgValueToMs(value, units) {
526
+ switch (units) {
527
+ case UNITS_INPUT.SECOND: return value * 1000;
528
+ case UNITS_INPUT.MINUTE: return value * 1000 * 60;
529
+ case UNITS_INPUT.HOUR: return value * 1000 * 60 * 60;
530
+ default: return value;
531
+ }
532
+ }
533
+
534
+ // -------------------------------------------------------------------------
535
+ // Event message construction + output dispatch
536
+ // -------------------------------------------------------------------------
537
+
538
+ /**
539
+ * Builds the standard event message envelope by cloning a base message
540
+ * (either the live triggering msg, or originalMsg when there is no live
541
+ * trigger - e.g. expiry, heartbeat, threshold actions) and layering the
542
+ * standard state/metadata fields on top, including the `ignored` and
543
+ * `source` fields used across every output.
544
+ */
545
+ function buildEventMessage(timerEvent, baseMsg, ignored, source) {
546
+ let evtMsg = RED.util.cloneMessage(baseMsg || {});
547
+ evtMsg.timerEvent = timerEvent;
548
+ evtMsg.timerState = timerState;
549
+ evtMsg.remainingTime = getRemainingTime();
550
+ evtMsg.timerDuration = timerDuration;
551
+ evtMsg.elapsedTime = getElapsedTime();
552
+ evtMsg.ignoredCount = ignoredCount;
553
+ evtMsg.lastIgnoredTime = lastIgnoredTime ? lastIgnoredTime.toISOString() : null;
554
+ evtMsg.doNotResetTimer = node.donotresettimer;
555
+ evtMsg.disabled = disabled;
556
+ evtMsg.ignored = ignored;
557
+ evtMsg.source = source;
558
+ return evtMsg;
559
+ }
560
+
561
+ /**
562
+ * Central output router for every timer event. Applies the fixed
563
+ * output-exclusivity rules:
564
+ * - Output 1 (Start): TIMER_EVENT.STARTED only, and only when ignored
565
+ * is false. A true start always also fires on
566
+ * output 4.
567
+ * - Output 2 (Stop): TIMER_EVENT.STOPPED or EXPIRED only, and only
568
+ * when ignored is false. Always also fires on
569
+ * output 4.
570
+ * - Output 3 (Query): TIMER_EVENT.QUERY only. Never fires on output 4.
571
+ * - Output 4 (Events): every event except QUERY, including ignored
572
+ * copies of what would otherwise be output 1/2
573
+ * events.
574
+ *
575
+ * extraProps allows event-specific fields (e.g. timeAdjusted, timeSet,
576
+ * durationSet) to be layered onto the built message.
577
+ */
578
+ function dispatchEvent(timerEvent, baseMsg, ignored, source, extraProps) {
579
+ let evtMsg = buildEventMessage(timerEvent, baseMsg, ignored, source);
580
+ if (extraProps) {
581
+ for (let key in extraProps) {
582
+ if (Object.prototype.hasOwnProperty.call(extraProps, key)) evtMsg[key] = extraProps[key];
583
+ }
584
+ }
585
+
586
+ if (timerEvent === TIMER_EVENT.QUERY) {
587
+ node.send([null, null, evtMsg, null]);
588
+ return;
589
+ }
590
+
591
+ let out1 = null;
592
+ let out2 = null;
593
+ let out4 = evtMsg;
594
+
595
+ if (!ignored) {
596
+ if (timerEvent === TIMER_EVENT.STARTED) {
597
+ out1 = RED.util.cloneMessage(evtMsg);
598
+ } else if (timerEvent === TIMER_EVENT.STOPPED || timerEvent === TIMER_EVENT.EXPIRED) {
599
+ out2 = RED.util.cloneMessage(evtMsg);
600
+ }
601
+ }
602
+
603
+ node.send([out1, out2, null, out4]);
604
+ }
605
+
606
+ // -------------------------------------------------------------------------
607
+ // Timer management helpers
608
+ // -------------------------------------------------------------------------
609
+
610
+ /**
611
+ * Clears the main timeout, countdown interval, and miniTimeout.
612
+ * Does NOT clear the heartbeat - heartbeat runs on a fixed schedule
613
+ * independent of pause/resume/adjusttime/settime/threshold actions.
614
+ */
615
+ function clearAllTimers() {
616
+ clearTimeout(timeout);
617
+ clearTimeout(miniTimeout);
618
+ clearInterval(countdown);
619
+ timeout = null;
620
+ countdown = null;
621
+ miniTimeout = null;
622
+ }
623
+
624
+ /**
625
+ * Starts the heartbeat interval if heartbeatinterval is configured (> 0).
626
+ * Clears any existing heartbeat interval first to avoid duplicates.
627
+ * Runs on a fixed wall-clock schedule, unaffected by pause, resume,
628
+ * adjusttime, settime, or threshold actions. Fires while running AND
629
+ * while paused. Only stopped explicitly when the timer stops or expires.
630
+ * Each tick triggers a QUERY event (output 3) with source "internal",
631
+ * carrying a full status snapshot - the consumer can read timerState
632
+ * from that snapshot to tell whether the tick landed while running or
633
+ * paused.
634
+ */
635
+ function startHeartbeat() {
636
+ if (heartbeatTimer) {
637
+ clearInterval(heartbeatTimer);
638
+ heartbeatTimer = null;
639
+ }
640
+ if (node.heartbeatinterval > 0) {
641
+ let intervalMS = convertToMilliseconds(node.heartbeatinterval, node.heartbeatintervalunits);
642
+ if (intervalMS > 0) {
643
+ heartbeatTimer = setInterval(function() {
644
+ dispatchEvent(TIMER_EVENT.QUERY, originalMsg, false, EVENT_SOURCE.INTERNAL);
645
+ }, intervalMS);
646
+ }
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Stops the heartbeat interval. Called when the timer stops or expires.
652
+ */
653
+ function stopHeartbeat() {
654
+ if (heartbeatTimer) {
655
+ clearInterval(heartbeatTimer);
656
+ heartbeatTimer = null;
657
+ }
658
+ }
659
+
660
+ function startTimeout(msg) {
661
+ actualDelayRemaining = delayRemainingDisplay;
662
+ if (actualDelayRemaining > maxTimeout) {
663
+ actualDelayInUse = maxTimeout;
664
+ actualDelayRemaining = actualDelayRemaining - maxTimeout;
665
+ } else {
666
+ actualDelayInUse = actualDelayRemaining;
667
+ actualDelayRemaining = 0;
668
+ }
669
+ timeout = setTimeout(timerElapsed, actualDelayInUse, msg);
670
+ }
671
+
672
+ /**
673
+ * Drives the node's status label only. This no longer produces any
674
+ * output message - periodic "time remaining" reporting on an output was
675
+ * replaced by the query output (manual query or heartbeat tick). The
676
+ * adaptive every-minute-then-every-second cadence is retained purely
677
+ * for the on-canvas status display.
678
+ */
679
+ function startReporting() {
680
+ if (reporting === REPORTING.NONE) {
681
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.RUNNING));
682
+ return;
683
+ }
684
+
685
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.RUNNING));
686
+
687
+ if ((delayRemainingDisplay > 60000) && (reporting === REPORTING.LAST_MINUTE_SECONDS)) {
688
+ miniTimeout = setTimeout(function() {
689
+ if ((delayRemainingDisplay % 60000) !== 0) {
690
+ delayRemainingDisplay -= (delayRemainingDisplay % 60000);
691
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.RUNNING));
692
+ }
693
+
694
+ if (delayRemainingDisplay <= 60000) {
695
+ countdown = setInterval(function() {
696
+ delayRemainingDisplay -= 1000;
697
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.RUNNING));
698
+ }, 1000);
699
+ } else {
700
+ countdown = setInterval(function() {
701
+ if (delayRemainingDisplay > 60000) {
702
+ delayRemainingDisplay -= 60000;
703
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.RUNNING));
704
+ }
705
+ if (delayRemainingDisplay <= 60000) {
706
+ clearInterval(countdown);
707
+ countdown = null;
708
+ countdown = setInterval(function() {
709
+ delayRemainingDisplay -= 1000;
710
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.RUNNING));
711
+ }, 1000);
712
+ }
713
+ }, 60000);
714
+ }
715
+ miniTimeout = null;
716
+ }, delayRemainingDisplay % 60000);
717
+
718
+ } else {
719
+ countdown = setInterval(function() {
720
+ delayRemainingDisplay -= 1000;
721
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.RUNNING));
722
+ }, 1000);
723
+ }
724
+ }
725
+
726
+ // -------------------------------------------------------------------------
727
+ // Cooldown management
728
+ // -------------------------------------------------------------------------
729
+
730
+ /**
731
+ * Clears every cooldown-specific timer handle. Deliberately separate
732
+ * from clearAllTimers() - a cooldown in progress must never be
733
+ * interrupted by the normal timer's own timeout/countdown handling.
734
+ */
735
+ function clearCooldownTimers() {
736
+ clearTimeout(cooldownTimeout);
737
+ clearInterval(cooldownReportInterval);
738
+ clearTimeout(cooldownReportMiniTimeout);
739
+ cooldownTimeout = null;
740
+ cooldownReportInterval = null;
741
+ cooldownReportMiniTimeout = null;
742
+ }
743
+
744
+ /**
745
+ * Begins a cooldown period following a natural expiry. Only ever called
746
+ * right after TIMER_EVENT.EXPIRED has been dispatched. Heartbeat is left
747
+ * running uninterrupted (it fires regardless of running/paused/cooldown
748
+ * state) rather than being stopped and restarted.
749
+ */
750
+ function startCooldown(baseMsg) {
751
+ let cooldownMS = convertToMilliseconds(node.cooldownduration, node.cooldownunits);
752
+ if (cooldownMS <= 0) return; // cooldown disabled - caller handles true idle expiry
753
+
754
+ cooldownActive = true;
755
+ cooldownRemainingDisplay = cooldownMS;
756
+ cooldownExpiryTarget = Date.now() + cooldownMS;
757
+ timerState = TIMER_STATE.COOLDOWN;
758
+ writeState(baseMsg);
759
+ node.status(buildStatus(displayTime(cooldownRemainingDisplay, reportingformat), TIMER_STATE.COOLDOWN));
760
+ dispatchEvent(TIMER_EVENT.COOLDOWNSTARTED, baseMsg, false, EVENT_SOURCE.INTERNAL);
761
+ startCooldownTimeout();
762
+ startCooldownReporting();
763
+ }
764
+
765
+ function startCooldownTimeout() {
766
+ actualCooldownDelayRemaining = cooldownRemainingDisplay;
767
+ if (actualCooldownDelayRemaining > maxTimeout) {
768
+ actualCooldownDelayInUse = maxTimeout;
769
+ actualCooldownDelayRemaining = actualCooldownDelayRemaining - maxTimeout;
770
+ } else {
771
+ actualCooldownDelayInUse = actualCooldownDelayRemaining;
772
+ actualCooldownDelayRemaining = 0;
773
+ }
774
+ cooldownTimeout = setTimeout(cooldownElapsed, actualCooldownDelayInUse);
775
+ }
776
+
777
+ /**
778
+ * Drives the cooldown status label only, same adaptive
779
+ * every-minute-then-every-second cadence as startReporting(), kept as a
780
+ * separate implementation so it never shares timer handles with the
781
+ * main countdown.
782
+ */
783
+ function startCooldownReporting() {
784
+ if (reporting === REPORTING.NONE) {
785
+ node.status(buildStatus(displayTime(cooldownRemainingDisplay, reportingformat), TIMER_STATE.COOLDOWN));
786
+ return;
787
+ }
788
+
789
+ node.status(buildStatus(displayTime(cooldownRemainingDisplay, reportingformat), TIMER_STATE.COOLDOWN));
790
+
791
+ if ((cooldownRemainingDisplay > 60000) && (reporting === REPORTING.LAST_MINUTE_SECONDS)) {
792
+ cooldownReportMiniTimeout = setTimeout(function() {
793
+ if ((cooldownRemainingDisplay % 60000) !== 0) {
794
+ cooldownRemainingDisplay -= (cooldownRemainingDisplay % 60000);
795
+ node.status(buildStatus(displayTime(cooldownRemainingDisplay, reportingformat), TIMER_STATE.COOLDOWN));
796
+ }
797
+
798
+ if (cooldownRemainingDisplay <= 60000) {
799
+ cooldownReportInterval = setInterval(function() {
800
+ cooldownRemainingDisplay -= 1000;
801
+ node.status(buildStatus(displayTime(cooldownRemainingDisplay, reportingformat), TIMER_STATE.COOLDOWN));
802
+ }, 1000);
803
+ } else {
804
+ cooldownReportInterval = setInterval(function() {
805
+ if (cooldownRemainingDisplay > 60000) {
806
+ cooldownRemainingDisplay -= 60000;
807
+ node.status(buildStatus(displayTime(cooldownRemainingDisplay, reportingformat), TIMER_STATE.COOLDOWN));
808
+ }
809
+ if (cooldownRemainingDisplay <= 60000) {
810
+ clearInterval(cooldownReportInterval);
811
+ cooldownReportInterval = null;
812
+ cooldownReportInterval = setInterval(function() {
813
+ cooldownRemainingDisplay -= 1000;
814
+ node.status(buildStatus(displayTime(cooldownRemainingDisplay, reportingformat), TIMER_STATE.COOLDOWN));
815
+ }, 1000);
816
+ }
817
+ }, 60000);
818
+ }
819
+ cooldownReportMiniTimeout = null;
820
+ }, cooldownRemainingDisplay % 60000);
821
+
822
+ } else {
823
+ cooldownReportInterval = setInterval(function() {
824
+ cooldownRemainingDisplay -= 1000;
825
+ node.status(buildStatus(displayTime(cooldownRemainingDisplay, reportingformat), TIMER_STATE.COOLDOWN));
826
+ }, 1000);
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Fires when the cooldown period completes naturally. Settles back into
832
+ * TIMER_STATE.EXPIRED (idle) - this does NOT re-fire TIMER_EVENT.EXPIRED,
833
+ * since expiry was already reported once when the original countdown
834
+ * hit zero. Only COOLDOWNENDED is dispatched.
835
+ */
836
+ function cooldownElapsed() {
837
+ if (actualCooldownDelayRemaining === 0) {
838
+ clearCooldownTimers();
839
+ cooldownActive = false;
840
+ cooldownRemainingDisplay = 0;
841
+ cooldownExpiryTarget = null;
842
+ timerState = TIMER_STATE.EXPIRED;
843
+ stopHeartbeat();
844
+ deleteState();
845
+ node.status(buildStatus(null, TIMER_STATE.EXPIRED));
846
+ dispatchEvent(TIMER_EVENT.COOLDOWNENDED, originalMsg, false, EVENT_SOURCE.INTERNAL);
847
+ return;
848
+ } else if (actualCooldownDelayRemaining > maxTimeout) {
849
+ actualCooldownDelayInUse = maxTimeout;
850
+ actualCooldownDelayRemaining -= maxTimeout;
851
+ } else {
852
+ actualCooldownDelayInUse = actualCooldownDelayRemaining;
853
+ actualCooldownDelayRemaining = 0;
854
+ }
855
+ cooldownTimeout = setTimeout(cooldownElapsed, actualCooldownDelayInUse);
856
+ }
857
+
858
+ // -------------------------------------------------------------------------
859
+ // Threshold action handler
860
+ // -------------------------------------------------------------------------
861
+
862
+ /**
863
+ * Fires automatically when the ignored-message count reaches the
864
+ * configured threshold. Every action here is internally-sourced
865
+ * (EVENT_SOURCE.INTERNAL) since nothing external directly triggered it -
866
+ * it is a side effect of the ignored-message count, not a new incoming
867
+ * command. All actions here genuinely alter timer state (ignored:false),
868
+ * except WARNING, which is a deliberate no-state-change notification and
869
+ * is therefore also ignored:false (it is not a blocked/declined action -
870
+ * it did exactly what it was meant to do).
871
+ */
872
+ function handleThresholdAction() {
873
+ // Threshold logic is scoped to an active run (running or paused) only.
874
+ // From an idle state (stopped/expired/cooldown) there is no run to
875
+ // stop, pause, restart, or extend - firing an action from idle could
876
+ // even START the timer (Add Time / Restart Timer), defeating the very
877
+ // block that produced the ignored count. Blocked idle starts are still
878
+ // counted and individually observable on output 4; they just never
879
+ // trip an action.
880
+ if (!timerRunning && !paused) return;
881
+ if (node.thresholdaction === THRESHOLD_ACTION.DONOTHING || node.thresholdcount <= 0) return;
882
+ if (ignoredCount % node.thresholdcount !== 0) return;
883
+
884
+ switch (node.thresholdaction) {
885
+
886
+ case THRESHOLD_ACTION.STOP:
887
+ let stopFinalElapsed = getElapsedTime(); // snapshot before the state flips to idle
888
+ timerRunning = false;
889
+ timerState = TIMER_STATE.STOPPED;
890
+ stopped = true;
891
+ expiryTarget = null;
892
+ frozenRemaining = null;
893
+ frozenElapsed = null;
894
+ clearAllTimers();
895
+ stopHeartbeat();
896
+ deleteState();
897
+ ignoredCount = 0;
898
+ lastIgnoredTime = null;
899
+ node.status(buildStatus(null, TIMER_STATE.STOPPED));
900
+ dispatchEvent(TIMER_EVENT.STOPPED, originalMsg, false, EVENT_SOURCE.INTERNAL, { elapsedTime: stopFinalElapsed });
901
+ break;
902
+
903
+ case THRESHOLD_ACTION.PAUSE:
904
+ if (timerRunning) {
905
+ // Same ordering rule as the pause command: capture the exact
906
+ // remaining and elapsed time while still in the running state.
907
+ frozenRemaining = getRemainingTime();
908
+ frozenElapsed = getElapsedTime();
909
+ delayRemainingDisplay = frozenRemaining;
910
+ timerRunning = false;
911
+ timerState = TIMER_STATE.PAUSED;
912
+ paused = true;
913
+ expiryTarget = null;
914
+ clearAllTimers();
915
+ writeState(originalMsg);
916
+ ignoredCount = 0;
917
+ lastIgnoredTime = null;
918
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.PAUSED));
919
+ dispatchEvent(TIMER_EVENT.PAUSED, originalMsg, false, EVENT_SOURCE.INTERNAL);
920
+ }
921
+ break;
922
+
923
+ case THRESHOLD_ACTION.RESET:
924
+ clearAllTimers();
925
+ delayRemainingDisplay = timerDuration;
926
+ if (paused) {
927
+ frozenRemaining = timerDuration;
928
+ frozenElapsed = 0; // full reset: nothing of the fresh run has elapsed yet
929
+ } else {
930
+ expiryTarget = Date.now() + timerDuration;
931
+ }
932
+ timerStartTime = new Date();
933
+ ignoredCount = 0;
934
+ lastIgnoredTime = null;
935
+ writeState(originalMsg);
936
+ if (paused) {
937
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.PAUSED));
938
+ dispatchEvent(TIMER_EVENT.RESTARTED, originalMsg, false, EVENT_SOURCE.INTERNAL);
939
+ } else {
940
+ timerState = TIMER_STATE.RUNNING;
941
+ timerRunning = true;
942
+ dispatchEvent(TIMER_EVENT.RESTARTED, originalMsg, false, EVENT_SOURCE.INTERNAL);
943
+ startTimeout(originalMsg);
944
+ startReporting();
945
+ }
946
+ break;
947
+
948
+ case THRESHOLD_ACTION.ADDTIME:
949
+ let addTimeMS = convertToMilliseconds(node.thresholdaddtime, node.thresholdaddtimeunits);
950
+ // Read the true remaining time BEFORE any mutation - the display
951
+ // counter may not have decremented at all with reporting off.
952
+ let addTimeNewRemaining = getRemainingTime() + addTimeMS;
953
+ clearAllTimers();
954
+ delayRemainingDisplay = addTimeNewRemaining;
955
+ if (paused) {
956
+ frozenRemaining = addTimeNewRemaining;
957
+ } else {
958
+ expiryTarget = Date.now() + addTimeNewRemaining;
959
+ }
960
+ ignoredCount = 0;
961
+ lastIgnoredTime = null;
962
+ writeState(originalMsg);
963
+ dispatchEvent(TIMER_EVENT.TIMEADJUSTED, originalMsg, false, EVENT_SOURCE.INTERNAL, { timeAdjusted: addTimeMS });
964
+ if (paused) {
965
+ node.status(buildStatus(displayTime(delayRemainingDisplay, reportingformat), TIMER_STATE.PAUSED));
966
+ } else {
967
+ timerState = TIMER_STATE.RUNNING;
968
+ timerRunning = true;
969
+ startTimeout(originalMsg);
970
+ startReporting();
971
+ }
972
+ break;
973
+
974
+ case THRESHOLD_ACTION.WARNING:
975
+ dispatchEvent(TIMER_EVENT.WARNING, originalMsg, false, EVENT_SOURCE.INTERNAL);
976
+ break;
977
+ }
978
+ }
979
+
980
+ // -------------------------------------------------------------------------
981
+ // Input event handler
982
+ // -------------------------------------------------------------------------
983
+
984
+ function handleInputEvent(msg, isRestore) {
985
+ // INVARIANT: every exit path from this function must set the node
986
+ // status itself. There is deliberately no blanket node.status({})
987
+ // here - the old unconditional blank made every incoming message
988
+ // (including a side-effect-free query) visibly flicker the status
989
+ // label before the handler repainted it. All current paths set their
990
+ // own status; a new handler that forgets will leave the PREVIOUS
991
+ // label lingering rather than a blank, so set it explicitly.
992
+
993
+ const msgPayload = typeof msg.payload === 'string' ? msg.payload.toLowerCase() : msg.payload;
994
+ const msgUnits = normalizeUnits(msg.units);
995
+ const msgSource = isRestore ? EVENT_SOURCE.INTERNAL : EVENT_SOURCE.EXTERNAL;
996
+
997
+ reporting = node.reporting;
998
+ reportingformat = node.reportingformat;
999
+
1000
+ // -- Query -----------------------------------------------------------
1001
+ if (msgPayload === PAYLOAD.QUERY) {
1002
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1003
+ dispatchEvent(TIMER_EVENT.QUERY, msg, false, msgSource);
1004
+ return;
1005
+ }
1006
+
1007
+ // -- Redundant Stop ----------------------------------------------------
1008
+ // A stop while truly idle (stopped/expired) is redundant - there is
1009
+ // nothing alive to kill - so like every other redundant command it is
1010
+ // ignored:true on output 4 with ZERO state change: no counter touch,
1011
+ // no expired -> stopped flip, and (deliberately, per design decision)
1012
+ // no arming of the _timerpass filter. Stop remains fully genuine
1013
+ // whenever something is alive: running, paused, or cooldown ("still
1014
+ // the timer running, in a sense").
1015
+ if (msgPayload === PAYLOAD.STOP && !timerRunning && !paused && !cooldownActive) {
1016
+ // The _timerpass swallow is unrelated to this rule and is preserved
1017
+ // exactly: an armed node still silently absorbs _timerpass-tagged
1018
+ // messages, stop included.
1019
+ if (stopped === true && msg._timerpass === true && node.ignoretimerpass !== true) {
1020
+ node.status({ fill: "red", shape: "ring", text: "stopped" });
1021
+ return;
1022
+ }
1023
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1024
+ dispatchEvent(TIMER_EVENT.STOPPED, msg, true, msgSource);
1025
+ return;
1026
+ }
1027
+
1028
+ // -- Disable ---------------------------------------------------------
1029
+ if (msgPayload === PAYLOAD.DISABLE) {
1030
+ if (disabled) {
1031
+ // Redundant command: no state change and (harmonized with every
1032
+ // other redundant command) no ignored-count bookkeeping.
1033
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1034
+ dispatchEvent(TIMER_EVENT.DISABLED, msg, true, msgSource);
1035
+ return;
1036
+ }
1037
+ disabled = true;
1038
+ writeState(originalMsg);
1039
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1040
+ dispatchEvent(TIMER_EVENT.DISABLED, msg, false, msgSource);
1041
+ return;
1042
+ }
1043
+
1044
+ // -- Enable ----------------------------------------------------------
1045
+ if (msgPayload === PAYLOAD.ENABLE) {
1046
+ if (!disabled) {
1047
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1048
+ dispatchEvent(TIMER_EVENT.ENABLED, msg, true, msgSource);
1049
+ return;
1050
+ }
1051
+ disabled = false;
1052
+ writeState(originalMsg);
1053
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1054
+ dispatchEvent(TIMER_EVENT.ENABLED, msg, false, msgSource);
1055
+ return;
1056
+ }
1057
+
1058
+ // -- Lock ------------------------------------------------------------
1059
+ if (msgPayload === PAYLOAD.LOCK) {
1060
+ if (node.donotresettimer) {
1061
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1062
+ dispatchEvent(TIMER_EVENT.LOCKED, msg, true, msgSource);
1063
+ return;
1064
+ }
1065
+ node.donotresettimer = true;
1066
+ ignoredCount = 0;
1067
+ lastIgnoredTime = null;
1068
+ writeState(originalMsg);
1069
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1070
+ dispatchEvent(TIMER_EVENT.LOCKED, msg, false, msgSource);
1071
+ return;
1072
+ }
1073
+
1074
+ // -- Unlock ----------------------------------------------------------
1075
+ if (msgPayload === PAYLOAD.UNLOCK) {
1076
+ if (!node.donotresettimer) {
1077
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1078
+ dispatchEvent(TIMER_EVENT.UNLOCKED, msg, true, msgSource);
1079
+ return;
1080
+ }
1081
+ node.donotresettimer = false;
1082
+ ignoredCount = 0;
1083
+ lastIgnoredTime = null;
1084
+ writeState(originalMsg);
1085
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1086
+ dispatchEvent(TIMER_EVENT.UNLOCKED, msg, false, msgSource);
1087
+ return;
1088
+ }
1089
+
1090
+ // -- Adjust Time -----------------------------------------------------
1091
+ if (msgPayload === PAYLOAD.ADJUSTTIME) {
1092
+ let adjustUnits = normalizeUnits(msg.adjusttimeunits);
1093
+ let adjustRaw = toFiniteNumber(msg.adjusttime);
1094
+ // A missing or non-numeric msg.adjusttime is rejected outright -
1095
+ // Math.max(0, NaN) is NaN, which used to silently corrupt the
1096
+ // remaining time. The attempted raw value rides along so a
1097
+ // downstream consumer can see what was rejected.
1098
+ if (!Number.isFinite(adjustRaw)) {
1099
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1100
+ dispatchEvent(TIMER_EVENT.TIMEADJUSTED, msg, true, msgSource, { timeAdjusted: msg.adjusttime !== undefined ? msg.adjusttime : null });
1101
+ return;
1102
+ }
1103
+ let adjustMS = msgValueToMs(adjustRaw, adjustUnits);
1104
+ if (timerRunning || paused) {
1105
+ let newRemaining = Math.max(0, getRemainingTime() + adjustMS);
1106
+ delayRemainingDisplay = newRemaining;
1107
+ if (paused) {
1108
+ frozenRemaining = newRemaining;
1109
+ } else {
1110
+ expiryTarget = Date.now() + newRemaining;
1111
+ }
1112
+ writeState(originalMsg);
1113
+ if (paused) {
1114
+ node.status(buildStatus(displayRemaining(reportingformat), TIMER_STATE.PAUSED));
1115
+ } else {
1116
+ clearAllTimers();
1117
+ startTimeout(originalMsg);
1118
+ startReporting();
1119
+ }
1120
+ dispatchEvent(TIMER_EVENT.TIMEADJUSTED, msg, false, msgSource, { timeAdjusted: adjustMS });
1121
+ } else {
1122
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1123
+ dispatchEvent(TIMER_EVENT.TIMEADJUSTED, msg, true, msgSource, { timeAdjusted: adjustMS });
1124
+ }
1125
+ return;
1126
+ }
1127
+
1128
+ // -- Set Time --------------------------------------------------------
1129
+ if (msgPayload === PAYLOAD.SETTIME) {
1130
+ let setUnits = normalizeUnits(msg.settimeunits);
1131
+ let setRaw = toFiniteNumber(msg.settime);
1132
+ // NaN <= 0 is false, so NaN used to pass the positive-value check
1133
+ // below as "valid" and corrupt the remaining time. Reject non-finite
1134
+ // values first, carrying the attempted raw value.
1135
+ if (!Number.isFinite(setRaw)) {
1136
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1137
+ dispatchEvent(TIMER_EVENT.TIMESET, msg, true, msgSource, { timeSet: msg.settime !== undefined ? msg.settime : null });
1138
+ return;
1139
+ }
1140
+ let setMS = msgValueToMs(setRaw, setUnits);
1141
+ if (timerRunning || paused) {
1142
+ if (setMS <= 0) {
1143
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1144
+ dispatchEvent(TIMER_EVENT.TIMESET, msg, true, msgSource, { timeSet: setMS });
1145
+ return;
1146
+ }
1147
+ delayRemainingDisplay = setMS;
1148
+ if (paused) {
1149
+ frozenRemaining = setMS;
1150
+ } else {
1151
+ expiryTarget = Date.now() + setMS;
1152
+ }
1153
+ writeState(originalMsg);
1154
+ if (paused) {
1155
+ node.status(buildStatus(displayRemaining(reportingformat), TIMER_STATE.PAUSED));
1156
+ } else {
1157
+ clearAllTimers();
1158
+ startTimeout(originalMsg);
1159
+ startReporting();
1160
+ }
1161
+ dispatchEvent(TIMER_EVENT.TIMESET, msg, false, msgSource, { timeSet: setMS });
1162
+ } else {
1163
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1164
+ dispatchEvent(TIMER_EVENT.TIMESET, msg, true, msgSource, { timeSet: setMS });
1165
+ }
1166
+ return;
1167
+ }
1168
+
1169
+ // -- Set Duration ----------------------------------------------------
1170
+ if (msgPayload === PAYLOAD.SETDURATION) {
1171
+ let durUnits = normalizeUnits(msg.setdurationunits);
1172
+ let durRaw = toFiniteNumber(msg.setduration);
1173
+ // Same NaN <= 0 hole as settime, but worse: a NaN stored in
1174
+ // overrideDuration would poison the NEXT run, not just this one.
1175
+ if (!Number.isFinite(durRaw)) {
1176
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1177
+ dispatchEvent(TIMER_EVENT.DURATIONSET, msg, true, msgSource, { durationSet: msg.setduration !== undefined ? msg.setduration : null });
1178
+ return;
1179
+ }
1180
+ let durMS = msgValueToMs(durRaw, durUnits);
1181
+ if (durMS <= 0) {
1182
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1183
+ dispatchEvent(TIMER_EVENT.DURATIONSET, msg, true, msgSource, { durationSet: durMS });
1184
+ return;
1185
+ }
1186
+ overrideDuration = durMS;
1187
+ writeState(originalMsg);
1188
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1189
+ dispatchEvent(TIMER_EVENT.DURATIONSET, msg, false, msgSource, { durationSet: durMS });
1190
+ return;
1191
+ }
1192
+
1193
+ // -- Pause -----------------------------------------------------------
1194
+ if (msgPayload === PAYLOAD.PAUSE) {
1195
+ if (paused) {
1196
+ node.status(buildStatus(displayRemaining(reportingformat), TIMER_STATE.PAUSED));
1197
+ dispatchEvent(TIMER_EVENT.PAUSED, msg, true, msgSource);
1198
+ return;
1199
+ }
1200
+ if (timerRunning) {
1201
+ // Capture the exact remaining AND elapsed time BEFORE flipping
1202
+ // state - both getters read live wall-clock values only while
1203
+ // running.
1204
+ frozenRemaining = getRemainingTime();
1205
+ frozenElapsed = getElapsedTime();
1206
+ delayRemainingDisplay = frozenRemaining; // keep the display counter in step for the paused label
1207
+ clearAllTimers();
1208
+ paused = true;
1209
+ timerRunning = false;
1210
+ timerState = TIMER_STATE.PAUSED;
1211
+ expiryTarget = null;
1212
+ writeState(originalMsg);
1213
+ node.status(buildStatus(displayRemaining(reportingformat), TIMER_STATE.PAUSED));
1214
+ dispatchEvent(TIMER_EVENT.PAUSED, msg, false, msgSource);
1215
+ } else {
1216
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1217
+ dispatchEvent(TIMER_EVENT.PAUSED, msg, true, msgSource);
1218
+ }
1219
+ return;
1220
+ }
1221
+
1222
+ // -- Resume ----------------------------------------------------------
1223
+ if (msgPayload === PAYLOAD.RESUME) {
1224
+ if (paused) {
1225
+ // Resume from the exact frozen snapshot, not the display counter
1226
+ // (which is stale when Status Reporting is "none").
1227
+ delayRemainingDisplay = getRemainingTime(); // startTimeout() and the label both read this
1228
+ paused = false;
1229
+ timerRunning = true;
1230
+ timerState = TIMER_STATE.RUNNING;
1231
+ expiryTarget = Date.now() + delayRemainingDisplay;
1232
+ frozenRemaining = null;
1233
+ frozenElapsed = null;
1234
+ timerStartTime = new Date((new Date()).getTime() - (timerDuration - delayRemainingDisplay));
1235
+ writeState(originalMsg);
1236
+ dispatchEvent(TIMER_EVENT.RESUMED, msg, false, msgSource);
1237
+ startTimeout(originalMsg);
1238
+ startReporting();
1239
+ } else {
1240
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1241
+ dispatchEvent(TIMER_EVENT.RESUMED, msg, true, msgSource);
1242
+ }
1243
+ return;
1244
+ }
1245
+
1246
+ // -- Paused gate -------------------------------------------------------
1247
+ // Any other message arriving while paused (other than stop) is blocked.
1248
+ // The intent of a plain message is to (re)start the timer, but since the
1249
+ // timer is already active (paused, not idle), it is a blocked RESTART,
1250
+ // not a blocked START.
1251
+ if (paused && msgPayload !== PAYLOAD.STOP) {
1252
+ ignoredCount++;
1253
+ lastIgnoredTime = new Date();
1254
+ node.status(buildStatus(displayRemaining(reportingformat), TIMER_STATE.PAUSED));
1255
+ dispatchEvent(TIMER_EVENT.RESTARTED, msg, true, msgSource);
1256
+ handleThresholdAction();
1257
+ return;
1258
+ }
1259
+
1260
+ // -- _timerpass gate -------------------------------------------------
1261
+ if (stopped === false || msg._timerpass !== true || node.ignoretimerpass === true) {
1262
+
1263
+ // -- donotresettimer gate --------------------------------------------
1264
+ // Same reasoning as the paused gate above: the timer is already
1265
+ // running, so a blocked message here is a blocked RESTART.
1266
+ if (node.donotresettimer && timerRunning && msgPayload !== PAYLOAD.STOP && msg._timerpass !== true) {
1267
+ ignoredCount++;
1268
+ lastIgnoredTime = new Date();
1269
+ node.status(buildStatus(displayRemaining(reportingformat), TIMER_STATE.RUNNING));
1270
+ dispatchEvent(TIMER_EVENT.RESTARTED, msg, true, msgSource);
1271
+ handleThresholdAction();
1272
+ return;
1273
+ }
1274
+
1275
+ stopped = false;
1276
+ // Snapshot elapsed BEFORE the shared paused-flag clear below - a
1277
+ // stop arriving while paused must report the frozen elapsed value,
1278
+ // and getElapsedTime() can no longer see it once paused is false.
1279
+ let preStopElapsed = getElapsedTime();
1280
+ paused = false;
1281
+ clearAllTimers();
1282
+
1283
+ // -- Stop ----------------------------------------------------------
1284
+ if (msgPayload === PAYLOAD.STOP) {
1285
+ // preStopElapsed (captured above, before the paused flag was
1286
+ // cleared) is the run's final elapsed value: the frozen snapshot
1287
+ // if stopping from pause, wall-clock elapsed if running, or time
1288
+ // into the cooldown period if cancelling a cooldown. The stopped
1289
+ // event carries it - information available nowhere else once the
1290
+ // run dies.
1291
+ let finalElapsed = preStopElapsed;
1292
+ // An explicit stop always cancels an in-progress cooldown too -
1293
+ // it's the one way to cut a cooldown short.
1294
+ clearCooldownTimers();
1295
+ cooldownActive = false;
1296
+ cooldownExpiryTarget = null;
1297
+ timerRunning = false;
1298
+ timerState = TIMER_STATE.STOPPED;
1299
+ stopped = true;
1300
+ expiryTarget = null;
1301
+ frozenRemaining = null;
1302
+ frozenElapsed = null;
1303
+ stopHeartbeat();
1304
+ deleteState();
1305
+ ignoredCount = 0;
1306
+ lastIgnoredTime = null;
1307
+ node.status(buildStatus(null, TIMER_STATE.STOPPED));
1308
+ dispatchEvent(TIMER_EVENT.STOPPED, msg, false, msgSource, { elapsedTime: finalElapsed });
1309
+ return;
1310
+ }
1311
+
1312
+ // -- Disabled / Cooldown gate -----------------------------------------
1313
+ // The timer is currently idle (stopped/expired/cooldown), so a
1314
+ // blocked message here is a blocked true START. disabled and
1315
+ // cooldownActive are independent blocking conditions - either one
1316
+ // blocks a new start. Threshold actions never fire from here:
1317
+ // threshold logic is scoped to an active run (running or paused),
1318
+ // enforced centrally by the guard in handleThresholdAction(). The
1319
+ // ignored count still increments for status/envelope visibility.
1320
+ if ((disabled || cooldownActive) && !isRestore) {
1321
+ ignoredCount++;
1322
+ lastIgnoredTime = new Date();
1323
+ node.status(buildStatus(displayRemaining(reportingformat), timerState));
1324
+ dispatchEvent(TIMER_EVENT.STARTED, msg, true, msgSource);
1325
+ return;
1326
+ }
1327
+
1328
+ // -- Start / Restart -----------------------------------------------
1329
+ let wasRunning = timerRunning;
1330
+
1331
+ msg._timerpass = true;
1332
+
1333
+ if (msgUnits !== null) {
1334
+ switch (msgUnits) {
1335
+ case UNITS_INPUT.MILLISECOND: delayFactor = 1; break;
1336
+ case UNITS_INPUT.SECOND: delayFactor = 1000; break;
1337
+ case UNITS_INPUT.MINUTE: delayFactor = 1000 * 60; break;
1338
+ case UNITS_INPUT.HOUR: delayFactor = 1000 * 60 * 60; break;
1339
+ default:
1340
+ node.warn("Unknown units in message, using node default: " + node.units);
1341
+ delayFactor = convertToMilliseconds(1, node.units);
1342
+ }
1343
+ } else {
1344
+ delayFactor = convertToMilliseconds(1, node.units);
1345
+ }
1346
+
1347
+ // Documented msg.delay behavior: an unconvertible value falls back
1348
+ // to the configured duration; a negative value is clamped to 0.
1349
+ // The old guard used parseInt while the math used the raw value, so
1350
+ // "5s" passed the guard but multiplied to NaN. Validate and compute
1351
+ // with the SAME numeric value. Fractional delays (e.g. 2.5 minutes)
1352
+ // remain supported.
1353
+ let msgDelay = msg.delay != null ? toFiniteNumber(msg.delay) : NaN;
1354
+ if (Number.isFinite(msgDelay)) {
1355
+ delayRemainingDisplay = Math.max(0, msgDelay) * delayFactor;
1356
+ } else {
1357
+ delayRemainingDisplay = overrideDuration !== null ? overrideDuration : node.duration;
1358
+ overrideDuration = null;
1359
+ }
1360
+
1361
+ // Fresh-run identity resets. Skipped on a persisted restore
1362
+ // (isRestore) - a restore is a CONTINUATION of the interrupted run,
1363
+ // not a new one, so the run's original timerDuration, its
1364
+ // back-calculated timerStartTime, and its accumulated
1365
+ // ignoredCount / lastIgnoredTime (all placed by the restore block
1366
+ // before this call) survive. Only the output-1 Start event treats
1367
+ // it as a start, for downstream consumers' benefit.
1368
+ if (!isRestore) {
1369
+ ignoredCount = 0;
1370
+ lastIgnoredTime = null;
1371
+ timerStartTime = new Date();
1372
+ timerDuration = delayRemainingDisplay;
1373
+ }
1374
+ timerRunning = true;
1375
+ timerState = TIMER_STATE.RUNNING;
1376
+ expiryTarget = Date.now() + delayRemainingDisplay;
1377
+ frozenRemaining = null;
1378
+ frozenElapsed = null;
1379
+ originalMsg = msg;
1380
+
1381
+ writeState(msg);
1382
+
1383
+ if (wasRunning) {
1384
+ dispatchEvent(TIMER_EVENT.RESTARTED, msg, false, msgSource);
1385
+ } else {
1386
+ dispatchEvent(TIMER_EVENT.STARTED, msg, false, msgSource);
1387
+ }
1388
+
1389
+ startTimeout(msg);
1390
+ startReporting();
1391
+ startHeartbeat();
1392
+
1393
+ } else {
1394
+ node.status({ fill: "red", shape: "ring", text: "stopped" });
1395
+ }
1396
+ }
1397
+
1398
+ // -------------------------------------------------------------------------
1399
+ // Timer elapsed handler
1400
+ // -------------------------------------------------------------------------
1401
+
1402
+ function timerElapsed(msg) {
1403
+ if (actualDelayRemaining === 0) {
1404
+ // Snapshot the run's final elapsed value while still running -
1405
+ // reported on the expired event, since getElapsedTime correctly
1406
+ // returns 0 once the state is idle. With mid-run adjustments this
1407
+ // is the true wall-clock run length, which may differ from
1408
+ // timerDuration.
1409
+ let finalElapsed = getElapsedTime();
1410
+ clearInterval(countdown);
1411
+ timerRunning = false;
1412
+ timerState = TIMER_STATE.EXPIRED;
1413
+ expiryTarget = null;
1414
+ delayRemainingDisplay = 0; // Ensure remainingTime is correctly 0 on expiry
1415
+ node.status(buildStatus(null, TIMER_STATE.EXPIRED));
1416
+
1417
+ if (stopped === false) {
1418
+ ignoredCount = 0;
1419
+ lastIgnoredTime = null;
1420
+ // Expiry is never externally triggered - it is always the node's
1421
+ // own clock reaching zero.
1422
+ dispatchEvent(TIMER_EVENT.EXPIRED, msg, false, EVENT_SOURCE.INTERNAL, { elapsedTime: finalElapsed });
1423
+
1424
+ // If a cooldown is configured, transition into it now - heartbeat
1425
+ // is left running uninterrupted throughout. Otherwise this is a
1426
+ // true idle expiry: stop the heartbeat and clear persisted state.
1427
+ startCooldown(msg);
1428
+ if (!cooldownActive) {
1429
+ stopHeartbeat();
1430
+ deleteState();
1431
+ }
1432
+ return;
1433
+ }
1434
+ stopHeartbeat();
1435
+ timeout = null;
1436
+ countdown = null;
1437
+ miniTimeout = null;
1438
+ } else if (actualDelayRemaining > maxTimeout) {
1439
+ actualDelayInUse = maxTimeout;
1440
+ actualDelayRemaining -= maxTimeout;
1441
+ } else {
1442
+ actualDelayInUse = actualDelayRemaining;
1443
+ actualDelayRemaining = 0;
1444
+ }
1445
+ timeout = setTimeout(timerElapsed, actualDelayInUse, msg);
1446
+ }
1447
+
1448
+ // -------------------------------------------------------------------------
1449
+ // Display time formatter
1450
+ // -------------------------------------------------------------------------
1451
+
1452
+ function displayTime(delayToDisplay, reportingformat) {
1453
+ delayToDisplay = delayToDisplay / 1000;
1454
+ switch (reportingformat) {
1455
+ case REPORTING_FORMAT.SECONDS: return delayToDisplay;
1456
+ case REPORTING_FORMAT.MINUTES: return delayToDisplay / 60;
1457
+ case REPORTING_FORMAT.HOURS: return delayToDisplay / 3600;
1458
+ default:
1459
+ let hours = String(Math.floor(delayToDisplay / 3600)).padStart(2, "0");
1460
+ delayToDisplay %= 3600;
1461
+ let minutes = String(Math.floor(delayToDisplay / 60)).padStart(2, "0");
1462
+ let seconds = String(delayToDisplay % 60).padStart(2, "0");
1463
+ return hours + ":" + minutes + ":" + seconds;
1464
+ }
1465
+ }
1466
+
1467
+ // -------------------------------------------------------------------------
1468
+ // Persist helpers
1469
+ // -------------------------------------------------------------------------
1470
+
1471
+ function writeState(msg) {
1472
+ if (node.persist !== true) return;
1473
+ try {
1474
+ if (!fs.existsSync(path.dirname(stvdtimersFile))) {
1475
+ fs.mkdirSync(path.dirname(stvdtimersFile), { recursive: true });
1476
+ }
1477
+ let target = (new Date(Date.now() + getRemainingTime())).toISOString();
1478
+ fs.writeFileSync(stvdtimersFile, JSON.stringify(JSON.decycle({
1479
+ reporting: node.reporting,
1480
+ reportingformat: node.reportingformat,
1481
+ time: target,
1482
+ // Frozen snapshots, written only while paused. The `time` target
1483
+ // above is correct for states that live in wall-clock terms
1484
+ // (running, cooldown) but wrong for a frozen pause - computing
1485
+ // target-minus-now on restore would silently deduct Node-RED
1486
+ // downtime from a timer that is supposed to be frozen. The paused
1487
+ // restore reads these directly; old persist files without them
1488
+ // fall back to the legacy target-based calculation.
1489
+ remaining: paused ? frozenRemaining : null,
1490
+ frozenElapsed: paused ? frozenElapsed : null,
1491
+ origmsg: msg !== null ? msg : {},
1492
+ paused: paused,
1493
+ timerDuration: timerDuration,
1494
+ timerStartTime: timerStartTime ? timerStartTime.toISOString() : null,
1495
+ timerState: timerState,
1496
+ ignoredCount: ignoredCount,
1497
+ lastIgnoredTime: lastIgnoredTime ? lastIgnoredTime.toISOString() : null,
1498
+ donotresettimer: node.donotresettimer,
1499
+ overrideDuration: overrideDuration,
1500
+ disabled: disabled,
1501
+ cooldownActive: cooldownActive
1502
+ })));
1503
+ } catch (error) {
1504
+ node.error("Error writing persistent file for timer-events node " + node.id.toString() + "\n\n" + error.toString());
1505
+ }
1506
+ }
1507
+
1508
+ function readState() {
1509
+ try {
1510
+ let contents = fs.readFileSync(stvdtimersFile).toString();
1511
+ if (typeof contents !== 'undefined') return contents;
1512
+ } catch (error) {
1513
+ node.error("Error reading persistent file for timer-events node " + node.id.toString() + "\n\n" + error.toString());
1514
+ }
1515
+ return -1;
1516
+ }
1517
+
1518
+ function deleteState() {
1519
+ try {
1520
+ if (fs.existsSync(stvdtimersFile)) fs.unlinkSync(stvdtimersFile);
1521
+ } catch (error) {
1522
+ node.error("Error deleting persistent file for timer-events node " + node.id.toString() + "\n\n" + error.toString());
1523
+ }
1524
+ }
1525
+ }
1526
+
1527
+ RED.nodes.registerType("timer-events", TimerEvents);
1528
+ }