@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.
- package/LICENSE.md +38 -5
- package/README.md +10 -0
- package/nodes/accumulate-block.html +3 -3
- package/nodes/alarm-collector.html +11 -0
- package/nodes/alarm-collector.js +31 -5
- package/nodes/alarm-config.html +11 -6
- package/nodes/alarm-config.js +34 -35
- package/nodes/alarm-service.js +2 -2
- package/nodes/and-block.js +1 -1
- package/nodes/boolean-switch-block.html +27 -14
- package/nodes/boolean-switch-block.js +22 -12
- package/nodes/call-status-block.html +83 -56
- package/nodes/call-status-block.js +335 -248
- package/nodes/changeover-block.html +30 -31
- package/nodes/changeover-block.js +287 -389
- package/nodes/contextual-label-block.js +3 -3
- package/nodes/delay-block.js +74 -13
- package/nodes/global-getter.html +173 -12
- package/nodes/global-getter.js +29 -14
- package/nodes/global-setter.html +37 -0
- package/nodes/global-setter.js +96 -14
- package/nodes/history-buffer.js +32 -27
- package/nodes/history-collector.html +3 -1
- package/nodes/history-collector.js +4 -4
- package/nodes/history-config.html +8 -2
- package/nodes/network-point-read.js +6 -1
- package/nodes/network-point-register.html +1 -1
- package/nodes/network-service-bridge.js +43 -11
- package/nodes/network-service-registry.html +236 -27
- package/nodes/network-service-registry.js +1 -1
- package/nodes/or-block.js +1 -1
- package/nodes/priority-block.js +1 -1
- package/nodes/tstat-block.html +34 -79
- package/nodes/tstat-block.js +223 -345
- package/nodes/utils.js +1 -1
- package/package.json +90 -75
|
@@ -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
|
-
//
|
|
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;
|
|
15
|
-
|
|
60
|
+
node.neverReceivedStatus = true;
|
|
61
|
+
|
|
62
|
+
// ====================================================================
|
|
16
63
|
// Timer management
|
|
17
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
143
|
+
text = `ALARM: ${node.alarmMessage} | call:${node.requestedState} status:${node.actualState}`;
|
|
99
144
|
utils.setStatusError(node, text);
|
|
100
|
-
} else if (
|
|
101
|
-
text =
|
|
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 =
|
|
152
|
+
text = `call:ON status:ON | running`;
|
|
111
153
|
utils.setStatusOK(node, text);
|
|
112
|
-
} else if (node.requestedState &&
|
|
113
|
-
text =
|
|
114
|
-
utils.
|
|
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 =
|
|
161
|
+
text = `call:OFF status:OFF | idle`;
|
|
117
162
|
utils.setStatusUnchanged(node, text);
|
|
118
163
|
} else {
|
|
119
|
-
text =
|
|
164
|
+
text = `call:OFF status:OFF | idle`;
|
|
120
165
|
utils.setStatusUnchanged(node, text);
|
|
121
166
|
}
|
|
122
167
|
}
|
|
123
168
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
169
|
+
// ====================================================================
|
|
170
|
+
// Timer management
|
|
171
|
+
// ====================================================================
|
|
127
172
|
function clearAllTimers() {
|
|
128
|
-
if (node.initialStatusTimer) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
if (node.
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (node.requestedState
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
function
|
|
253
|
-
|
|
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 =
|
|
245
|
+
node.requestedState = newCall;
|
|
267
246
|
|
|
268
247
|
if (node.requestedState) {
|
|
269
|
-
// Call activated
|
|
248
|
+
// === Call activated ===
|
|
270
249
|
node.neverReceivedStatus = true;
|
|
271
250
|
node.alarm = false;
|
|
272
251
|
node.alarmMessage = "";
|
|
273
252
|
|
|
274
|
-
// Clear
|
|
253
|
+
// Clear timers from previous cycle
|
|
275
254
|
clearAllTimers();
|
|
276
255
|
|
|
277
|
-
//
|
|
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
|
|
291
|
-
if (node.initialStatusTimer) {
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
325
|
-
send(buildOutput());
|
|
326
|
-
updateNodeStatus();
|
|
298
|
+
return true; // State changed
|
|
327
299
|
}
|
|
328
300
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
function
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
//
|
|
341
|
-
if (node.
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
node.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
node.
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
|
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();
|