@bldgblocks/node-red-contrib-control 0.1.37 → 0.2.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.
@@ -1,3 +1,26 @@
1
+ // ============================================================================
2
+ // Call Status Block - Equipment Call/Status Monitor
3
+ // ============================================================================
4
+ // Monitors call and status signals to detect equipment faults, communication
5
+ // losses, and synchronization errors.
6
+ //
7
+ // State machine: IDLE → WAITING_FOR_STATUS → RUNNING → STATUS_LOST
8
+ //
9
+ // Both "call" and "status" are typed inputs — they can come from msg properties,
10
+ // flow variables, global variables, or static boolean values.
11
+ //
12
+ // On every incoming message:
13
+ // 1. Evaluate call value from typed input
14
+ // 2. Evaluate status value from typed input
15
+ // 3. Process state transitions and alarm conditions
16
+ //
17
+ // Alarm conditions:
18
+ // - No status received within statusTimeout after call activates
19
+ // - Status lost during active call (goes false or heartbeat expires)
20
+ // - Status remains active after call deactivates (equipment stuck)
21
+ // - Status active without any call (unexpected equipment activity)
22
+ // ============================================================================
23
+
1
24
  module.exports = function(RED) {
2
25
  const utils = require('./utils')(RED);
3
26
 
@@ -5,53 +28,62 @@ module.exports = function(RED) {
5
28
  RED.nodes.createNode(this, config);
6
29
  const node = this;
7
30
 
8
- // State management
31
+ // ====================================================================
32
+ // Configuration — safe parse helpers
33
+ // ====================================================================
34
+ const num = (v, fallback) => { const n = parseFloat(v); return isNaN(n) ? fallback : n; };
35
+
36
+ node.name = config.name;
37
+ node.isBusy = false;
38
+
39
+ node.config = {
40
+ statusTimeout: Math.max(num(config.statusTimeout, 30), 0), // 0 = disabled
41
+ heartbeatTimeout: Math.max(num(config.heartbeatTimeout, 0), 0), // 0 = disabled
42
+ clearDelay: Math.max(num(config.clearDelay, 10), 0),
43
+ debounce: Math.max(num(config.debounce, 0), 0), // ms, 0 = disabled
44
+ runLostStatus: config.runLostStatus === true,
45
+ noStatusOnRun: config.noStatusOnRun !== false,
46
+ statusWithoutCall: config.statusWithoutCall !== false,
47
+ runLostStatusMessage: config.runLostStatusMessage || "Status lost during run",
48
+ noStatusOnRunMessage: config.noStatusOnRunMessage || "No status received during run",
49
+ statusWithoutCallMessage: config.statusWithoutCallMessage || "Status active without call"
50
+ };
51
+
52
+ // ====================================================================
53
+ // Runtime state
54
+ // ====================================================================
9
55
  node.requestedState = false; // What we want equipment to do (call)
10
56
  node.actualState = false; // What equipment is actually doing (status)
11
57
  node.alarm = false;
12
58
  node.alarmMessage = "";
13
59
  node.lastStatusTime = null;
14
- node.neverReceivedStatus = true; // Track if status arrived during this call
15
-
60
+ node.neverReceivedStatus = true;
61
+
62
+ // ====================================================================
16
63
  // Timer management
17
- node.initialStatusTimer = null; // Initial timeout waiting for first status response
64
+ // ====================================================================
65
+ node.initialStatusTimer = null; // Timeout waiting for first status response
18
66
  node.heartbeatTimer = null; // Continuous heartbeat verification timer
19
67
  node.statusLostTimer = null; // Hysteresis timer for status lost alarm
20
- node.inactiveStatusTimer = null; // Timer to verify status goes inactive when call=false
68
+ node.inactiveStatusTimer = null; // Timer to verify status goes inactive
21
69
  node.clearTimer = null; // Timer to clear state after call ends
22
70
  node.debounceTimer = null; // Debounce status flicker
23
71
 
24
- // State machine states
72
+ // ====================================================================
73
+ // State machine
74
+ // ====================================================================
25
75
  const STATES = {
26
76
  IDLE: "IDLE",
27
77
  WAITING_FOR_STATUS: "WAITING_FOR_STATUS",
28
78
  RUNNING: "RUNNING",
29
- STATUS_LOST: "STATUS_LOST",
30
- SHUTDOWN: "SHUTDOWN"
79
+ STATUS_LOST: "STATUS_LOST"
31
80
  };
32
81
 
33
- // Configuration with defaults and validation
34
- node.config = {
35
- inputProperty: config.inputProperty || "payload",
36
- statusInputProperty: config.statusInputProperty || "status",
37
- statusTimeout: Math.max(parseFloat(config.statusTimeout) || 30, 0.01),
38
- heartbeatTimeout: Math.max(parseFloat(config.heartbeatTimeout) || 30, 0), // 0 = disabled
39
- clearDelay: Math.max(parseFloat(config.clearDelay) || 10, 0),
40
- debounce: Math.max(parseFloat(config.debounce) || 100, 0), // ms
41
- runLostStatus: config.runLostStatus === true,
42
- noStatusOnRun: config.noStatusOnRun === true,
43
- runLostStatusMessage: config.runLostStatusMessage || "Status lost during run",
44
- noStatusOnRunMessage: config.noStatusOnRunMessage || "No status received during run"
45
- };
46
-
47
- /**
48
- * Get the current state machine state
49
- */
50
82
  function getCurrentState() {
51
83
  if (!node.requestedState) {
52
84
  return STATES.IDLE;
53
85
  }
54
- if (node.requestedState && node.neverReceivedStatus && node.initialStatusTimer) {
86
+ if (node.requestedState && node.neverReceivedStatus) {
55
87
  return STATES.WAITING_FOR_STATUS;
56
88
  }
57
89
  if (node.requestedState && node.actualState) {
@@ -63,9 +95,23 @@ module.exports = function(RED) {
63
95
  return STATES.IDLE;
64
96
  }
65
97
 
66
- /**
67
- * Build the output message
68
- */
98
+ // ====================================================================
99
+ // Typed-input evaluation helpers
100
+ // ====================================================================
101
+ function evalBool(configValue, configType, fallback, msg) {
102
+ return utils.evaluateNodeProperty(configValue, configType, node, msg)
103
+ .then(val => {
104
+ if (typeof val === "boolean") return val;
105
+ if (val === "true" || val === 1) return true;
106
+ if (val === "false" || val === 0) return false;
107
+ return fallback;
108
+ })
109
+ .catch(() => fallback);
110
+ }
111
+
112
+ // ====================================================================
113
+ // Output builder
114
+ // ====================================================================
69
115
  function buildOutput() {
70
116
  return {
71
117
  payload: node.requestedState,
@@ -86,74 +132,55 @@ module.exports = function(RED) {
86
132
  };
87
133
  }
88
134
 
89
- /**
90
- * Update node status indicator
91
- */
135
+ // ====================================================================
136
+ // Status display
137
+ // ====================================================================
92
138
  function updateNodeStatus() {
93
139
  const state = getCurrentState();
94
- const timeSince = node.lastStatusTime ? Math.round((Date.now() - node.lastStatusTime) / 1000) : '-';
95
140
  let text;
96
141
 
97
142
  if (node.alarm) {
98
- text = `${state} | ALARM: ${node.alarmMessage}`;
143
+ text = `ALARM: ${node.alarmMessage} | call:${node.requestedState} status:${node.actualState}`;
99
144
  utils.setStatusError(node, text);
100
- } else if (node.heartbeatTimer && node.requestedState && node.actualState) {
101
- text = `${state} | call:ON status:ON heartbeat:${timeSince}s | monitoring`;
102
- utils.setStatusBusy(node, text);
103
- } else if (node.inactiveStatusTimer && !node.requestedState && node.actualState) {
104
- text = `${state} | call:OFF status:ON | waiting for deactivation`;
105
- utils.setStatusBusy(node, text);
106
- } else if (node.initialStatusTimer) {
107
- text = `${state} | call:ON status:WAITING | initial timeout`;
145
+ } else if (state === STATES.WAITING_FOR_STATUS) {
146
+ text = `call:ON status:WAITING | initial timeout`;
108
147
  utils.setStatusBusy(node, text);
148
+ } else if (node.requestedState && node.actualState && node.heartbeatTimer) {
149
+ text = `call:ON status:ON | running heartbeat:${node.config.heartbeatTimeout}s`;
150
+ utils.setStatusOK(node, text);
109
151
  } else if (node.requestedState && node.actualState) {
110
- text = `${state} | call:ON status:ON heartbeat:${timeSince}s | running`;
152
+ text = `call:ON status:ON | running`;
111
153
  utils.setStatusOK(node, text);
112
- } else if (node.requestedState && !node.actualState) {
113
- text = `${state} | call:ON status:OFF | off`;
114
- utils.setStatusUnchanged(node, text);
154
+ } else if (!node.requestedState && node.actualState) {
155
+ text = `call:OFF status:ON | waiting for deactivation`;
156
+ utils.setStatusWarn(node, text);
157
+ } else if (node.requestedState && !node.actualState && !node.neverReceivedStatus) {
158
+ text = `call:ON status:OFF | status lost`;
159
+ utils.setStatusWarn(node, text);
115
160
  } else if (!node.requestedState && !node.actualState) {
116
- text = `${state} | call:OFF status:OFF | idle`;
161
+ text = `call:OFF status:OFF | idle`;
117
162
  utils.setStatusUnchanged(node, text);
118
163
  } else {
119
- text = `${state} | call:${node.requestedState} status:${node.actualState}`;
164
+ text = `call:OFF status:OFF | idle`;
120
165
  utils.setStatusUnchanged(node, text);
121
166
  }
122
167
  }
123
168
 
124
- /**
125
- * Clear all timers
126
- */
169
+ // ====================================================================
170
+ // Timer management
171
+ // ====================================================================
127
172
  function clearAllTimers() {
128
- if (node.initialStatusTimer) {
129
- clearTimeout(node.initialStatusTimer);
130
- node.initialStatusTimer = null;
131
- }
132
- if (node.heartbeatTimer) {
133
- clearTimeout(node.heartbeatTimer);
134
- node.heartbeatTimer = null;
135
- }
136
- if (node.statusLostTimer) {
137
- clearTimeout(node.statusLostTimer);
138
- node.statusLostTimer = null;
139
- }
140
- if (node.inactiveStatusTimer) {
141
- clearTimeout(node.inactiveStatusTimer);
142
- node.inactiveStatusTimer = null;
143
- }
144
- if (node.clearTimer) {
145
- clearTimeout(node.clearTimer);
146
- node.clearTimer = null;
147
- }
148
- if (node.debounceTimer) {
149
- clearTimeout(node.debounceTimer);
150
- node.debounceTimer = null;
151
- }
173
+ if (node.initialStatusTimer) { clearTimeout(node.initialStatusTimer); node.initialStatusTimer = null; }
174
+ if (node.heartbeatTimer) { clearTimeout(node.heartbeatTimer); node.heartbeatTimer = null; }
175
+ if (node.statusLostTimer) { clearTimeout(node.statusLostTimer); node.statusLostTimer = null; }
176
+ if (node.inactiveStatusTimer) { clearTimeout(node.inactiveStatusTimer); node.inactiveStatusTimer = null; }
177
+ if (node.clearTimer) { clearTimeout(node.clearTimer); node.clearTimer = null; }
178
+ if (node.debounceTimer) { clearTimeout(node.debounceTimer); node.debounceTimer = null; }
152
179
  }
153
180
 
154
- /**
155
- * Start heartbeat verification timer
156
- */
181
+ // ====================================================================
182
+ // Heartbeat monitoring continuous status freshness check
183
+ // ====================================================================
157
184
  function startHeartbeatMonitoring(send) {
158
185
  if (!node.config.heartbeatTimeout || node.config.heartbeatTimeout <= 0) {
159
186
  return; // Heartbeat monitoring disabled
@@ -164,156 +191,103 @@ module.exports = function(RED) {
164
191
  }
165
192
 
166
193
  node.heartbeatTimer = setTimeout(() => {
167
- // Check if status has been updated within the heartbeat window
168
- const timeSinceLastUpdate = node.lastStatusTime ? Date.now() - node.lastStatusTime : Infinity;
169
-
170
- if (node.requestedState && node.actualState && timeSinceLastUpdate > node.config.heartbeatTimeout * 1000) {
171
- // Status hasn't been updated within heartbeat window - arm the alarm
172
- if (!node.statusLostTimer && node.config.runLostStatus) {
173
- // Start hysteresis timer before alarming
174
- node.statusLostTimer = setTimeout(() => {
175
- if (node.requestedState && !node.actualState && node.lastStatusTime &&
176
- (Date.now() - node.lastStatusTime > node.config.heartbeatTimeout * 1000)) {
177
- node.alarm = true;
178
- node.alarmMessage = node.config.runLostStatusMessage;
179
- send(buildOutput());
180
- updateNodeStatus();
181
- }
182
- node.statusLostTimer = null;
183
- }, 500); // 500ms hysteresis
194
+ node.heartbeatTimer = null;
195
+
196
+ // Only alarm if call is still active
197
+ if (!node.requestedState) return;
198
+
199
+ const timeSinceLastUpdate = node.lastStatusTime
200
+ ? Date.now() - node.lastStatusTime
201
+ : Infinity;
202
+
203
+ if (timeSinceLastUpdate > node.config.heartbeatTimeout * 1000) {
204
+ // Status hasn't been refreshed within heartbeat window
205
+ if (node.config.runLostStatus) {
206
+ node.alarm = true;
207
+ node.alarmMessage = node.config.runLostStatusMessage;
208
+ send(buildOutput());
209
+ updateNodeStatus();
184
210
  }
211
+ } else {
212
+ // Status was refreshed recently, schedule next check
213
+ startHeartbeatMonitoring(send);
185
214
  }
186
-
187
- // Restart heartbeat timer
188
- node.heartbeatTimer = null;
189
- startHeartbeatMonitoring(send);
190
215
  }, node.config.heartbeatTimeout * 1000);
191
216
  }
192
217
 
193
- /**
194
- * Start timer to verify status goes inactive when call is inactive
195
- */
218
+ // ====================================================================
219
+ // Inactive status monitoring verify equipment deactivates
220
+ // ====================================================================
196
221
  function startInactiveStatusMonitoring(send) {
197
222
  if (node.inactiveStatusTimer) {
198
223
  clearTimeout(node.inactiveStatusTimer);
199
224
  }
200
225
 
201
- // When call=false but status=true, monitor with hysteresis
202
226
  node.inactiveStatusTimer = setTimeout(() => {
203
- if (!node.requestedState && node.actualState) {
204
- // Status should have gone false by now
205
- if (!node.statusLostTimer) {
206
- node.statusLostTimer = setTimeout(() => {
207
- if (!node.requestedState && node.actualState) {
208
- node.alarm = true;
209
- node.alarmMessage = "Status not clearing after call deactivated";
210
- send(buildOutput());
211
- updateNodeStatus();
212
- }
213
- node.statusLostTimer = null;
214
- }, 500); // 500ms hysteresis
215
- }
216
- }
217
227
  node.inactiveStatusTimer = null;
218
- }, (node.config.clearDelay + 1) * 1000); // Check after clear delay passes
219
- }
220
-
221
- /**
222
- * Check alarm conditions and set alarm state
223
- */
224
- function checkAlarmConditions() {
225
- // Condition 1: Status active without a call (with hysteresis)
226
- if (node.actualState && !node.requestedState) {
227
- if (!node.statusLostTimer) {
228
- node.statusLostTimer = setTimeout(() => {
229
- if (node.actualState && !node.requestedState) {
230
- node.alarm = true;
231
- node.alarmMessage = "Status active without call";
232
- }
233
- node.statusLostTimer = null;
234
- }, 500); // 500ms hysteresis to prevent false alarms
228
+ if (!node.requestedState && node.actualState) {
229
+ node.alarm = true;
230
+ node.alarmMessage = "Status not clearing after call deactivated";
231
+ send(buildOutput());
232
+ updateNodeStatus();
235
233
  }
236
- return;
237
- }
238
-
239
- // Condition 2: Status lost during active call (checked by heartbeat/status update)
240
- // This is handled by heartbeat monitoring and status timeout handlers
241
-
242
- // If no conditions met and no timers running, clear alarm
243
- if (!node.heartbeatTimer && !node.statusLostTimer && !node.inactiveStatusTimer && !node.initialStatusTimer) {
244
- node.alarm = false;
245
- node.alarmMessage = "";
246
- }
234
+ }, (node.config.clearDelay + 1) * 1000);
247
235
  }
248
236
 
249
- /**
250
- * Process a requested state (call) change
251
- */
252
- function processRequestedState(value, send) {
253
- const { valid, value: boolValue, error } = utils.validateBoolean(value);
254
-
255
- if (!valid) {
256
- utils.setStatusError(node, error || "invalid requested state");
257
- return;
258
- }
259
-
260
- // No change
261
- if (boolValue === node.requestedState) {
262
- utils.setStatusUnchanged(node, "no change");
263
- return;
237
+ // ====================================================================
238
+ // Process call state change
239
+ // ====================================================================
240
+ function processCallChange(newCall, send) {
241
+ if (newCall === node.requestedState) {
242
+ return false; // No change
264
243
  }
265
244
 
266
- node.requestedState = boolValue;
245
+ node.requestedState = newCall;
267
246
 
268
247
  if (node.requestedState) {
269
- // Call activated - expect status to arrive and be maintained
248
+ // === Call activated ===
270
249
  node.neverReceivedStatus = true;
271
250
  node.alarm = false;
272
251
  node.alarmMessage = "";
273
252
 
274
- // Clear any existing timers
253
+ // Clear timers from previous cycle
275
254
  clearAllTimers();
276
255
 
277
- // Set timeout waiting for initial status response
278
- if (node.config.noStatusOnRun) {
256
+ // Start timeout waiting for initial status response
257
+ if (node.config.noStatusOnRun && node.config.statusTimeout > 0) {
279
258
  node.initialStatusTimer = setTimeout(() => {
259
+ node.initialStatusTimer = null;
280
260
  if (node.neverReceivedStatus && node.requestedState) {
281
261
  node.alarm = true;
282
262
  node.alarmMessage = node.config.noStatusOnRunMessage;
283
263
  send(buildOutput());
284
264
  updateNodeStatus();
285
265
  }
286
- node.initialStatusTimer = null;
287
266
  }, node.config.statusTimeout * 1000);
288
267
  }
289
268
  } else {
290
- // Call deactivated - start monitoring for status to go false
291
- if (node.initialStatusTimer) {
292
- clearTimeout(node.initialStatusTimer);
293
- node.initialStatusTimer = null;
294
- }
295
- if (node.heartbeatTimer) {
296
- clearTimeout(node.heartbeatTimer);
297
- node.heartbeatTimer = null;
298
- }
269
+ // === Call deactivated ===
270
+ if (node.initialStatusTimer) { clearTimeout(node.initialStatusTimer); node.initialStatusTimer = null; }
271
+ if (node.heartbeatTimer) { clearTimeout(node.heartbeatTimer); node.heartbeatTimer = null; }
272
+ if (node.statusLostTimer) { clearTimeout(node.statusLostTimer); node.statusLostTimer = null; }
299
273
 
300
274
  // Monitor that status goes inactive
301
275
  if (node.actualState) {
302
276
  startInactiveStatusMonitoring(send);
303
277
  }
304
278
 
279
+ // Schedule clear of state after delay
305
280
  if (node.config.clearDelay > 0) {
306
281
  node.clearTimer = setTimeout(() => {
282
+ node.clearTimer = null;
307
283
  node.actualState = false;
308
284
  node.alarm = false;
309
285
  node.alarmMessage = "";
310
286
  node.neverReceivedStatus = true;
311
287
  send(buildOutput());
312
288
  updateNodeStatus();
313
- node.clearTimer = null;
314
289
  }, node.config.clearDelay * 1000);
315
290
  } else {
316
- // No delay, clear immediately
317
291
  node.actualState = false;
318
292
  node.alarm = false;
319
293
  node.alarmMessage = "";
@@ -321,118 +295,231 @@ module.exports = function(RED) {
321
295
  }
322
296
  }
323
297
 
324
- checkAlarmConditions();
325
- send(buildOutput());
326
- updateNodeStatus();
298
+ return true; // State changed
327
299
  }
328
300
 
329
- /**
330
- * Process a status update with debounce
331
- */
332
- function processStatus(value, send) {
333
- const { valid, value: boolValue, error } = utils.validateBoolean(value);
334
-
335
- if (!valid) {
336
- utils.setStatusError(node, error || "invalid status");
337
- return;
301
+ // ====================================================================
302
+ // Process status update (after debounce, if applicable)
303
+ // ====================================================================
304
+ function processStatusChange(newStatus, send) {
305
+ node.actualState = newStatus;
306
+
307
+ // Clear status lost hysteresis on status going true
308
+ if (node.statusLostTimer && newStatus === true) {
309
+ clearTimeout(node.statusLostTimer);
310
+ node.statusLostTimer = null;
311
+ }
312
+
313
+ // If call active and status true → running, start heartbeat
314
+ if (node.requestedState && newStatus) {
315
+ node.alarm = false;
316
+ node.alarmMessage = "";
317
+ startHeartbeatMonitoring(send);
338
318
  }
339
319
 
340
- // Debounce rapid status changes
341
- if (node.debounceTimer) {
342
- clearTimeout(node.debounceTimer);
320
+ // If call active and status went false → status lost alarm
321
+ if (node.requestedState && !newStatus && node.config.runLostStatus) {
322
+ node.statusLostTimer = setTimeout(() => {
323
+ node.statusLostTimer = null;
324
+ if (node.requestedState && !node.actualState) {
325
+ node.alarm = true;
326
+ node.alarmMessage = node.config.runLostStatusMessage;
327
+ send(buildOutput());
328
+ updateNodeStatus();
329
+ }
330
+ }, 100); // 100ms hysteresis
343
331
  }
344
332
 
345
- node.debounceTimer = setTimeout(() => {
346
- // Check if status actually changed
347
- if (boolValue === node.actualState) {
348
- utils.setStatusUnchanged(node, "status unchanged");
349
- node.debounceTimer = null;
350
- return;
333
+ // If call inactive and status goes false → all clear
334
+ if (!node.requestedState && !newStatus) {
335
+ if (node.inactiveStatusTimer) {
336
+ clearTimeout(node.inactiveStatusTimer);
337
+ node.inactiveStatusTimer = null;
351
338
  }
339
+ node.alarm = false;
340
+ node.alarmMessage = "";
341
+ }
342
+
343
+ // If status active without call and no clearTimer running → unexpected
344
+ if (!node.requestedState && newStatus && !node.clearTimer && node.config.statusWithoutCall) {
345
+ node.statusLostTimer = setTimeout(() => {
346
+ node.statusLostTimer = null;
347
+ if (node.actualState && !node.requestedState) {
348
+ node.alarm = true;
349
+ node.alarmMessage = node.config.statusWithoutCallMessage;
350
+ send(buildOutput());
351
+ updateNodeStatus();
352
+ }
353
+ }, 100); // 100ms hysteresis
354
+ }
352
355
 
353
- node.actualState = boolValue;
356
+ return true;
357
+ }
358
+
359
+ // ====================================================================
360
+ // Process status with heartbeat refresh and optional debounce
361
+ //
362
+ // CRITICAL: lastStatusTime must be updated on EVERY status=true receipt,
363
+ // even if the value hasn't changed. Without this, heartbeat monitoring
364
+ // would alarm despite equipment continuously reporting status=true.
365
+ //
366
+ // neverReceivedStatus is only cleared when status=true is received,
367
+ // not when status=false comes in (false doesn't confirm equipment ran).
368
+ // ====================================================================
369
+ function processStatus(newStatus, send) {
370
+ // Only mark as "received" and update timestamp when status is true
371
+ // A false status doesn't confirm the equipment responded
372
+ if (newStatus === true) {
354
373
  node.lastStatusTime = Date.now();
355
374
  node.neverReceivedStatus = false;
356
375
 
357
- // Clear initial timeout if we finally got status
376
+ // Clear initial timeout we received a positive status response
358
377
  if (node.initialStatusTimer) {
359
378
  clearTimeout(node.initialStatusTimer);
360
379
  node.initialStatusTimer = null;
361
380
  }
381
+ }
362
382
 
363
- // Clear status lost hysteresis timer on successful update
364
- if (node.statusLostTimer && boolValue === true) {
365
- clearTimeout(node.statusLostTimer);
366
- node.statusLostTimer = null;
383
+ // If value hasn't changed (or reverted back), cancel any pending
384
+ // debounce and just refresh heartbeat timer (no output)
385
+ if (newStatus === node.actualState) {
386
+ // Cancel pending debounce — the transient change reverted
387
+ if (node.debounceTimer) {
388
+ clearTimeout(node.debounceTimer);
389
+ node.debounceTimer = null;
390
+ }
391
+
392
+ // CRITICAL: If alarm is active and we receive status=true with
393
+ // call active, clear the alarm. The heartbeat timer sets alarm
394
+ // without changing actualState, so we must recover here.
395
+ if (node.alarm && newStatus && node.requestedState) {
396
+ node.alarm = false;
397
+ node.alarmMessage = "";
398
+ startHeartbeatMonitoring(send);
399
+ return true; // Changed (alarm cleared) — caller should send
367
400
  }
368
401
 
369
- // If call is active and status is true, start heartbeat monitoring
370
- if (node.requestedState && boolValue) {
402
+ if (node.requestedState && node.actualState && node.config.heartbeatTimeout > 0) {
371
403
  startHeartbeatMonitoring(send);
372
404
  }
405
+ return false; // No change — caller decides whether to send
406
+ }
373
407
 
374
- // If call is inactive and status goes false, clear inactiveStatusTimer
375
- if (!node.requestedState && !boolValue && node.inactiveStatusTimer) {
376
- clearTimeout(node.inactiveStatusTimer);
377
- node.inactiveStatusTimer = null;
378
- node.alarm = false;
379
- node.alarmMessage = "";
408
+ // Value changed apply debounce if configured
409
+ if (node.config.debounce > 0) {
410
+ if (node.debounceTimer) {
411
+ clearTimeout(node.debounceTimer);
380
412
  }
413
+ node.debounceTimer = setTimeout(() => {
414
+ node.debounceTimer = null;
415
+ processStatusChange(newStatus, send);
416
+ send(buildOutput());
417
+ updateNodeStatus();
418
+ }, node.config.debounce);
419
+ return false; // Will send after debounce
420
+ } else {
421
+ // No debounce — process immediately
422
+ processStatusChange(newStatus, send);
423
+ return true; // Changed — caller should send
424
+ }
425
+ }
381
426
 
382
- // Re-evaluate alarm conditions
383
- checkAlarmConditions();
384
- send(buildOutput());
385
- updateNodeStatus();
386
- node.debounceTimer = null;
387
- }, node.config.debounce);
427
+ // ====================================================================
428
+ // Reset all state
429
+ // ====================================================================
430
+ function resetState(send) {
431
+ clearAllTimers();
432
+ node.requestedState = false;
433
+ node.actualState = false;
434
+ node.alarm = false;
435
+ node.alarmMessage = "";
436
+ node.lastStatusTime = null;
437
+ node.neverReceivedStatus = true;
438
+ utils.setStatusOK(node, "state reset");
439
+ send(buildOutput());
388
440
  }
389
441
 
390
- node.on("input", function(msg, send, done) {
442
+ // ====================================================================
443
+ // Initial status
444
+ // ====================================================================
445
+ utils.setStatusUnchanged(node, "call:OFF status:OFF | idle");
446
+
447
+ // ====================================================================
448
+ // Main input handler
449
+ // ====================================================================
450
+ node.on("input", async function(msg, send, done) {
391
451
  send = send || function() { node.send.apply(node, arguments); };
392
452
 
393
- // Validate message exists
394
- if (!msg || typeof msg !== 'object') {
453
+ if (!msg) {
395
454
  utils.setStatusError(node, "invalid message");
396
455
  if (done) done();
397
456
  return;
398
457
  }
399
458
 
400
- try {
401
- // ===== STATUS UPDATE (Dedicated Property Priority) =====
402
- // 1. Check dedicated status property first (msg.status)
403
- if (msg.hasOwnProperty(node.config.statusInputProperty) &&
404
- typeof msg[node.config.statusInputProperty] === 'boolean') {
405
- processStatus(msg[node.config.statusInputProperty], send);
406
- if (done) done();
407
- return;
408
- }
409
-
410
- // 2. Fallback to context tagging (msg.context === "status")
411
- if (msg.hasOwnProperty("context") && msg.context === "status") {
412
- processStatus(msg.payload, send);
413
- if (done) done();
414
- return;
415
- }
416
-
417
- // ===== REQUESTED STATE (Call) =====
418
- // Check configured input property
419
- if (msg.hasOwnProperty(node.config.inputProperty)) {
420
- processRequestedState(msg[node.config.inputProperty], send);
421
- if (done) done();
422
- return;
459
+ // Handle reset context (project convention)
460
+ if (msg.hasOwnProperty("context") && msg.context === "reset") {
461
+ if (msg.payload === true) {
462
+ resetState(send);
463
+ } else {
464
+ utils.setStatusError(node, "invalid reset");
423
465
  }
466
+ if (done) done();
467
+ return;
468
+ }
424
469
 
425
- // Default: no recognized command
426
- utils.setStatusWarn(node, "unrecognized input");
470
+ // ----------------------------------------------------------------
471
+ // 1. Evaluate typed inputs (async phase — acquire busy lock)
472
+ // ----------------------------------------------------------------
473
+ if (node.isBusy) {
474
+ utils.setStatusBusy(node);
427
475
  if (done) done();
476
+ return;
477
+ }
478
+ node.isBusy = true;
428
479
 
480
+ let callValue, statusValue;
481
+ try {
482
+ const results = await Promise.all([
483
+ evalBool(config.callValue, config.callValueType, node.requestedState, msg),
484
+ evalBool(config.statusValue, config.statusValueType, node.actualState, msg),
485
+ ]);
486
+ callValue = results[0];
487
+ statusValue = results[1];
429
488
  } catch (err) {
430
- node.error(`Error processing message: ${err.message}`);
431
- utils.setStatusError(node, `error: ${err.message}`);
489
+ node.error(`Error evaluating properties: ${err.message}`);
490
+ utils.setStatusError(node, `eval error: ${err.message}`);
432
491
  if (done) done();
492
+ return;
493
+ } finally {
494
+ node.isBusy = false;
433
495
  }
496
+
497
+ // ----------------------------------------------------------------
498
+ // 2. Process call and status values
499
+ // ----------------------------------------------------------------
500
+
501
+ // Track whether call is being deactivated (before processCallChange modifies state)
502
+ const callJustDeactivated = !callValue && node.requestedState;
503
+
504
+ // Process call first (may start/stop timers that status needs)
505
+ processCallChange(callValue, send);
506
+
507
+ // Process status (handles heartbeat refresh, debounce, alarms)
508
+ // Skip status processing if call was just deactivated with clearDelay=0
509
+ // (state was already fully cleared by processCallChange)
510
+ if (!(callJustDeactivated && node.config.clearDelay === 0)) {
511
+ processStatus(statusValue, send);
512
+ }
513
+
514
+ // Always send current state output on every message
515
+ send(buildOutput());
516
+ updateNodeStatus();
517
+ if (done) done();
434
518
  });
435
519
 
520
+ // ====================================================================
521
+ // Cleanup
522
+ // ====================================================================
436
523
  node.on("close", function(done) {
437
524
  clearAllTimers();
438
525
  done();