@bldgblocks/node-red-contrib-control 0.1.34 → 0.1.37
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/nodes/accumulate-block.html +18 -8
- package/nodes/accumulate-block.js +39 -44
- package/nodes/add-block.html +1 -1
- package/nodes/add-block.js +18 -11
- package/nodes/alarm-collector.html +260 -0
- package/nodes/alarm-collector.js +292 -0
- package/nodes/alarm-config.html +129 -0
- package/nodes/alarm-config.js +126 -0
- package/nodes/alarm-service.html +96 -0
- package/nodes/alarm-service.js +142 -0
- package/nodes/analog-switch-block.js +25 -36
- package/nodes/and-block.js +44 -15
- package/nodes/average-block.js +46 -41
- package/nodes/boolean-switch-block.js +10 -28
- package/nodes/boolean-to-number-block.html +18 -5
- package/nodes/boolean-to-number-block.js +24 -16
- package/nodes/cache-block.js +24 -37
- package/nodes/call-status-block.html +91 -32
- package/nodes/call-status-block.js +398 -115
- package/nodes/changeover-block.html +5 -0
- package/nodes/changeover-block.js +167 -162
- package/nodes/comment-block.html +1 -1
- package/nodes/comment-block.js +14 -9
- package/nodes/compare-block.html +14 -4
- package/nodes/compare-block.js +23 -18
- package/nodes/contextual-label-block.html +5 -0
- package/nodes/contextual-label-block.js +6 -16
- package/nodes/convert-block.html +25 -39
- package/nodes/convert-block.js +31 -16
- package/nodes/count-block.html +11 -5
- package/nodes/count-block.js +34 -32
- package/nodes/delay-block.js +58 -53
- package/nodes/divide-block.js +43 -45
- package/nodes/edge-block.html +17 -10
- package/nodes/edge-block.js +43 -41
- package/nodes/enum-switch-block.js +6 -6
- package/nodes/frequency-block.html +6 -1
- package/nodes/frequency-block.js +64 -74
- package/nodes/global-getter.html +51 -15
- package/nodes/global-getter.js +43 -13
- package/nodes/global-setter.html +1 -1
- package/nodes/global-setter.js +40 -12
- package/nodes/history-buffer.html +96 -0
- package/nodes/history-buffer.js +464 -0
- package/nodes/history-collector.html +29 -1
- package/nodes/history-collector.js +46 -16
- package/nodes/history-config.html +13 -1
- package/nodes/history-service.html +84 -0
- package/nodes/history-service.js +66 -0
- package/nodes/hysteresis-block.html +5 -0
- package/nodes/hysteresis-block.js +13 -16
- package/nodes/interpolate-block.html +20 -2
- package/nodes/interpolate-block.js +39 -50
- package/nodes/join.html +78 -0
- package/nodes/join.js +78 -0
- package/nodes/latch-block.js +12 -14
- package/nodes/load-sequence-block.js +102 -110
- package/nodes/max-block.js +26 -26
- package/nodes/memory-block.js +57 -58
- package/nodes/min-block.js +26 -25
- package/nodes/minmax-block.js +35 -34
- package/nodes/modulo-block.js +45 -43
- package/nodes/multiply-block.js +43 -41
- package/nodes/negate-block.html +17 -7
- package/nodes/negate-block.js +25 -19
- package/nodes/network-point-read.html +128 -0
- package/nodes/network-point-read.js +230 -0
- package/nodes/{network-register.html → network-point-register.html} +94 -7
- package/nodes/{network-register.js → network-point-register.js} +18 -4
- package/nodes/network-point-write.html +149 -0
- package/nodes/network-point-write.js +222 -0
- package/nodes/network-service-bridge.html +131 -0
- package/nodes/network-service-bridge.js +376 -0
- package/nodes/network-service-read.html +81 -0
- package/nodes/{network-read.js → network-service-read.js} +4 -3
- package/nodes/{network-point-registry.html → network-service-registry.html} +19 -4
- package/nodes/{network-point-registry.js → network-service-registry.js} +7 -2
- package/nodes/network-service-write.html +89 -0
- package/nodes/{network-write.js → network-service-write.js} +3 -3
- package/nodes/nullify-block.js +13 -15
- package/nodes/on-change-block.html +17 -9
- package/nodes/on-change-block.js +49 -46
- package/nodes/oneshot-block.html +13 -10
- package/nodes/oneshot-block.js +57 -75
- package/nodes/or-block.js +44 -15
- package/nodes/pid-block.html +54 -4
- package/nodes/pid-block.js +459 -248
- package/nodes/priority-block.js +24 -35
- package/nodes/rate-limit-block.js +70 -72
- package/nodes/rate-of-change-block.html +33 -14
- package/nodes/rate-of-change-block.js +74 -62
- package/nodes/round-block.html +14 -9
- package/nodes/round-block.js +32 -25
- package/nodes/saw-tooth-wave-block.js +49 -76
- package/nodes/scale-range-block.html +12 -6
- package/nodes/scale-range-block.js +46 -39
- package/nodes/sine-wave-block.js +49 -57
- package/nodes/string-builder-block.js +6 -6
- package/nodes/subtract-block.js +38 -34
- package/nodes/thermistor-block.js +44 -44
- package/nodes/tick-tock-block.js +32 -32
- package/nodes/time-sequence-block.js +30 -42
- package/nodes/triangle-wave-block.js +49 -69
- package/nodes/tstat-block.js +34 -44
- package/nodes/units-block.html +90 -69
- package/nodes/units-block.js +22 -30
- package/nodes/utils.js +206 -3
- package/package.json +14 -6
- package/nodes/network-read.html +0 -56
- package/nodes/network-write.html +0 -65
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
const utils = require('./utils')(RED);
|
|
3
|
+
|
|
4
|
+
function AlarmCollectorNode(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
const node = this;
|
|
7
|
+
|
|
8
|
+
// Initialize configuration
|
|
9
|
+
node.name = config.name || "alarm-collector";
|
|
10
|
+
node.alarmConfigId = config.alarmConfig;
|
|
11
|
+
node.inputMode = config.inputMode || "value";
|
|
12
|
+
node.inputField = config.inputField || "payload";
|
|
13
|
+
node.alarmWhenTrue = config.alarmWhenTrue !== false;
|
|
14
|
+
node.highThreshold = parseFloat(config.highThreshold) || 85;
|
|
15
|
+
node.lowThreshold = parseFloat(config.lowThreshold) || 68;
|
|
16
|
+
node.compareMode = config.compareMode || "either";
|
|
17
|
+
node.hysteresisTime = parseInt(config.hysteresisTime) || 500;
|
|
18
|
+
node.hysteresisMagnitude = parseFloat(config.hysteresisMagnitude) || 2;
|
|
19
|
+
node.priority = config.priority || "normal";
|
|
20
|
+
node.topic = config.topic || "Alarms_Default";
|
|
21
|
+
node.title = config.title || "Alarm";
|
|
22
|
+
node.message = config.message || "Condition triggered";
|
|
23
|
+
node.tags = config.tags || "";
|
|
24
|
+
node.units = config.units || "";
|
|
25
|
+
|
|
26
|
+
// Get reference to alarm-config node
|
|
27
|
+
node.alarmConfig = RED.nodes.getNode(node.alarmConfigId);
|
|
28
|
+
if (!node.alarmConfig) {
|
|
29
|
+
utils.setStatusWarn(node, "Alarm registry not configured");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Getter pattern: optional target global-setter node selection
|
|
33
|
+
node.sourceNodeId = config.sourceNode || null;
|
|
34
|
+
node.sourceNodeType = config.sourceNodeType || "wired";
|
|
35
|
+
let setterNode = null;
|
|
36
|
+
if (node.sourceNodeType === "setter" && node.sourceNodeId) {
|
|
37
|
+
setterNode = RED.nodes.getNode(node.sourceNodeId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Runtime state
|
|
41
|
+
node.currentValue = null;
|
|
42
|
+
node.alarmState = false;
|
|
43
|
+
node.lastEmittedState = null;
|
|
44
|
+
node.hysteresisTimer = null;
|
|
45
|
+
node.conditionMet = false;
|
|
46
|
+
node.valueChangedListener = null;
|
|
47
|
+
|
|
48
|
+
utils.setStatusOK(node, `idle`);
|
|
49
|
+
|
|
50
|
+
// ====================================================================
|
|
51
|
+
// Helper: Evaluate alarm condition and emit event if state changed
|
|
52
|
+
// ====================================================================
|
|
53
|
+
function evaluateAndEmit(inputValue) {
|
|
54
|
+
// Evaluate alarm condition based on input mode
|
|
55
|
+
let conditionNowMet = false;
|
|
56
|
+
let numericValue = null;
|
|
57
|
+
|
|
58
|
+
if (node.inputMode === "boolean") {
|
|
59
|
+
// Boolean mode: compare directly with alarmWhenTrue
|
|
60
|
+
conditionNowMet = (inputValue === node.alarmWhenTrue);
|
|
61
|
+
node.currentValue = inputValue;
|
|
62
|
+
} else {
|
|
63
|
+
// Value mode: parse as numeric and check thresholds
|
|
64
|
+
numericValue = inputValue;
|
|
65
|
+
if (typeof inputValue === 'object' && inputValue !== null && inputValue.value !== undefined) {
|
|
66
|
+
numericValue = inputValue.value;
|
|
67
|
+
}
|
|
68
|
+
numericValue = parseFloat(numericValue);
|
|
69
|
+
|
|
70
|
+
if (isNaN(numericValue)) {
|
|
71
|
+
utils.setStatusError(node, "Invalid numeric input");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
node.currentValue = numericValue;
|
|
76
|
+
|
|
77
|
+
// Check thresholds
|
|
78
|
+
if (node.compareMode === "either") {
|
|
79
|
+
conditionNowMet = (numericValue > node.highThreshold) || (numericValue < node.lowThreshold);
|
|
80
|
+
} else if (node.compareMode === "high-only") {
|
|
81
|
+
conditionNowMet = (numericValue > node.highThreshold);
|
|
82
|
+
} else if (node.compareMode === "low-only") {
|
|
83
|
+
conditionNowMet = (numericValue < node.lowThreshold);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Time-based hysteresis logic
|
|
88
|
+
if (conditionNowMet && !node.conditionMet) {
|
|
89
|
+
// Condition just became true - start hysteresis timer
|
|
90
|
+
node.conditionMet = true;
|
|
91
|
+
|
|
92
|
+
if (node.hysteresisTimer) clearTimeout(node.hysteresisTimer);
|
|
93
|
+
|
|
94
|
+
node.hysteresisTimer = setTimeout(() => {
|
|
95
|
+
if (node.conditionMet && node.alarmState === false) {
|
|
96
|
+
// Condition stayed true for hysteresisTime ms
|
|
97
|
+
node.alarmState = true;
|
|
98
|
+
emitAlarmEvent("false → true");
|
|
99
|
+
}
|
|
100
|
+
node.hysteresisTimer = null;
|
|
101
|
+
}, node.hysteresisTime);
|
|
102
|
+
|
|
103
|
+
} else if (!conditionNowMet && node.conditionMet) {
|
|
104
|
+
// Condition just became false - cancel pending timer
|
|
105
|
+
node.conditionMet = false;
|
|
106
|
+
|
|
107
|
+
if (node.hysteresisTimer) {
|
|
108
|
+
clearTimeout(node.hysteresisTimer);
|
|
109
|
+
node.hysteresisTimer = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check magnitude hysteresis before clearing
|
|
113
|
+
let shouldClear = true;
|
|
114
|
+
if (node.inputMode === "value" && node.alarmState === true) {
|
|
115
|
+
if (node.compareMode === "either" || node.compareMode === "high-only") {
|
|
116
|
+
const clearThreshold = node.highThreshold - node.hysteresisMagnitude;
|
|
117
|
+
if (numericValue > clearThreshold) {
|
|
118
|
+
shouldClear = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (node.compareMode === "either" || node.compareMode === "low-only") {
|
|
122
|
+
const clearThreshold = node.lowThreshold + node.hysteresisMagnitude;
|
|
123
|
+
if (numericValue < clearThreshold) {
|
|
124
|
+
shouldClear = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (shouldClear && node.alarmState === true) {
|
|
130
|
+
node.alarmState = false;
|
|
131
|
+
emitAlarmEvent("true → false");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Update status display
|
|
136
|
+
let statusText;
|
|
137
|
+
if (node.inputMode === "boolean") {
|
|
138
|
+
statusText = `${inputValue ? "true" : "false"}`;
|
|
139
|
+
} else {
|
|
140
|
+
statusText = `${numericValue.toFixed(2)} ${node.units}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (node.alarmState) {
|
|
144
|
+
utils.setStatusError(node, statusText + " [ALARM]");
|
|
145
|
+
} else if (node.conditionMet) {
|
|
146
|
+
utils.setStatusWarn(node, statusText + " (hysteresis)");
|
|
147
|
+
} else {
|
|
148
|
+
utils.setStatusOK(node, statusText);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ====================================================================
|
|
153
|
+
// Emit alarm event (only on state transition)
|
|
154
|
+
// ====================================================================
|
|
155
|
+
function emitAlarmEvent(transition) {
|
|
156
|
+
if (node.lastEmittedState === node.alarmState) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
node.lastEmittedState = node.alarmState;
|
|
161
|
+
|
|
162
|
+
const eventData = {
|
|
163
|
+
nodeId: node.id,
|
|
164
|
+
nodeName: node.name,
|
|
165
|
+
value: node.currentValue,
|
|
166
|
+
highThreshold: node.inputMode === "value" ? node.highThreshold : undefined,
|
|
167
|
+
lowThreshold: node.inputMode === "value" ? node.lowThreshold : undefined,
|
|
168
|
+
compareMode: node.inputMode === "value" ? node.compareMode : undefined,
|
|
169
|
+
state: node.alarmState,
|
|
170
|
+
priority: node.priority,
|
|
171
|
+
topic: node.topic,
|
|
172
|
+
title: node.title,
|
|
173
|
+
message: node.message,
|
|
174
|
+
tags: node.tags,
|
|
175
|
+
units: node.units,
|
|
176
|
+
timestamp: new Date().toISOString(),
|
|
177
|
+
transition: transition
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Register/update alarm in registry
|
|
181
|
+
if (node.alarmConfig) {
|
|
182
|
+
const alarmName = node.name;
|
|
183
|
+
node.alarmConfig.register(alarmName, {
|
|
184
|
+
nodeId: node.id,
|
|
185
|
+
pointId: node.currentValue,
|
|
186
|
+
severity: node.priority,
|
|
187
|
+
status: node.alarmState ? 'active' : 'cleared',
|
|
188
|
+
title: node.title,
|
|
189
|
+
message: node.message,
|
|
190
|
+
topic: node.topic,
|
|
191
|
+
timestamp: new Date().toISOString()
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Emit to fixed event - service listens here
|
|
196
|
+
RED.events.emit("bldgblocks:alarms:state-change", eventData);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ====================================================================
|
|
200
|
+
// Setup listeners based on mode (wired or target node)
|
|
201
|
+
// ====================================================================
|
|
202
|
+
|
|
203
|
+
// If target global-setter selected, listen to value changes (same as global-getter)
|
|
204
|
+
if (setterNode && setterNode.varName) {
|
|
205
|
+
node.valueChangedListener = function(evt) {
|
|
206
|
+
if (evt.key === setterNode.varName && evt.store === setterNode.storeName) {
|
|
207
|
+
// Extract value from the global data object
|
|
208
|
+
let val = evt.data;
|
|
209
|
+
if (val && typeof val === 'object' && val.hasOwnProperty('value')) {
|
|
210
|
+
val = val.value;
|
|
211
|
+
}
|
|
212
|
+
if (val !== undefined && val !== null) {
|
|
213
|
+
evaluateAndEmit(val);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
RED.events.on("bldgblocks:global:value-changed", node.valueChangedListener);
|
|
219
|
+
utils.setStatusOK(node, `monitoring ${setterNode.varName}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Wired input handler
|
|
223
|
+
node.on("input", async function(msg, send, done) {
|
|
224
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
225
|
+
|
|
226
|
+
if (!msg) {
|
|
227
|
+
utils.setStatusError(node, "invalid message");
|
|
228
|
+
if (done) done();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// If using target node, ignore wired input
|
|
233
|
+
if (setterNode) {
|
|
234
|
+
if (done) done();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Get input value from configured msg property using evaluateNodeProperty
|
|
239
|
+
try {
|
|
240
|
+
let inputValue = await utils.evaluateNodeProperty(node.inputField, node.inputFieldType || "msg", node, msg);
|
|
241
|
+
|
|
242
|
+
if (inputValue === undefined || inputValue === null) {
|
|
243
|
+
utils.setStatusError(node, `missing field: ${node.inputField}`);
|
|
244
|
+
if (done) done();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Evaluate based on input mode
|
|
249
|
+
if (node.inputMode === "boolean") {
|
|
250
|
+
inputValue = Boolean(inputValue);
|
|
251
|
+
} else {
|
|
252
|
+
inputValue = parseFloat(inputValue);
|
|
253
|
+
if (isNaN(inputValue)) {
|
|
254
|
+
utils.setStatusError(node, "invalid numeric input");
|
|
255
|
+
if (done) done();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
evaluateAndEmit(inputValue);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
utils.setStatusError(node, `Error reading input: ${err.message}`);
|
|
263
|
+
node.error(err);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (done) done();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
node.on("close", function(done) {
|
|
270
|
+
// Cleanup timers
|
|
271
|
+
if (node.hysteresisTimer) {
|
|
272
|
+
clearTimeout(node.hysteresisTimer);
|
|
273
|
+
node.hysteresisTimer = null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Unregister alarm from registry
|
|
277
|
+
if (node.alarmConfig) {
|
|
278
|
+
node.alarmConfig.unregister(node.name, node.id);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Remove global value-changed listener
|
|
282
|
+
if (node.valueChangedListener) {
|
|
283
|
+
RED.events.off("bldgblocks:global:value-changed", node.valueChangedListener);
|
|
284
|
+
node.valueChangedListener = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
done();
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
RED.nodes.registerType("alarm-collector", AlarmCollectorNode);
|
|
292
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="alarm-config">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
4
|
+
<input type="text" id="node-config-input-name" placeholder="Alarm Registry">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-tips">
|
|
7
|
+
<p><b>Alarm Registry</b></p>
|
|
8
|
+
<p>This node maintains all active alarms and their current status (active/cleared).</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="form-row">
|
|
12
|
+
<label><i class="fa fa-exclamation-triangle"></i> Alarms</label>
|
|
13
|
+
<div id="node-input-alarm-list-div"
|
|
14
|
+
style="
|
|
15
|
+
border:1px solid #ccc;
|
|
16
|
+
height:250px;
|
|
17
|
+
overflow-y:auto;
|
|
18
|
+
padding:5px;
|
|
19
|
+
box-sizing:border-box;">
|
|
20
|
+
<ul id="node-input-alarm-list" style="margin:0;padding-left:1.2em;"></ul>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<script type="text/javascript">
|
|
26
|
+
RED.nodes.registerType("alarm-config", {
|
|
27
|
+
category: 'config',
|
|
28
|
+
defaults: {
|
|
29
|
+
name: { value: "" }
|
|
30
|
+
},
|
|
31
|
+
label: function() {
|
|
32
|
+
return this.name || "Alarm Registry";
|
|
33
|
+
},
|
|
34
|
+
oneditprepare: function() {
|
|
35
|
+
const node = this;
|
|
36
|
+
const $list = $("#node-input-alarm-list");
|
|
37
|
+
|
|
38
|
+
function loadAlarms() {
|
|
39
|
+
$.getJSON(`/alarm-config/list/${node.id}`, function(data) {
|
|
40
|
+
$list.empty();
|
|
41
|
+
if (!data.length) {
|
|
42
|
+
return $list.append('<li style="color:#999;">No alarms registered</li>');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Data already sorted by server
|
|
46
|
+
data.forEach(alarm => {
|
|
47
|
+
const li = $('<li>')
|
|
48
|
+
.css({
|
|
49
|
+
display: 'flex',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
padding: '4px 0',
|
|
52
|
+
borderBottom: '1px solid #eee'
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Status indicator (colored circle)
|
|
56
|
+
const statusColor = alarm.status === 'active' ? '#d32f2f' : '#4caf50';
|
|
57
|
+
const statusIcon = alarm.status === 'active' ? 'fa-circle' : 'fa-check-circle';
|
|
58
|
+
const statusDot = $('<i>')
|
|
59
|
+
.addClass(`fa ${statusIcon}`)
|
|
60
|
+
.css({
|
|
61
|
+
color: statusColor,
|
|
62
|
+
marginRight: '8px',
|
|
63
|
+
width: '16px'
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Alarm info (name, severity, point)
|
|
67
|
+
const infoText = $('<span>')
|
|
68
|
+
.html(`<strong>${alarm.name}</strong> <span style="color:#666;font-size:0.9em;">[${alarm.severity}] #${alarm.pointId}</span>`)
|
|
69
|
+
.css({ flexGrow: 1 });
|
|
70
|
+
|
|
71
|
+
// Reveal button (find node on canvas)
|
|
72
|
+
const revealBtn = $('<button type="button" class="editor-button">')
|
|
73
|
+
.attr('title', 'Reveal alarm collector on canvas')
|
|
74
|
+
.html('<i class="fa fa-search"></i>')
|
|
75
|
+
.on('click', function(e) {
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
RED.view.reveal(alarm.nodeId);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
li.append(statusDot, infoText, revealBtn);
|
|
81
|
+
$list.append(li);
|
|
82
|
+
});
|
|
83
|
+
}).fail(function() {
|
|
84
|
+
$list.html('<li style="color:red;">Could not load alarms</li>');
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Load alarms on editor open
|
|
89
|
+
loadAlarms();
|
|
90
|
+
|
|
91
|
+
// Optionally: reload on a timer for live updates
|
|
92
|
+
// setInterval(loadAlarms, 2000);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<script type="text/markdown" data-help-name="alarm-config">
|
|
98
|
+
Maintains a registry of active alarms and their status.
|
|
99
|
+
|
|
100
|
+
### Details
|
|
101
|
+
The Alarm Registry keeps track of all active alarms generated by alarm-collector nodes. Each alarm has:
|
|
102
|
+
- **Name**: Unique identifier (usually derived from point ID and condition)
|
|
103
|
+
- **Status**: Current state (active or cleared)
|
|
104
|
+
- **Severity**: Level assigned by the alarm-collector
|
|
105
|
+
- **Point ID**: Source network point that triggered the alarm
|
|
106
|
+
|
|
107
|
+
This config node is referenced by alarm-collector nodes and used by the alarm-service node to subscribe to alarm updates.
|
|
108
|
+
|
|
109
|
+
### Features
|
|
110
|
+
- **Live Status Display**: Shows all active alarms with color-coded status (red = active, green = cleared)
|
|
111
|
+
- **Reveal Button**: Click to find an alarm collector on the canvas
|
|
112
|
+
- **Severity Indication**: See the priority level of each alarm at a glance
|
|
113
|
+
|
|
114
|
+
### Usage
|
|
115
|
+
1. Create a single alarm-config node for your workflow
|
|
116
|
+
2. Reference it from each alarm-collector node
|
|
117
|
+
3. The alarm-service node listens to events from all collectors and uses this registry for state tracking
|
|
118
|
+
|
|
119
|
+
### API for Developers
|
|
120
|
+
* `register(alarmName, meta)`: Register an alarm
|
|
121
|
+
* `unregister(alarmName, nodeId)`: Remove an alarm
|
|
122
|
+
* `lookup(alarmName)`: Find alarm details by name
|
|
123
|
+
* `updateStatus(alarmName, status)`: Update alarm state
|
|
124
|
+
* `getAll()`: Get all alarms
|
|
125
|
+
|
|
126
|
+
### References
|
|
127
|
+
- [Node-RED Documentation](https://nodered.org/docs/)
|
|
128
|
+
- [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
|
|
129
|
+
</script>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
|
|
3
|
+
function AlarmConfigNode(config) {
|
|
4
|
+
RED.nodes.createNode(this, config);
|
|
5
|
+
const node = this;
|
|
6
|
+
|
|
7
|
+
// Register this registry with utils for global lookup
|
|
8
|
+
const utils = require('./utils')(RED);
|
|
9
|
+
utils.registerRegistryNode(node);
|
|
10
|
+
|
|
11
|
+
// The Map: stores alarm metadata by name
|
|
12
|
+
// Format: { "alarmName": { nodeId: "abc.123", pointId: 101, severity: "high", status: "active", ... } }
|
|
13
|
+
node.alarms = new Map();
|
|
14
|
+
|
|
15
|
+
// Register an alarm in the registry
|
|
16
|
+
node.register = function(alarmName, meta) {
|
|
17
|
+
if (!alarmName || typeof alarmName !== 'string') {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (node.alarms.has(alarmName)) {
|
|
22
|
+
const existing = node.alarms.get(alarmName);
|
|
23
|
+
// Allow update if it's the same node
|
|
24
|
+
if (existing.nodeId !== meta.nodeId) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
// Merge updates (preserving status if provided)
|
|
28
|
+
meta = Object.assign({}, existing, meta);
|
|
29
|
+
}
|
|
30
|
+
node.alarms.set(alarmName, meta);
|
|
31
|
+
return true;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Unregister an alarm
|
|
35
|
+
node.unregister = function(alarmName, nodeId) {
|
|
36
|
+
if (node.alarms.has(alarmName) && node.alarms.get(alarmName).nodeId === nodeId) {
|
|
37
|
+
node.alarms.delete(alarmName);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Lookup an alarm by name
|
|
42
|
+
node.lookup = function(alarmName) {
|
|
43
|
+
return node.alarms.get(alarmName);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Update alarm status
|
|
47
|
+
node.updateStatus = function(alarmName, status) {
|
|
48
|
+
if (node.alarms.has(alarmName)) {
|
|
49
|
+
const alarm = node.alarms.get(alarmName);
|
|
50
|
+
alarm.status = status; // 'active' or 'cleared'
|
|
51
|
+
alarm.lastUpdate = new Date().toISOString();
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Get all alarms
|
|
58
|
+
node.getAll = function() {
|
|
59
|
+
const arr = [];
|
|
60
|
+
for (const [name, meta] of node.alarms.entries()) {
|
|
61
|
+
arr.push({ name, ...meta });
|
|
62
|
+
}
|
|
63
|
+
return arr;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
RED.nodes.registerType("alarm-config", AlarmConfigNode);
|
|
67
|
+
|
|
68
|
+
// --- HTTP Endpoint: List all alarms in a specific config ---
|
|
69
|
+
// Route: /alarm-config/list/<ConfigID>
|
|
70
|
+
RED.httpAdmin.get('/alarm-config/list/:configId', RED.auth.needsPermission('alarm-config.read'), function(req, res) {
|
|
71
|
+
const configId = req.params.configId;
|
|
72
|
+
|
|
73
|
+
// Find the alarm-config node
|
|
74
|
+
const configNode = RED.nodes.getNode(configId);
|
|
75
|
+
if (!configNode) {
|
|
76
|
+
return res.status(404).json({ error: 'Configuration node not found' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Get all alarms from this config
|
|
80
|
+
const alarms = configNode.getAll();
|
|
81
|
+
|
|
82
|
+
// Sort by name
|
|
83
|
+
alarms.sort((a, b) => a.name.localeCompare(b.name));
|
|
84
|
+
|
|
85
|
+
res.json(alarms);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// --- HTTP Endpoint: Check if alarm exists ---
|
|
89
|
+
// Route: /alarm-config/check/<ConfigID>/<AlarmName>/<CurrentNodeID>
|
|
90
|
+
RED.httpAdmin.get('/alarm-config/check/:configId/:alarmName/:nodeId', RED.auth.needsPermission('alarm-config.read'), function(req, res) {
|
|
91
|
+
const configId = req.params.configId;
|
|
92
|
+
const alarmName = decodeURIComponent(req.params.alarmName);
|
|
93
|
+
const checkNodeId = req.params.nodeId;
|
|
94
|
+
|
|
95
|
+
// Find the alarm-config node
|
|
96
|
+
const configNode = RED.nodes.getNode(configId);
|
|
97
|
+
|
|
98
|
+
let entry = null;
|
|
99
|
+
let result = "unavailable";
|
|
100
|
+
let collision = false;
|
|
101
|
+
|
|
102
|
+
if (!configNode) {
|
|
103
|
+
// Config exists in editor but not deployed yet, or doesn't exist
|
|
104
|
+
return res.json({ status: result, warning: "Configuration not deployed" });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check for the alarm
|
|
108
|
+
entry = configNode.lookup(alarmName);
|
|
109
|
+
if (entry) {
|
|
110
|
+
// Collision if alarm exists AND belongs to a different node
|
|
111
|
+
if (entry.nodeId !== checkNodeId) {
|
|
112
|
+
collision = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (collision) {
|
|
117
|
+
result = "collision";
|
|
118
|
+
} else if (!collision && entry) {
|
|
119
|
+
result = "assigned";
|
|
120
|
+
} else {
|
|
121
|
+
result = "available";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
res.json({ status: result, details: entry });
|
|
125
|
+
});
|
|
126
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="alarm-service">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Node Name</label>
|
|
4
|
+
<input type="text" id="node-input-name" placeholder="Alarm Relay">
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="form-row">
|
|
8
|
+
<label for="node-input-filterTopic"><i class="fa fa-filter"></i> Filter by Topic (optional)</label>
|
|
9
|
+
<input type="text" id="node-input-filterTopic" placeholder="Leave blank to relay all topics">
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="form-row">
|
|
13
|
+
<label for="node-input-filterPriority"><i class="fa fa-exclamation-triangle"></i> Filter by Priority (optional)</label>
|
|
14
|
+
<select id="node-input-filterPriority">
|
|
15
|
+
<option value="">All Priorities</option>
|
|
16
|
+
<option value="urgent">Urgent</option>
|
|
17
|
+
<option value="high">High</option>
|
|
18
|
+
<option value="normal">Normal</option>
|
|
19
|
+
<option value="low">Low</option>
|
|
20
|
+
</select>
|
|
21
|
+
</div>
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script type="text/javascript">
|
|
25
|
+
RED.nodes.registerType("alarm-service", {
|
|
26
|
+
category: "bldgblocks alarms",
|
|
27
|
+
color: "#800020",
|
|
28
|
+
defaults: {
|
|
29
|
+
name: { value: "" },
|
|
30
|
+
filterTopic: { value: "" },
|
|
31
|
+
filterPriority: { value: "" }
|
|
32
|
+
},
|
|
33
|
+
inputs: 0,
|
|
34
|
+
outputs: 1,
|
|
35
|
+
outputLabels: ["alarm events"],
|
|
36
|
+
icon: "font-awesome/fa-bell",
|
|
37
|
+
label: function() {
|
|
38
|
+
return this.name || "alarm-service";
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<script type="text/markdown" data-help-name="alarm-service">
|
|
44
|
+
Listens to alarm events from all collectors and relays them downstream with status information.
|
|
45
|
+
|
|
46
|
+
### Configuration
|
|
47
|
+
- **Node Name**: Display name for this relay instance
|
|
48
|
+
- **Filter by Topic** (optional): Only relay alarms matching a specific topic (e.g., "Alarms_HVAC")
|
|
49
|
+
- **Filter by Priority** (optional): Only relay alarms with specific priority level
|
|
50
|
+
|
|
51
|
+
### Outputs
|
|
52
|
+
Single output with status included in message:
|
|
53
|
+
- **payload** (object): Full alarm event data with topic, priority, title, message, etc.
|
|
54
|
+
- **status** (object): `{ state: "triggered" | "cleared", transition: "false → true" | "true → false" }`
|
|
55
|
+
- **activeAlarmCount** (number): Current number of active alarms being tracked
|
|
56
|
+
- **alarmKey** (string): Topic or auto-generated ID for grouping related alarms
|
|
57
|
+
|
|
58
|
+
### How It Works
|
|
59
|
+
1. Listens to `bldgblocks:alarms:state-change` event emitted by all collectors
|
|
60
|
+
2. Filters alarms by topic and priority (if configured)
|
|
61
|
+
3. Tracks state transitions (cleared→alarmed or alarmed→cleared)
|
|
62
|
+
4. Includes transition state in `msg.status` for downstream routing logic
|
|
63
|
+
5. Updates status showing number of active alarms
|
|
64
|
+
|
|
65
|
+
### Status
|
|
66
|
+
- **Green dot**: Service running, no current alarms
|
|
67
|
+
- **Yellow ring**: Multiple alarms active
|
|
68
|
+
- **Red ring**: Alarm(s) active
|
|
69
|
+
|
|
70
|
+
### Downstream Routing
|
|
71
|
+
|
|
72
|
+
Route using `msg.status.state` field:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
[alarm-service]
|
|
76
|
+
├─ [Switch node] → status.state == "triggered"
|
|
77
|
+
│ ├─ [Format for ntfy] → [HTTP POST] → ntfy.sh (push notification)
|
|
78
|
+
│ ├─ [InfluxDB out] → InfluxDB (record alarm trigger)
|
|
79
|
+
│ └─ [Email] → Send alert email
|
|
80
|
+
│
|
|
81
|
+
└─ [Switch node] → status.state == "cleared"
|
|
82
|
+
├─ [Format for ntfy] → [HTTP POST] → ntfy.sh (all clear)
|
|
83
|
+
└─ [InfluxDB out] → InfluxDB (record alarm cleared)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Control Messages
|
|
87
|
+
|
|
88
|
+
Send control messages via wired input (optional):
|
|
89
|
+
- `{ context: "getStatus" }` — Returns array of active alarms
|
|
90
|
+
- `{ context: "clearAll", payload: true }` — Clears alarm tracking (for testing/reset)
|
|
91
|
+
|
|
92
|
+
### References
|
|
93
|
+
- Pair with [alarm-collector] nodes to monitor conditions
|
|
94
|
+
- Use `msg.status.state` to conditionally handle triggered vs cleared alarms
|
|
95
|
+
- Multiple services can listen to same collectors (fan-out/routing pattern)
|
|
96
|
+
</script>
|