node-red-contrib-event-calc 3.3.3 → 3.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ /**
2
+ * event-alarm - Alarm management node with ISA-18.2 lifecycle
3
+ *
4
+ * Alarm states:
5
+ * UNACK_ALM - Active + Unacknowledged (just raised)
6
+ * ACK_ALM - Active + Acknowledged (operator acked, condition still true)
7
+ * UNACK_RTN - Inactive + Unacknowledged (condition cleared, not yet acked)
8
+ * NORM - Normal (fully resolved, removed from state)
9
+ *
10
+ * Lifecycle transitions:
11
+ * NORM → condition true → UNACK_ALM
12
+ * UNACK_ALM → ack → ACK_ALM
13
+ * ACK_ALM → condition false → NORM (resolved)
14
+ * UNACK_ALM → condition false → UNACK_RTN
15
+ * UNACK_RTN → ack → NORM (resolved)
16
+ *
17
+ * Outputs (one per state):
18
+ * 0: Raised (UNACK_ALM)
19
+ * 1: Acknowledged (ACK_ALM)
20
+ * 2: Cleared (UNACK_RTN)
21
+ * 3: Resolved (NORM - lifecycle complete)
22
+ */
23
+ module.exports = function(RED) {
24
+
25
+ function EventAlarmNode(config) {
26
+ RED.nodes.createNode(this, config);
27
+ const node = this;
28
+
29
+ node.cacheConfig = RED.nodes.getNode(config.cache);
30
+ node.inputMappings = config.inputMappings || [];
31
+ node.condition = config.condition || '';
32
+ node.conditionId = config.conditionId || node.id;
33
+ node.conditionName = config.conditionName || 'Alarm';
34
+ node.severity = parseInt(config.severity) || 500;
35
+ node.outputTopic = config.outputTopic || 'alarm/event';
36
+
37
+ const subscriptionIds = [];
38
+
39
+ if (!node.cacheConfig) {
40
+ node.status({ fill: "red", shape: "ring", text: "no cache configured" });
41
+ return;
42
+ }
43
+
44
+ if (node.inputMappings.length === 0) {
45
+ node.status({ fill: "yellow", shape: "ring", text: "no inputs defined" });
46
+ return;
47
+ }
48
+
49
+ if (!node.condition) {
50
+ node.status({ fill: "yellow", shape: "ring", text: "no condition" });
51
+ return;
52
+ }
53
+
54
+ // Alarm state dictionary: Map<source, alarmRecord>
55
+ // Stored in node context for persistence across restarts
56
+ const nodeContext = node.context();
57
+ let alarms = new Map();
58
+
59
+ // Restore from context
60
+ const stored = nodeContext.get('alarms');
61
+ if (stored && typeof stored === 'object') {
62
+ for (const [key, val] of Object.entries(stored)) {
63
+ alarms.set(key, val);
64
+ }
65
+ }
66
+
67
+ function saveAlarms() {
68
+ const obj = {};
69
+ for (const [key, val] of alarms) {
70
+ obj[key] = val;
71
+ }
72
+ nodeContext.set('alarms', obj);
73
+ }
74
+
75
+ // Track latest values from subscriptions
76
+ const latestValues = new Map();
77
+ const inputNames = node.inputMappings.map(m => m.name);
78
+
79
+ // Compile condition expression
80
+ let compiledFn = null;
81
+ try {
82
+ compiledFn = new Function(...inputNames, `return !!(${node.condition});`);
83
+ } catch (err) {
84
+ node.status({ fill: "red", shape: "ring", text: "compile error" });
85
+ return;
86
+ }
87
+
88
+ function buildAlarmMsg(record) {
89
+ return {
90
+ topic: node.outputTopic,
91
+ payload: {
92
+ condition_id: node.conditionId,
93
+ condition_name: node.conditionName,
94
+ source: record.source,
95
+ source_node: node.id,
96
+ active_state: record.active_state,
97
+ acked_state: record.acked_state,
98
+ severity: node.severity,
99
+ retain: record.retain,
100
+ ts: record.ts,
101
+ lifecycle: record.lifecycle
102
+ }
103
+ };
104
+ }
105
+
106
+ function updateStatus() {
107
+ const active = [...alarms.values()].filter(a => a.active_state === 'Active');
108
+ const unacked = [...alarms.values()].filter(a => a.acked_state === 'Unacknowledged');
109
+ if (alarms.size === 0) {
110
+ node.status({ fill: "green", shape: "dot", text: "normal" });
111
+ } else if (active.length > 0 && unacked.length > 0) {
112
+ node.status({ fill: "red", shape: "dot", text: `${alarms.size} alarm(s), ${unacked.length} unacked` });
113
+ } else if (active.length > 0) {
114
+ node.status({ fill: "yellow", shape: "dot", text: `${active.length} active` });
115
+ } else {
116
+ node.status({ fill: "blue", shape: "dot", text: `${unacked.length} unacked (cleared)` });
117
+ }
118
+ }
119
+
120
+ function evaluateCondition(triggerTopic, triggerTs) {
121
+ if (latestValues.size === 0) return;
122
+
123
+ // Build argument values
124
+ const args = inputNames.map(name => {
125
+ const data = latestValues.get(name);
126
+ return data ? data.value : undefined;
127
+ });
128
+
129
+ let isActive;
130
+ try {
131
+ isActive = compiledFn(...args);
132
+ } catch (err) {
133
+ node.status({ fill: "red", shape: "ring", text: "eval error" });
134
+ return;
135
+ }
136
+
137
+ const source = triggerTopic;
138
+ const now = triggerTs || Date.now();
139
+ const existing = alarms.get(source);
140
+
141
+ if (isActive && !existing) {
142
+ // NORM → UNACK_ALM: New alarm raised
143
+ const record = {
144
+ source: source,
145
+ active_state: 'Active',
146
+ acked_state: 'Unacknowledged',
147
+ retain: true,
148
+ ts: now,
149
+ lifecycle: {
150
+ raised_ts: now
151
+ }
152
+ };
153
+ alarms.set(source, record);
154
+ saveAlarms();
155
+ updateStatus();
156
+ node.send([buildAlarmMsg(record), null, null, null]);
157
+
158
+ } else if (!isActive && existing && existing.active_state === 'Active') {
159
+ if (existing.acked_state === 'Acknowledged') {
160
+ // ACK_ALM → NORM: Condition cleared after ack → fully resolved
161
+ existing.active_state = 'Inactive';
162
+ existing.retain = false;
163
+ existing.ts = now;
164
+ existing.lifecycle.resolved_ts = now;
165
+ const msg = buildAlarmMsg(existing);
166
+ alarms.delete(source);
167
+ saveAlarms();
168
+ updateStatus();
169
+ node.send([null, null, null, msg]);
170
+ } else {
171
+ // UNACK_ALM → UNACK_RTN: Condition cleared but not yet acked
172
+ existing.active_state = 'Inactive';
173
+ existing.retain = true;
174
+ existing.ts = now;
175
+ existing.lifecycle.cleared_ts = now;
176
+ saveAlarms();
177
+ updateStatus();
178
+ node.send([null, null, buildAlarmMsg(existing), null]);
179
+ }
180
+
181
+ } else if (isActive && existing && existing.active_state === 'Inactive') {
182
+ // UNACK_RTN → UNACK_ALM: Condition re-activated before ack
183
+ existing.active_state = 'Active';
184
+ existing.retain = true;
185
+ existing.ts = now;
186
+ existing.lifecycle.reraised_ts = now;
187
+ saveAlarms();
188
+ updateStatus();
189
+ node.send([buildAlarmMsg(existing), null, null, null]);
190
+ }
191
+ }
192
+
193
+ function handleAck(source) {
194
+ const existing = alarms.get(source);
195
+ if (!existing || existing.acked_state === 'Acknowledged') return;
196
+
197
+ const now = Date.now();
198
+ existing.acked_state = 'Acknowledged';
199
+ existing.ts = now;
200
+ existing.lifecycle.acked_ts = now;
201
+
202
+ if (existing.active_state === 'Inactive') {
203
+ // UNACK_RTN → NORM: Ack on cleared alarm → fully resolved
204
+ existing.retain = false;
205
+ existing.lifecycle.resolved_ts = now;
206
+ const msg = buildAlarmMsg(existing);
207
+ alarms.delete(source);
208
+ saveAlarms();
209
+ updateStatus();
210
+ node.send([null, null, null, msg]);
211
+ } else {
212
+ // UNACK_ALM → ACK_ALM: Ack on active alarm
213
+ saveAlarms();
214
+ updateStatus();
215
+ node.send([null, buildAlarmMsg(existing), null, null]);
216
+ }
217
+ }
218
+
219
+ // Subscribe to input topics
220
+ for (const input of node.inputMappings) {
221
+ const topicName = input.topic || input.pattern;
222
+ if (!input.name || !topicName) continue;
223
+
224
+ const subId = node.cacheConfig.subscribe(topicName, (topic, entry) => {
225
+ latestValues.set(input.name, {
226
+ topic: topic,
227
+ value: entry.value,
228
+ ts: entry.ts
229
+ });
230
+ evaluateCondition(topic, entry.ts);
231
+ });
232
+ subscriptionIds.push(subId);
233
+ }
234
+
235
+ updateStatus();
236
+
237
+ // Handle input messages (ack, ack_all, clear)
238
+ node.on('input', function(msg, send, done) {
239
+ send = send || function() { node.send.apply(node, arguments); };
240
+ done = done || function(err) { if (err) node.error(err, msg); };
241
+
242
+ const action = (msg.payload && msg.payload.action) || msg.action;
243
+
244
+ if (action === 'ack') {
245
+ const source = (msg.payload && msg.payload.source) || msg.source;
246
+ if (source) {
247
+ handleAck(source);
248
+ } else {
249
+ node.warn("ack requires a source");
250
+ }
251
+ } else if (action === 'ack_all') {
252
+ const sources = [...alarms.keys()];
253
+ for (const source of sources) {
254
+ handleAck(source);
255
+ }
256
+ } else if (action === 'list') {
257
+ const list = [];
258
+ for (const [source, record] of alarms) {
259
+ list.push({ ...record });
260
+ }
261
+ send([null, null, null, { topic: node.outputTopic + '/list', payload: list }]);
262
+ }
263
+
264
+ done();
265
+ });
266
+
267
+ node.on('close', function(done) {
268
+ for (const subId of subscriptionIds) {
269
+ if (node.cacheConfig) {
270
+ node.cacheConfig.unsubscribe(subId);
271
+ }
272
+ }
273
+ subscriptionIds.length = 0;
274
+ saveAlarms();
275
+ done();
276
+ });
277
+ }
278
+
279
+ RED.nodes.registerType("event-alarm", EventAlarmNode);
280
+
281
+ // HTTP endpoint to get active alarms
282
+ RED.httpAdmin.get("/event-alarm/:id/alarms", function(req, res) {
283
+ const node = RED.nodes.getNode(req.params.id);
284
+ if (node) {
285
+ const ctx = node.context();
286
+ const stored = ctx.get('alarms') || {};
287
+ res.json(stored);
288
+ } else {
289
+ res.sendStatus(404);
290
+ }
291
+ });
292
+ };
@@ -2,8 +2,8 @@
2
2
  * event-cache - Config node providing central cache and event bus
