node-red-contrib-event-calc 0.1.2 → 0.1.4

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.
@@ -3,53 +3,17 @@
3
3
  *
4
4
  * Features:
5
5
  * - Map<topic, {value, ts, metadata}> for caching latest values
6
+ * - Stored in global context for visibility in sidebar
6
7
  * - EventEmitter for notifying subscribers on updates
7
- * - Wildcard matching (* for one or more chars, ? for exactly one char)
8
+ * - Exact topic matching for subscriptions
8
9
  * - LRU eviction when maxEntries exceeded
9
10
  * - Reference counting for cleanup
10
11
  */
11
12
  module.exports = function(RED) {
12
13
  const EventEmitter = require('events');
13
14
 
14
- // Shared cache instances per config node ID
15
- const cacheInstances = new Map();
16
-
17
- /**
18
- * Check if a pattern contains wildcards
19
- * @param {string} pattern - Topic pattern
20
- * @returns {boolean} - True if pattern contains * or ?
21
- */
22
- function hasWildcard(pattern) {
23
- return pattern && (pattern.includes('*') || pattern.includes('?'));
24
- }
25
-
26
- /**
27
- * Convert topic pattern to RegExp
28
- *
29
- * Wildcards:
30
- * - '*' (asterisk): Matches one or more characters
31
- * Example: 'sensors/*' matches 'sensors/temp', 'sensors/room1/temp'
32
- *
33
- * - '?' (question mark): Matches exactly one character
34
- * Example: 'sensor?' matches 'sensor1', 'sensorA' but NOT 'sensor' or 'sensor12'
35
- *
36
- * @param {string} pattern - Topic pattern with wildcards
37
- * @returns {RegExp} - Regular expression for matching
38
- */
39
- function patternToRegex(pattern) {
40
- // Handle empty pattern or just *
41
- if (!pattern || pattern === '*') {
42
- return /^.+$/;
43
- }
44
-
45
- // Escape regex special characters except our wildcards (* and ?)
46
- let regexStr = pattern
47
- .replace(/[.^$|()[\]{}\\+#/]/g, '\\$&') // Escape regex special chars (including /)
48
- .replace(/\?/g, '.') // ? matches exactly one character
49
- .replace(/\*/g, '.+'); // * matches one or more characters
50
-
51
- return new RegExp(`^${regexStr}$`);
52
- }
15
+ // Shared instances for event emitters and subscriptions (not stored in context)
16
+ const sharedInstances = new Map();
53
17
 
54
18
  function EventCacheNode(config) {
55
19
  RED.nodes.createNode(this, config);
@@ -59,36 +23,47 @@ module.exports = function(RED) {
59
23
  node.maxEntries = parseInt(config.maxEntries) || 10000;
60
24
  node.ttl = parseInt(config.ttl) || 0; // 0 = no expiry
61
25
 
62
- // Create or get shared cache instance
63
- const cacheKey = node.id;
64
- if (!cacheInstances.has(cacheKey)) {
65
- cacheInstances.set(cacheKey, {
66
- cache: new Map(),
26
+ // Context key for storing cache data (visible in sidebar)
27
+ const contextKey = `eventCache_${node.name.replace(/[^a-zA-Z0-9_]/g, '_')}`;
28
+ const globalContext = node.context().global;
29
+
30
+ // Create or get shared instance for emitters/subscriptions (not serializable)
31
+ const instanceKey = node.id;
32
+ if (!sharedInstances.has(instanceKey)) {
33
+ sharedInstances.set(instanceKey, {
67
34
  emitter: new EventEmitter(),
68
- // Optimized subscription storage:
69
- // - exactSubscriptions: Map<topic, Map<subId, callback>> for O(1) exact match
70
- // - wildcardSubscriptions: Map<subId, {pattern, regex, callback}> for pattern matching
71
- exactSubscriptions: new Map(),
72
- wildcardSubscriptions: new Map(),
35
+ // Subscription storage: Map<topic, Map<subId, callback>> for O(1) exact match
36
+ subscriptions: new Map(),
73
37
  users: 0,
74
38
  subscriptionCounter: 0
75
39
  });
76
40
  }
77
41
 
78
- const instance = cacheInstances.get(cacheKey);
42
+ const instance = sharedInstances.get(instanceKey);
79
43
  instance.users++;
80
44
  instance.emitter.setMaxListeners(100); // Allow many subscribers
81
45
 
46
+ // Initialize cache in global context if not exists
47
+ if (!globalContext.get(contextKey)) {
48
+ globalContext.set(contextKey, {});
49
+ }
50
+
82
51
  // TTL cleanup interval
83
52
  let ttlInterval = null;
84
53
  if (node.ttl > 0) {
85
54
  ttlInterval = setInterval(() => {
86
55
  const now = Date.now();
87
- for (const [topic, entry] of instance.cache) {
88
- if (now - entry.ts > node.ttl) {
89
- instance.cache.delete(topic);
56
+ const cache = globalContext.get(contextKey) || {};
57
+ let changed = false;
58
+ for (const topic of Object.keys(cache)) {
59
+ if (now - cache[topic].ts > node.ttl) {
60
+ delete cache[topic];
61
+ changed = true;
90
62
  }
91
63
  }
64
+ if (changed) {
65
+ globalContext.set(contextKey, cache);
66
+ }
92
67
  }, Math.min(node.ttl, 60000)); // Check at most every minute
93
68
  }
94
69
 
@@ -105,14 +80,26 @@ module.exports = function(RED) {
105
80
  metadata: metadata
106
81
  };
107
82
 
108
- instance.cache.set(topic, entry);
83
+ const cache = globalContext.get(contextKey) || {};
84
+ cache[topic] = entry;
109
85
 
110
86
  // Enforce max entries (LRU eviction - remove oldest)
111
- if (instance.cache.size > node.maxEntries) {
112
- const firstKey = instance.cache.keys().next().value;
113
- instance.cache.delete(firstKey);
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;
95
+ oldestKey = key;
96
+ }
97
+ }
98
+ delete cache[oldestKey];
114
99
  }
115
100
 
101
+ globalContext.set(contextKey, cache);
102
+
116
103
  // Emit topic-specific update event
117
104
  instance.emitter.emit('update', topic, entry);
118
105
  };
@@ -123,52 +110,23 @@ module.exports = function(RED) {
123
110
  * @returns {object|undefined} - The cached entry {value, ts, metadata} or undefined
124
111
  */
125
112
  node.getValue = function(topic) {
126
- return instance.cache.get(topic);
127
- };
128
-
129
- /**
130
- * Get all values matching a pattern
131
- * @param {string} pattern - Pattern with * and ? wildcards
132
- * @returns {Map} - Map of matching topic -> entry
133
- */
134
- node.getMatching = function(pattern) {
135
- const regex = patternToRegex(pattern);
136
- const results = new Map();
137
-
138
- for (const [topic, entry] of instance.cache) {
139
- if (regex.test(topic)) {
140
- results.set(topic, entry);
141
- }
142
- }
143
-
144
- return results;
113
+ const cache = globalContext.get(contextKey) || {};
114
+ return cache[topic];
145
115
  };
146
116
 
147
117
  /**
148
- * Subscribe to updates matching a pattern
149
- * Optimized: exact matches use O(1) lookup, wildcards use pattern matching
150
- * @param {string} pattern - Topic pattern with wildcards
151
- * @param {Function} callback - Called with (topic, entry) on match
118
+ * Subscribe to updates for a specific topic
119
+ * @param {string} topic - The exact topic to subscribe to
120
+ * @param {Function} callback - Called with (topic, entry) on update
152
121
  * @returns {string} - Subscription ID for unsubscribe
153
122
  */
154
- node.subscribe = function(pattern, callback) {
123
+ node.subscribe = function(topic, callback) {
155
124
  const subId = `sub_${++instance.subscriptionCounter}`;
156
125
 
157
- if (hasWildcard(pattern)) {
158
- // Wildcard pattern - store with regex for matching
159
- const regex = patternToRegex(pattern);
160
- instance.wildcardSubscriptions.set(subId, {
161
- pattern: pattern,
162
- regex: regex,
163
- callback: callback
164
- });
165
- } else {
166
- // Exact match - store in topic-indexed map for O(1) lookup
167
- if (!instance.exactSubscriptions.has(pattern)) {
168
- instance.exactSubscriptions.set(pattern, new Map());
169
- }
170
- instance.exactSubscriptions.get(pattern).set(subId, callback);
126
+ if (!instance.subscriptions.has(topic)) {
127
+ instance.subscriptions.set(topic, new Map());
171
128
  }
129
+ instance.subscriptions.get(topic).set(subId, callback);
172
130
 
173
131
  return subId;
174
132
  };
@@ -178,17 +136,11 @@ module.exports = function(RED) {
178
136
  * @param {string} subscriptionId - The subscription ID to remove
179
137
  */
180
138
  node.unsubscribe = function(subscriptionId) {
181
- // Try wildcard subscriptions first
182
- if (instance.wildcardSubscriptions.delete(subscriptionId)) {
183
- return;
184
- }
185
-
186
- // Search exact subscriptions
187
- for (const [topic, subs] of instance.exactSubscriptions) {
139
+ for (const [topic, subs] of instance.subscriptions) {
188
140
  if (subs.delete(subscriptionId)) {
189
141
  // Clean up empty topic maps
190
142
  if (subs.size === 0) {
191
- instance.exactSubscriptions.delete(topic);
143
+ instance.subscriptions.delete(topic);
192
144
  }
193
145
  return;
194
146
  }
@@ -200,7 +152,8 @@ module.exports = function(RED) {
200
152
  * @returns {string[]} - Array of all topic keys
201
153
  */
202
154
  node.getTopics = function() {
203
- return Array.from(instance.cache.keys());
155
+ const cache = globalContext.get(contextKey) || {};
156
+ return Object.keys(cache);
204
157
  };
205
158
 
206
159
  /**
@@ -208,23 +161,22 @@ module.exports = function(RED) {
208
161
  * @returns {number} - Cache size
209
162
  */
210
163
  node.size = function() {
211
- return instance.cache.size;
164
+ const cache = globalContext.get(contextKey) || {};
165
+ return Object.keys(cache).length;
212
166
  };
213
167
 
214
168
  /**
215
169
  * Clear all entries from cache
216
170
  */
217
171
  node.clear = function() {
218
- instance.cache.clear();
172
+ globalContext.set(contextKey, {});
219
173
  };
220
174
 
221
- // Internal: dispatch updates to matching subscriptions
222
- // Optimized: O(1) for exact matches, O(w) for wildcard patterns
175
+ // Internal: dispatch updates to matching subscriptions (O(1) lookup)
223
176
  const updateHandler = (topic, entry) => {
224
- // First: O(1) lookup for exact subscriptions
225
- const exactSubs = instance.exactSubscriptions.get(topic);
226
- if (exactSubs) {
227
- for (const [subId, callback] of exactSubs) {
177
+ const subs = instance.subscriptions.get(topic);
178
+ if (subs) {
179
+ for (const [subId, callback] of subs) {
228
180
  try {
229
181
  callback(topic, entry);
230
182
  } catch (err) {
@@ -232,17 +184,6 @@ module.exports = function(RED) {
232
184
  }
233
185
  }
234
186
  }
235
-
236
- // Second: iterate only wildcard subscriptions (typically fewer)
237
- for (const [subId, sub] of instance.wildcardSubscriptions) {
238
- if (sub.regex.test(topic)) {
239
- try {
240
- sub.callback(topic, entry);
241
- } catch (err) {
242
- RED.log.error(`[event-cache] Subscription callback error: ${err.message}`);
243
- }
244
- }
245
- }
246
187
  };
247
188
  instance.emitter.on('update', updateHandler);
248
189
 
@@ -254,11 +195,10 @@ module.exports = function(RED) {
254
195
 
255
196
  instance.users--;
256
197
  if (instance.users <= 0) {
257
- instance.cache.clear();
258
- instance.exactSubscriptions.clear();
259
- instance.wildcardSubscriptions.clear();
198
+ // Don't clear the context cache - let it persist
199
+ instance.subscriptions.clear();
260
200
  instance.emitter.removeAllListeners();
261
- cacheInstances.delete(cacheKey);
201
+ sharedInstances.delete(instanceKey);
262
202
  }
263
203
  done();
264
204
  });
@@ -281,11 +221,11 @@ module.exports = function(RED) {
281
221
  RED.httpAdmin.get("/event-cache/:id/stats", function(req, res) {
282
222
  const node = RED.nodes.getNode(req.params.id);
283
223
  if (node) {
284
- const instance = cacheInstances.get(node.id);
285
- let exactSubCount = 0;
224
+ const instance = sharedInstances.get(node.id);
225
+ let subCount = 0;
286
226
  if (instance) {
287
- for (const subs of instance.exactSubscriptions.values()) {
288
- exactSubCount += subs.size;
227
+ for (const subs of instance.subscriptions.values()) {
228
+ subCount += subs.size;
289
229
  }
290
230
  }
291
231
  res.json({
@@ -294,9 +234,8 @@ module.exports = function(RED) {
294
234
  maxEntries: node.maxEntries,
295
235
  ttl: node.ttl,
296
236
  subscriptions: {
297
- exact: exactSubCount,
298
- wildcard: instance ? instance.wildcardSubscriptions.size : 0,
299
- exactTopics: instance ? instance.exactSubscriptions.size : 0
237
+ count: subCount,
238
+ topics: instance ? instance.subscriptions.size : 0
300
239
  }
301
240
  });
302
241
  } else {
@@ -317,11 +256,17 @@ module.exports = function(RED) {
317
256
  // HTTP Admin endpoint to get topics from all caches
318
257
  RED.httpAdmin.get("/event-cache/topics/all", function(req, res) {
319
258
  const allTopics = new Set();
320
- for (const [cacheKey, instance] of cacheInstances) {
321
- for (const topic of instance.cache.keys()) {
322
- allTopics.add(topic);
259
+ // Get all event-cache nodes and collect their topics
260
+ RED.nodes.eachNode(function(n) {
261
+ if (n.type === 'event-cache') {
262
+ const cacheNode = RED.nodes.getNode(n.id);
263
+ if (cacheNode && cacheNode.getTopics) {
264
+ for (const topic of cacheNode.getTopics()) {
265
+ allTopics.add(topic);
266
+ }
267
+ }
323
268
  }
324
- }
269
+ });
325
270
  res.json(Array.from(allTopics).sort());
326
271
  });
327
272
  };
@@ -16,7 +16,8 @@
16
16
  outputTopic: { value: "calc/result" }
17
17
  },
18
18
  inputs: 1,
19
- outputs: 1,
19
+ outputs: 2,
20
+ outputLabels: ["result", "error"],
20
21
  icon: "font-awesome/fa-calculator",
21
22
  label: function() {
22
23
  return this.name || this.expression || "event calc";
@@ -34,7 +35,7 @@
34
35
  $.getJSON("event-cache/" + cacheId + "/topics", function(topics) {
35
36
  cachedTopics = topics || [];
36
37
  // Update existing autocomplete instances
37
- $(".input-pattern").each(function() {
38
+ $(".input-topic").each(function() {
38
39
  $(this).autocomplete("option", "source", cachedTopics);
39
40
  });
40
41
  });
@@ -92,17 +93,17 @@
92
93
 
93
94
  $('<span/>', { style: "flex: 0 0 auto;" }).text(" = ").appendTo(row);
94
95
 
95
- const patternInput = $('<input/>', {
96
+ const topicInput = $('<input/>', {
96
97
  type: "text",
97
98
  placeholder: "topic (type to search cached topics)",
98
- class: "input-pattern"
99
+ class: "input-topic"
99
100
  })
100
101
  .css({ flex: "1 1 auto" })
101
- .val(data.pattern || "")
102
+ .val(data.topic || data.pattern || "")
102
103
  .appendTo(row);
103
104
 
104
- // Add autocomplete to pattern input
105
- patternInput.autocomplete({
105
+ // Add autocomplete to topic input
106
+ topicInput.autocomplete({
106
107
  source: cachedTopics,
107
108
  minLength: 0,
108
109
  delay: 0
@@ -135,9 +136,9 @@
135
136
 
136
137
  $("#node-input-inputMappings-list").editableList('items').each(function() {
137
138
  const name = $(this).find(".input-name").val().trim();
138
- const pattern = $(this).find(".input-pattern").val().trim();
139
- if (name && pattern) {
140
- node.inputMappings.push({ name: name, pattern: pattern });
139
+ const topic = $(this).find(".input-topic").val().trim();
140
+ if (name && topic) {
141
+ node.inputMappings.push({ name: name, topic: topic });
141
142
  }
142
143
  });
143
144
  }
@@ -157,7 +158,7 @@
157
158
  <label style="width:100%;"><i class="fa fa-sign-in"></i> Input Variables</label>
158
159
  <ol id="node-input-inputMappings-list"></ol>
159
160
  <div class="form-tips">
160
- Map variable names to topic patterns. Wildcards: <code>?</code> = one char, <code>*</code> = one or more chars
161
+ Map variable names to topics.
161
162
  </div>
162
163
  </div>
163
164
  <div class="form-row">
@@ -220,7 +221,7 @@
220
221
  <h3>Properties</h3>
221
222
  <dl class="message-properties">
222
223
  <dt>Input Variables</dt>
223
- <dd>Map variable names to topic patterns. Each variable receives the latest value from matching topics.</dd>
224
+ <dd>Map variable names to topics.</dd>
224
225
  <dt>Expression</dt>
225
226
  <dd>JavaScript expression using the variable names, e.g. <code>a + b</code>, <code>Math.max(a, b)</code>, <code>(a - b) / a * 100</code></dd>
226
227
  <dt>Trigger</dt>
@@ -231,7 +232,7 @@
231
232
  </ul>
232
233
  </dd>
233
234
  <dt>Output Topic</dt>
234
- <dd>Topic to set on output messages</dd>
235
+ <dd>Topic for output messages.</dd>
235
236
  </dl>
236
237
 
237
238
  <h3>Inputs</h3>
@@ -243,19 +244,40 @@
243
244
  </dl>
244
245
 
245
246
  <h3>Outputs</h3>
247
+ <p>The node has two outputs:</p>
248
+ <ol>
249
+ <li><b>Result</b> - Successful calculation results</li>
250
+ <li><b>Error</b> - Errors (NaN results, evaluation failures)</li>
251
+ </ol>
252
+
253
+ <h4>Output 1 (Result)</h4>
246
254
  <dl class="message-properties">
247
255
  <dt>payload <span class="property-type">any</span></dt>
248
256
  <dd>The result of the expression</dd>
249
257
  <dt>topic <span class="property-type">string</span></dt>
250
- <dd>The configured output topic</dd>
258
+ <dd>The output topic</dd>
259
+ <dt>topics <span class="property-type">object</span></dt>
260
+ <dd>Mapping of variable names to topics</dd>
261
+ <dt>timestamps <span class="property-type">object</span></dt>
262
+ <dd>Mapping of variable names to their cached timestamps</dd>
251
263
  <dt>inputs <span class="property-type">object</span></dt>
252
- <dd>Details of all input values used in the calculation</dd>
264
+ <dd>Full details of all input values</dd>
253
265
  <dt>expression <span class="property-type">string</span></dt>
254
266
  <dd>The expression that was evaluated</dd>
255
267
  <dt>trigger <span class="property-type">string</span></dt>
256
268
  <dd>The topic that triggered this calculation</dd>
257
269
  </dl>
258
270
 
271
+ <h4>Output 2 (Error)</h4>
272
+ <dl class="message-properties">
273
+ <dt>payload.error <span class="property-type">string</span></dt>
274
+ <dd>Error message (e.g., "Expression resulted in NaN")</dd>
275
+ <dt>payload.missingInputs <span class="property-type">array</span></dt>
276
+ <dd>List of input variables that were undefined</dd>
277
+ <dt>payload.expression <span class="property-type">string</span></dt>
278
+ <dd>The expression that failed</dd>
279
+ </dl>
280
+
259
281
  <h3>Built-in Functions</h3>
260
282
  <p>The following functions are available in expressions:</p>
261
283
 
@@ -304,10 +326,4 @@
304
326
  <li><code>ifelse(a > b, 'high', 'low')</code> - Conditional</li>
305
327
  <li><code>pctChange(a, b)</code> - Percent change from b to a</li>
306
328
  </ul>
307
-
308
- <h3>Wildcard Examples</h3>
309
- <ul>
310
- <li><code>sensor?</code> - matches sensor1, sensorA (exactly one char)</li>
311
- <li><code>sensors/*</code> - matches sensors/temp, sensors/room1/temp</li>
312
- </ul>
313
329
  </script>