node-red-contrib-event-calc 3.3.6 → 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,37 +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 = {}) {
77
- const cache = globalContext.get(contextKey) || {};
78
- const existing = cache[topic];
109
+ node.setValue = function(topic, value, metadata) {
110
+ const existing = cache.get(topic);
111
+ const ts = Date.now();
79
112
 
80
113
  const entry = {
81
114
  value: value,
82
- ts: Date.now(),
83
- metadata: metadata,
115
+ ts: ts,
116
+ metadata: metadata || {},
84
117
  previous: existing
85
118
  ? { value: existing.value, ts: existing.ts, metadata: existing.metadata }
86
- : { value: value, ts: Date.now(), metadata: metadata }
119
+ : { value: value, ts: ts, metadata: metadata || {} }
87
120
  };
88
121
 
89
- cache[topic] = entry;
122
+ cache.set(topic, entry);
90
123
 
91
124
  // Enforce max entries (LRU eviction - remove oldest)
92
- const keys = Object.keys(cache);
93
- if (keys.length > node.maxEntries) {
94
- // Find oldest entry
95
- let oldestKey = keys[0];
96
- let oldestTs = cache[oldestKey].ts;
97
- for (const key of keys) {
98
- if (cache[key].ts < oldestTs) {
99
- 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;
100
131
  oldestKey = key;
101
132
  }
102
133
  }
103
- delete cache[oldestKey];
134
+ if (oldestKey !== null) {
135
+ cache.delete(oldestKey);
136
+ }
104
137
  }
105
138
 
106
- globalContext.set(contextKey, cache);
139
+ // Debounced sync to context (for sidebar visibility)
140
+ scheduleSyncToContext();
107
141
 
108
142
  // Emit topic-specific update event
109
143
  instance.emitter.emit('update', topic, entry);
@@ -112,11 +146,10 @@ module.exports = function(RED) {
112
146
  /**
113
147
  * Get a value from the cache
114
148
  * @param {string} topic - The topic key
115
- * @returns {object|undefined} - The cached entry {value, ts, metadata} or undefined
149
+ * @returns {object|undefined} - The cached entry {value, ts, metadata, previous} or undefined
116
150
  */
117
151
  node.getValue = function(topic) {
118
- const cache = globalContext.get(contextKey) || {};
119
- return cache[topic];
152
+ return cache.get(topic);
120
153
  };
121
154
 
122
155
  /**
@@ -125,8 +158,7 @@ module.exports = function(RED) {
125
158
  * @returns {object|undefined} - The previous entry {value, ts, metadata} or undefined
126
159
  */
127
160
  node.getPrevious = function(topic) {
128
- const cache = globalContext.get(contextKey) || {};
129
- const entry = cache[topic];
161
+ const entry = cache.get(topic);
130
162
  return entry ? entry.previous : undefined;
131
163
  };
132
164
 
@@ -168,8 +200,7 @@ module.exports = function(RED) {
168
200
  * @returns {string[]} - Array of all topic keys
169
201
  */
170
202
  node.getTopics = function() {
171
- const cache = globalContext.get(contextKey) || {};
172
- return Object.keys(cache);
203
+ return Array.from(cache.keys());
173
204
  };
174
205
 
175
206
  /**
@@ -177,14 +208,14 @@ module.exports = function(RED) {
177
208
  * @returns {number} - Cache size
178
209
  */
179
210
  node.size = function() {
180
- const cache = globalContext.get(contextKey) || {};
181
- return Object.keys(cache).length;
211
+ return cache.size;
182
212
  };
183
213
 
184
214
  /**
185
215
  * Clear all entries from cache
186
216
  */
187
217
  node.clear = function() {
218
+ cache.clear();
188
219
  globalContext.set(contextKey, {});
189
220
  };
190
221
 
@@ -209,6 +240,10 @@ module.exports = function(RED) {
209
240
  clearInterval(ttlInterval);
210
241
  }
211
242
 
243
+ // Final sync to context before closing
244
+ syncPending = true;
245
+ syncToContext();
246
+
212
247
  instance.users--;
213
248
  if (instance.users <= 0) {
214
249
  // Don't clear the context cache - let it persist