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.
- package/.github/workflows/npm-publish.yml +18 -0
- package/LICENSE +201 -0
- package/README.md +69 -0
- package/package.json +29 -0
- package/test-scripts/test-elapsed.js +219 -0
- package/test-scripts/test-harness.js +203 -0
- package/test-scripts/test-paused-restore.js +157 -0
- package/test-scripts/test-redundant-stop.js +190 -0
- package/test-scripts/test-restore.js +147 -0
- package/test-scripts/test-status-flicker.js +143 -0
- package/test-scripts/test-threshold-scope.js +148 -0
- package/test-scripts/test-validation.js +198 -0
- package/timer-events/cycle.js +182 -0
- package/timer-events/timer-events.html +444 -0
- package/timer-events/timer-events.js +1528 -0
|
@@ -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
|
+
}
|