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

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 ADDED
@@ -0,0 +1,25 @@
1
+ Personal Use License
2
+
3
+ Copyright (c) 2025 Holger Amort
4
+
5
+ This software is provided for personal, non-commercial use only.
6
+
7
+ You may:
8
+ - Use this software for personal projects
9
+ - Modify the software for your own use
10
+ - Share the software with attribution
11
+
12
+ You may not:
13
+ - Use this software for commercial purposes without permission
14
+ - Sell or sublicense this software
15
+ - Remove this license notice
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+ For commercial licensing inquiries, contact the author.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # node-red-contrib-event-calc
2
2
 
3
- Node-RED nodes for event caching and calculations with topic wildcard patterns.
3
+ Node-RED nodes for event caching and streaming calculations with a local pub/sub event hub.
4
4
 
5
5
  ## Overview
6
6
 
7
- This package provides a central cache for event/streaming data values with reactive updates. It enables subscription to topic patterns and calculations when values change, making it easy to build event-driven data processing flows.
7
+ This package provides a local in-memory event hub with topic-based publish/subscribe and latest-value caching for reactive data flows within Node-RED. Stream data from MQTT, OPC-UA, or any source, then perform calculations that trigger automatically when values update.
8
8
 
9
9
  ## Architecture
10
10
 
@@ -13,14 +13,14 @@ This package provides a central cache for event/streaming data values with react
13
13
  │ event-cache (config node) │
14
14
  │ • Stores: Map<topic, {value, ts, metadata}> │
15
15
  │ • Event emitter for topic updates │
16
- │ • Wildcard pattern matching
16
+ │ • LRU eviction, optional TTL
17
17
  └──────────────────────────────────────────────────────────────┘
18
18
  │ │ │
19
19
  ┌────▼────┐ ┌────▼────┐ ┌────▼────┐
20
20
  │event-in │ │event- │ │event- │
21
21
  │ │ │topic │ │calc │
22
22
  │ pushes │ │subscribes│ │multi-sub│
23
- │to cache │ │to pattern│ │+ expr │
23
+ │to cache │ │to topic │ │+ expr │
24
24
  └─────────┘ └─────────┘ └─────────┘
25
25
  ```
26
26
 
@@ -53,30 +53,30 @@ The original message passes through, allowing insertion into existing flows.
53
53
 
54
54
  ### event-topic
55
55
 
56
- Subscribes to a topic pattern and outputs when matching topics update.
56
+ Subscribes to a topic and outputs when that topic updates.
57
57
 
58
58
  **Properties:**
59
- - **Topic Pattern**: Pattern with wildcards (`?` for single level, `*` for any levels)
59
+ - **Topic**: Exact topic to subscribe to
60
60
  - **Output Format**:
61
61
  - *Value only*: `msg.payload` = value
62
62
  - *Full entry*: `msg.payload` = `{value, ts, metadata}`
63
- - *All matching*: `msg.payload` = `{topic1: value1, topic2: value2, ...}`
64
63
  - **Output on deploy**: Emit cached values when flow starts
65
64
 
66
65
  **Dynamic control via input:**
67
- - `msg.pattern`: Change subscription pattern
68
- - `msg.payload = 'refresh'`: Output all currently cached values
66
+ - `msg.topic`: Change subscription topic
67
+ - `msg.payload = 'refresh'`: Output current cached value
69
68
 
70
69
  ### event-calc
71
70
 
72
71
  Subscribes to multiple topics and evaluates an expression when values update.
73
72
 
74
73
  **Properties:**
75
- - **Input Variables**: Map variable names to topic patterns
74
+ - **Input Variables**: Map variable names to topics
76
75
  - **Expression**: JavaScript expression using the variables
77
76
  - **Trigger**: When to calculate
78
77
  - *Any input updates*: Calculate on every update
79
78
  - *Only when all inputs have values*: Wait for all values
79
+ - **External Trigger**: When enabled, any incoming message triggers calculation using cached values
80
80
 
81
81
  **Output:**
82
82
  ```json
@@ -92,19 +92,27 @@ Subscribes to multiple topics and evaluates an expression when values update.
92
92
  }