3
3
  *
4
4
  * Features:
5
- * - Map<topic, {value, ts, metadata}> for caching latest values
6
- * - Stored in global context for visibility in sidebar
5
+ * - Map<topic, {value, ts, metadata, previous}> for caching latest values
6
+ * - In-memory primary store with debounced sync to global context for sidebar
7
7
  * - EventEmitter for notifying subscribers on updates
8
8
  * - Exact topic matching for subscriptions
9
9
  * - LRU eviction when maxEntries exceeded
@@ -35,7 +35,10 @@ module.exports = function(RED) {
35
35
  // Subscription storage: Map<topic, Map<subId, callback>> for O(1) exact match
36
36
  subscriptions: new Map(),
37
37
  users: 0,
38
- subscriptionCounter: 0
38
+ subscriptionCounter: 0,
39
+ // In-memory primary cache store (Map for O(1) access)
40
+ cache: new Map(),
41
+ entryCount: 0
39
42
  });
40
43
  }
41
44
 
@@ -43,9 +46,39 @@ module.exports = function(RED) {
43
46
  instance.users++;
44
47
  instance.emitter.setMaxListeners(100); // Allow many subscribers
45
48
 
46
- // Initialize cache in global context if not exists
47
- if (!globalContext.get(contextKey)) {
48
- globalContext.set(contextKey, {});
49
+ // Reference to the in-memory cache
50
+ const cache = instance.cache;
51
+
52
+ // Initialize from global context if cache is empty (e.g. after restart)
53
+ if (cache.size === 0) {
54
+ const stored = globalContext.get(contextKey);
55
+ if (stored && typeof stored === 'object') {
56
+ for (const [topic, entry] of Object.entries(stored)) {
57
+ cache.set(topic, entry);
58
+ }
59
+ }
60
+ }
61
+ instance.entryCount = cache.size;
62
+
63
+ // Debounced sync to global context for sidebar visibility
64
+ let syncPending = false;
65
+ const SYNC_INTERVAL = 500; // ms
66
+
67
+ function syncToContext() {
68
+ if (!syncPending) return;
69
+ syncPending = false;
70
+ const obj = {};
71
+ for (const [topic, entry] of cache) {
72
+ obj[topic] = entry;
73
+ }
74
+ globalContext.set(contextKey, obj);
75
+ }
76
+
77
+ function scheduleSyncToContext() {
78
+ if (!syncPending) {
79
+ syncPending = true;
80
+ setTimeout(syncToContext, SYNC_INTERVAL);
81
+ }
49
82
  }
50
83
 
51
84
  // TTL cleanup interval
@@ -53,16 +86,16 @@ module.exports = function(RED) {
53
86
  if (node.ttl > 0) {
54
87
  ttlInterval = setInterval(() => {
55
88
  const now = Date.now();
56
- const cache = globalContext.get(contextKey) || {};
57
89
  let changed = false;
58
- for (const topic of Object.keys(cache)) {
59
- if (now - cache[topic].ts > node.ttl) {
60
- delete cache[topic];
90
+ for (const [topic, entry] of cache) {
91
+ if (now - entry.ts > node.ttl) {
92
+ cache.delete(topic);
61
93
  changed = true;
62
94
  }
63
95
  }
64
96
  if (changed) {
65
- globalContext.set(contextKey, cache);
97
+ instance.entryCount = cache.size;
98
+ scheduleSyncToContext();
66
99
  }
67
100
  }, Math.min(node.ttl, 60000)); // Check at most every minute
68
101
  }
@@ -73,32 +106,38 @@ module.exports = function(RED) {
73
106
  * @param {any} value - The value to store
74
107
  * @param {object} metadata - Optional metadata
75
108
  */
76
- node.setValue = function(topic, value, metadata = {}) {
109
+ node.setValue = function(topic, value, metadata) {
110
+ const existing = cache.get(topic);
111
+ const ts = Date.now();
112
+
77
113
  const entry = {
78
114
  value: value,
79
- ts: Date.now(),
80
- metadata: metadata
115
+ ts: ts,
116
+ metadata: metadata || {},
117
+ previous: existing
118
+ ? { value: existing.value, ts: existing.ts, metadata: existing.metadata }
119
+ : { value: value, ts: ts, metadata: metadata || {} }
81
120
  };
82
121
 
83
- const cache = globalContext.get(contextKey) || {};
84
- cache[topic] = entry;
122
+ cache.set(topic, entry);
85
123
 
86
124
  // Enforce max entries (LRU eviction - remove oldest)
87
- const keys = Object.keys(cache);
88
- if (keys.length > node.maxEntries) {
89
- // Find oldest entry
90
- let oldestKey = keys[0];
91
- let oldestTs = cache[oldestKey].ts;
92
- for (const key of keys) {
93
- if (cache[key].ts < oldestTs) {
94
- oldestTs = cache[key].ts;
125
+ if (cache.size > node.maxEntries) {
126
+ let oldestKey = null;
127
+ let oldestTs = Infinity;
128
+ for (const [key, e] of cache) {
129
+ if (e.ts < oldestTs) {
130
+ oldestTs = e.ts;
95
131
  oldestKey = key;
96
132
  }
97
133
  }
98
- delete cache[oldestKey];
134
+ if (oldestKey !== null) {
135
+ cache.delete(oldestKey);
136
+ }
99
137
  }
100
138
 
101
- globalContext.set(contextKey, cache);
139
+ // Debounced sync to context (for sidebar visibility)
140
+ scheduleSyncToContext();
102
141
 
103
142
  // Emit topic-specific update event
104
143
  instance.emitter.emit('update', topic, entry);
@@ -107,11 +146,20 @@ module.exports = function(RED) {
107
146
  /**
108
147
  * Get a value from the cache
109
148
  * @param {string} topic - The topic key
110
- * @returns {object|undefined} - The cached entry {value, ts, metadata} or undefined
149
+ * @returns {object|undefined} - The cached entry {value, ts, metadata, previous} or undefined
111
150
  */
112
151
  node.getValue = function(topic) {
113
- const cache = globalContext.get(contextKey) || {};
114
- return cache[topic];
152
+ return cache.get(topic);
153
+ };
154
+
155
+ /**
156
+ * Get the previous value for a topic
157
+ * @param {string} topic - The topic key
158
+ * @returns {object|undefined} - The previous entry {value, ts, metadata} or undefined
159
+ */
160
+ node.getPrevious = function(topic) {
161
+ const entry = cache.get(topic);
162
+ return entry ? entry.previous : undefined;
115
163
  };
116
164
 
117
165
  /**
@@ -152,8 +200,7 @@ module.exports = function(RED) {
152
200
  * @returns {string[]} - Array of all topic keys
153
201
  */
154
202
  node.getTopics = function() {
155
- const cache = globalContext.get(contextKey) || {};
156
- return Object.keys(cache);
203
+ return Array.from(cache.keys());
157
204
  };
158
205
 
159
206
  /**
@@ -161,14 +208,14 @@ module.exports = function(RED) {
161
208
  * @returns {number} - Cache size
162
209
  */
163
210
  node.size = function() {
164
- const cache = globalContext.get(contextKey) || {};
165
- return Object.keys(cache).length;
211
+ return cache.size;
166
212
  };
167
213
 
168
214
  /**
169
215
  * Clear all entries from cache
170
216
  */
171
217
  node.clear = function() {
218
+ cache.clear();
172
219
  globalContext.set(contextKey, {});
173
220
  };
174
221
 
@@ -193,6 +240,10 @@ module.exports = function(RED) {
193
240
  clearInterval(ttlInterval);
194
241
  }
195
242
 
243
+ // Final sync to context before closing
244
+ syncPending = true;
245
+ syncToContext();
246
+
196
247
  instance.users--;
197
248
  if (instance.users <= 0) {
198
249
  // Don't clear the context cache - let it persist