93
93
  ```
94
94
 
95
- ## Wildcard Patterns
95
+ ### event-json
96
96
 
97
- Two wildcards are supported:
97
+ Bidirectional JSON envelope converter for MQTT messaging.
98
98
 
99
- | Pattern | Matches | Doesn't Match |
100
- |---------|---------|---------------|
101
- | `sensor?` | `sensor1`, `sensorA` | `sensor`, `sensor12` |
102
- | `sensors/*` | `sensors/temp`, `sensors/room1/temp` | `sensors` (nothing after /) |
103
- | `*/temp` | `room/temp`, `sensors/temp` | `temp` (nothing before /) |
104
- | `*` | Any topic with 1+ chars | Empty string |
99
+ **Behavior:**
100
+ - **Unwrap**: If payload is `{value, topic?, timestamp?}`, extracts to msg properties
101
+ - **Wrap**: If payload is any other value, wraps as `{timestamp, topic, value}`
105
102
 
106
- - `?` matches exactly one character
107
- - `*` matches one or more characters
103
+ **Usage:**
104
+ ```
105
+ [MQTT in] → [event-json] → [event-in] (unwrap JSON from broker)
106
+ [event-topic] → [event-json] → [MQTT out] (wrap for broker)
107
+ ```
108
+
109
+ ### event-simulator
110
+
111
+ Generates simulated data for testing. Supports sine waves, random values, and ramps.
112
+
113
+ ### event-chart
114
+
115
+ Real-time charting node for visualizing cached event data.
108
116
 
109
117
  ## Examples
110
118
 
@@ -121,13 +129,21 @@ Two wildcards are supported:
121
129
  trigger: all
122
130
  ```
123
131
 
124
- ### Monitor All Sensors
132
+ ### Time-based Calculations (External Trigger)
125
133
 
126
134
  ```
127
- [any-input: sensors/*] → [event-in] → [cache]
135
+ [inject: every 1 min] → [event-calc (external trigger)] → [MQTT out]
136
+ inputs: a = sensors/power
137
+ b = sensors/voltage
138
+ expression: a * b
139
+ ```
140
+
141
+ ### MQTT Round-trip with JSON Envelope
128
142
 
129
- [event-topic: sensors/*] → [debug]
130
- outputFormat: all
143
+ ```
144
+ [MQTT in] → [event-json] → [event-in] → [cache]
145
+
146
+ [event-calc] → [event-json] → [MQTT out]
131
147
  ```
132
148
 
133
149
  ### Calculate Power (Voltage × Current)
@@ -188,27 +204,6 @@ Two wildcards are supported:
188
204
  | `ifelse(a > b, 'high', 'low')` | Conditional |
189
205
  | `pctChange(a, b)` | % change from b to a |
190
206
 
191
- ## Scalability
192
-
193
- The event-cache is optimized for high subscriber counts:
194
-
195
- | Subscription Type | Lookup Complexity | Best For |
196
- |-------------------|-------------------|----------|
197
- | Exact topic (e.g., `sensors/room1/temp`) | O(1) | High-frequency updates, many subscribers |
198
- | Wildcard pattern (e.g., `sensors/*`) | O(w) | Flexible matching, fewer patterns |
199
-
200
- Where `w` = number of wildcard subscriptions (typically much smaller than total subscribers).
201
-
202
- **Example performance:**
203
- - 1000 exact subscriptions to different topics: O(1) per update
204
- - 10 wildcard patterns + 1000 exact subscriptions: O(10) per update
205
- - Pure wildcard subscriptions: O(n) per update
206
-
207
- **Recommendations for high scale:**
208
- - Prefer exact topic matches when possible
209
- - Use wildcards sparingly for monitoring/logging
210
- - Check stats endpoint: `GET /event-cache/:id/stats`
211
-
212
207
  ## API (for custom nodes)
213
208
 
214
209
  The event-cache node exposes methods for programmatic access:
@@ -223,12 +218,8 @@ cache.setValue('topic/path', 42, { source: 'sensor' });
223
218
  const entry = cache.getValue('topic/path');
224
219
  // { value: 42, ts: 1704000000000, metadata: { source: 'sensor' } }
225
220
 
226
- // Get matching values
227
- const temps = cache.getMatching('sensors/*');
228
- // Map { 'sensors/room1/temp' => {...}, 'sensors/room2/temp' => {...} }
229
-
230
221
  // Subscribe to updates
231
- const subId = cache.subscribe('sensors/*', (topic, entry) => {
222
+ const subId = cache.subscribe('sensors/room1/temp', (topic, entry) => {
232
223
  console.log(`${topic} = ${entry.value}`);
233
224
  });
234
225
 
@@ -242,6 +233,14 @@ const topics = cache.getTopics();
242
233
  cache.clear();
243
234
  ```
244
235
 
236
+ ## HTTP Admin Endpoints
237
+
238
+ ```
239
+ GET /event-cache/:id/stats - Cache statistics
240
+ GET /event-cache/:id/topics - List all topics
241
+ POST /event-cache/:id/clear - Clear cache
242
+ ```
243
+
245
244
  ## License
246
245
 
247
- MIT
246
+ Personal Use License - See [LICENSE](LICENSE) file.
@@ -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
  };