node-red-contrib-event-calc 0.1.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/README.md ADDED
@@ -0,0 +1,247 @@
1
+ # node-red-contrib-event-calc
2
+
3
+ Node-RED nodes for event caching and calculations with topic wildcard patterns.
4
+
5
+ ## Overview
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.
8
+
9
+ ## Architecture
10
+
11
+ ```
12
+ ┌──────────────────────────────────────────────────────────────┐
13
+ │ event-cache (config node) │
14
+ │ • Stores: Map<topic, {value, ts, metadata}> │
15
+ │ • Event emitter for topic updates │
16
+ │ • Wildcard pattern matching │
17
+ └──────────────────────────────────────────────────────────────┘
18
+ │ │ │
19
+ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐
20
+ │event-in │ │event- │ │event- │
21
+ │ │ │topic │ │calc │
22
+ │ pushes │ │subscribes│ │multi-sub│
23
+ │to cache │ │to pattern│ │+ expr │
24
+ └─────────┘ └─────────┘ └─────────┘
25
+ ```
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ npm install node-red-contrib-event-calc
31
+ ```
32
+
33
+ Or install directly from the Node-RED palette manager.
34
+
35
+ ## Nodes
36
+
37
+ ### event-cache (Config Node)
38
+
39
+ Central cache that stores topic values and manages subscriptions. Configure:
40
+
41
+ - **Max Entries**: Maximum topics to cache (default: 10000). Oldest entries removed when exceeded.
42
+ - **TTL**: Time-to-live in milliseconds. Set to 0 for no expiry.
43
+
44
+ ### event-in
45
+
46
+ Receives messages from any upstream node and pushes values to the cache.
47
+
48
+ **Properties:**
49
+ - **Topic Field**: Where to get the topic (default: `msg.topic`)
50
+ - **Value Field**: Where to get the value (default: `msg.payload`)
51
+
52
+ The original message passes through, allowing insertion into existing flows.
53
+
54
+ ### event-topic
55
+
56
+ Subscribes to a topic pattern and outputs when matching topics update.
57
+
58
+ **Properties:**
59
+ - **Topic Pattern**: Pattern with wildcards (`?` for single level, `*` for any levels)
60
+ - **Output Format**:
61
+ - *Value only*: `msg.payload` = value
62
+ - *Full entry*: `msg.payload` = `{value, ts, metadata}`
63
+ - *All matching*: `msg.payload` = `{topic1: value1, topic2: value2, ...}`
64
+ - **Output on deploy**: Emit cached values when flow starts
65
+
66
+ **Dynamic control via input:**
67
+ - `msg.pattern`: Change subscription pattern
68
+ - `msg.payload = 'refresh'`: Output all currently cached values
69
+
70
+ ### event-calc
71
+
72
+ Subscribes to multiple topics and evaluates an expression when values update.
73
+
74
+ **Properties:**
75
+ - **Input Variables**: Map variable names to topic patterns
76
+ - **Expression**: JavaScript expression using the variables
77
+ - **Trigger**: When to calculate
78
+ - *Any input updates*: Calculate on every update
79
+ - *Only when all inputs have values*: Wait for all values
80
+
81
+ **Output:**
82
+ ```json
83
+ {
84
+ "topic": "calc/result",
85
+ "payload": 21.5,
86
+ "inputs": {
87
+ "a": { "topic": "sensors/room1/temp", "value": 22, "ts": 1704000000000 },
88
+ "b": { "topic": "sensors/room2/temp", "value": 21, "ts": 1704000001000 }
89
+ },
90
+ "expression": "(a + b) / 2",
91
+ "trigger": "sensors/room1/temp"
92
+ }
93
+ ```
94
+
95
+ ## Wildcard Patterns
96
+
97
+ Two wildcards are supported:
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 |
105
+
106
+ - `?` matches exactly one character
107
+ - `*` matches one or more characters
108
+
109
+ ## Examples
110
+
111
+ ### Average Temperature
112
+
113
+ ```
114
+ [inject: room1/temp] → [event-in] → [cache]
115
+ [inject: room2/temp] → [event-in] → [cache]
116
+
117
+ [event-calc] → [debug]
118
+ inputs: a = sensors/room1/temp
119
+ b = sensors/room2/temp
120
+ expression: (a + b) / 2
121
+ trigger: all
122
+ ```
123
+
124
+ ### Monitor All Sensors
125
+
126
+ ```
127
+ [any-input: sensors/*] → [event-in] → [cache]
128
+
129
+ [event-topic: sensors/*] → [debug]
130
+ outputFormat: all
131
+ ```
132
+
133
+ ### Calculate Power (Voltage × Current)
134
+
135
+ ```
136
+ [event-calc]
137
+ inputs: v = power/voltage
138
+ i = power/current
139
+ expression: v * i
140
+ topic: power/watts
141
+ ```
142
+
143
+ ## Built-in Functions
144
+
145
+ ### Math
146
+ | Function | Description |
147
+ |----------|-------------|
148
+ | `min(a, b, ...)` | Minimum value |
149
+ | `max(a, b, ...)` | Maximum value |
150
+ | `abs(x)` | Absolute value |
151
+ | `sqrt(x)` | Square root |
152
+ | `pow(base, exp)` | Power |
153
+ | `log(x)`, `log10(x)` | Logarithms |
154
+ | `floor(x)`, `ceil(x)` | Rounding |
155
+ | `sin(x)`, `cos(x)`, `tan(x)` | Trigonometry |
156
+ | `PI`, `E` | Constants |
157
+
158
+ ### Aggregation
159
+ | Function | Description |
160
+ |----------|-------------|
161
+ | `sum(a, b, ...)` | Sum of values |
162
+ | `avg(a, b, ...)` | Average of values |
163
+ | `count(a, b, ...)` | Count of values |
164
+
165
+ ### Utility
166
+ | Function | Description |
167
+ |----------|-------------|
168
+ | `round(value, decimals)` | Round to N decimals |
169
+ | `clamp(value, min, max)` | Constrain to range |
170
+ | `map(value, inMin, inMax, outMin, outMax)` | Scale between ranges |
171
+ | `lerp(a, b, t)` | Linear interpolation |
172
+ | `ifelse(cond, trueVal, falseVal)` | Conditional |
173
+ | `between(value, min, max)` | Range check (returns boolean) |
174
+ | `delta(current, previous)` | Difference |
175
+ | `pctChange(current, previous)` | Percentage change |
176
+
177
+ ## Expression Examples
178
+
179
+ | Expression | Description |
180
+ |------------|-------------|
181
+ | `a + b` | Sum |
182
+ | `avg(a, b)` | Average |
183
+ | `max(a, b, c)` | Maximum |
184
+ | `sqrt(a*a + b*b)` | Pythagorean |
185
+ | `round(a, 2)` | Round to 2 decimals |
186
+ | `clamp(a, 0, 100)` | Constrain 0-100 |
187
+ | `map(a, 0, 1023, 0, 100)` | Scale ADC to % |
188
+ | `ifelse(a > b, 'high', 'low')` | Conditional |
189
+ | `pctChange(a, b)` | % change from b to a |
190
+
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
+ ## API (for custom nodes)
213
+
214
+ The event-cache node exposes methods for programmatic access:
215
+
216
+ ```javascript
217
+ const cache = RED.nodes.getNode(configId);
218
+
219
+ // Set a value
220
+ cache.setValue('topic/path', 42, { source: 'sensor' });
221
+
222
+ // Get a value
223
+ const entry = cache.getValue('topic/path');
224
+ // { value: 42, ts: 1704000000000, metadata: { source: 'sensor' } }
225
+
226
+ // Get matching values
227
+ const temps = cache.getMatching('sensors/*');
228
+ // Map { 'sensors/room1/temp' => {...}, 'sensors/room2/temp' => {...} }
229
+
230
+ // Subscribe to updates
231
+ const subId = cache.subscribe('sensors/*', (topic, entry) => {
232
+ console.log(`${topic} = ${entry.value}`);
233
+ });
234
+
235
+ // Unsubscribe
236
+ cache.unsubscribe(subId);
237
+
238
+ // Get all topics
239
+ const topics = cache.getTopics();
240
+
241
+ // Clear cache
242
+ cache.clear();
243
+ ```
244
+
245
+ ## License
246
+
247
+ MIT
@@ -0,0 +1,184 @@
1
+ [
2
+ {
3
+ "id": "event-calc-example-flow",
4
+ "type": "tab",
5
+ "label": "Event Calc Example",
6
+ "disabled": false,
7
+ "info": "This flow demonstrates the event-calc nodes:\n- event-in: feeds data into the cache\n- event-topic: subscribes to topic patterns\n- event-calc: performs calculations on cached values"
8
+ },
9
+ {
10
+ "id": "cache1",
11
+ "type": "event-cache",
12
+ "name": "Main Cache",
13
+ "maxEntries": "10000",
14
+ "ttl": "0"
15
+ },
16
+ {
17
+ "id": "inject-temp1",
18
+ "type": "inject",
19
+ "z": "event-calc-example-flow",
20
+ "name": "Room 1 Temp",
21
+ "props": [
22
+ { "p": "topic", "vt": "str" },
23
+ { "p": "payload" }
24
+ ],
25
+ "topic": "sensors/room1/temp",
26
+ "payload": "",
27
+ "payloadType": "date",
28
+ "repeat": "",
29
+ "crontab": "",
30
+ "once": false,
31
+ "onceDelay": 0.1,
32
+ "x": 130,
33
+ "y": 100,
34
+ "wires": [["random-temp1"]]
35
+ },
36
+ {
37
+ "id": "random-temp1",
38
+ "type": "function",
39
+ "z": "event-calc-example-flow",
40
+ "name": "Random 20-25",
41
+ "func": "msg.payload = 20 + Math.random() * 5;\nreturn msg;",
42
+ "outputs": 1,
43
+ "x": 300,
44
+ "y": 100,
45
+ "wires": [["event-in1"]]
46
+ },
47
+ {
48
+ "id": "inject-temp2",
49
+ "type": "inject",
50
+ "z": "event-calc-example-flow",
51
+ "name": "Room 2 Temp",
52
+ "props": [
53
+ { "p": "topic", "vt": "str" },
54
+ { "p": "payload" }
55
+ ],
56
+ "topic": "sensors/room2/temp",
57
+ "payload": "",
58
+ "payloadType": "date",
59
+ "repeat": "",
60
+ "crontab": "",
61
+ "once": false,
62
+ "onceDelay": 0.1,
63
+ "x": 130,
64
+ "y": 160,
65
+ "wires": [["random-temp2"]]
66
+ },
67
+ {
68
+ "id": "random-temp2",
69
+ "type": "function",
70
+ "z": "event-calc-example-flow",
71
+ "name": "Random 18-23",
72
+ "func": "msg.payload = 18 + Math.random() * 5;\nreturn msg;",
73
+ "outputs": 1,
74
+ "x": 300,
75
+ "y": 160,
76
+ "wires": [["event-in1"]]
77
+ },
78
+ {
79
+ "id": "event-in1",
80
+ "type": "event-in",
81
+ "z": "event-calc-example-flow",
82
+ "name": "",
83
+ "cache": "cache1",
84
+ "topicField": "topic",
85
+ "valueField": "payload",
86
+ "x": 490,
87
+ "y": 130,
88
+ "wires": [[]]
89
+ },
90
+ {
91
+ "id": "event-topic1",
92
+ "type": "event-topic",
93
+ "z": "event-calc-example-flow",
94
+ "name": "All Temps",
95
+ "cache": "cache1",
96
+ "pattern": "sensors/*/temp",
97
+ "outputFormat": "value",
98
+ "outputOnStart": false,
99
+ "x": 490,
100
+ "y": 240,
101
+ "wires": [["debug-topic"]]
102
+ },
103
+ {
104
+ "id": "debug-topic",
105
+ "type": "debug",
106
+ "z": "event-calc-example-flow",
107
+ "name": "Topic Updates",
108
+ "active": true,
109
+ "tosidebar": true,
110
+ "console": false,
111
+ "tostatus": true,
112
+ "complete": "true",
113
+ "targetType": "full",
114
+ "statusVal": "payload",
115
+ "statusType": "auto",
116
+ "x": 690,
117
+ "y": 240,
118
+ "wires": []
119
+ },
120
+ {
121
+ "id": "event-calc1",
122
+ "type": "event-calc",
123
+ "z": "event-calc-example-flow",
124
+ "name": "Avg Temp",
125
+ "cache": "cache1",
126
+ "inputMappings": [
127
+ { "name": "a", "pattern": "sensors/room1/temp" },
128
+ { "name": "b", "pattern": "sensors/room2/temp" }
129
+ ],
130
+ "expression": "(a + b) / 2",
131
+ "triggerOn": "all",
132
+ "outputTopic": "calc/avg_temp",
133
+ "x": 490,
134
+ "y": 320,
135
+ "wires": [["debug-calc"]]
136
+ },
137
+ {
138
+ "id": "debug-calc",
139
+ "type": "debug",
140
+ "z": "event-calc-example-flow",
141
+ "name": "Calculation",
142
+ "active": true,
143
+ "tosidebar": true,
144
+ "console": false,
145
+ "tostatus": true,
146
+ "complete": "true",
147
+ "targetType": "full",
148
+ "statusVal": "payload",
149
+ "statusType": "auto",
150
+ "x": 690,
151
+ "y": 320,
152
+ "wires": []
153
+ },
154
+ {
155
+ "id": "comment1",
156
+ "type": "comment",
157
+ "z": "event-calc-example-flow",
158
+ "name": "1. Click inject buttons to simulate sensor readings",
159
+ "info": "",
160
+ "x": 220,
161
+ "y": 40,
162
+ "wires": []
163
+ },
164
+ {
165
+ "id": "comment2",
166
+ "type": "comment",
167
+ "z": "event-calc-example-flow",
168
+ "name": "2. event-topic outputs when any temp updates",
169
+ "info": "",
170
+ "x": 590,
171
+ "y": 200,
172
+ "wires": []
173
+ },
174
+ {
175
+ "id": "comment3",
176
+ "type": "comment",
177
+ "z": "event-calc-example-flow",
178
+ "name": "3. event-calc computes average (waits for both values)",
179
+ "info": "",
180
+ "x": 620,
181
+ "y": 280,
182
+ "wires": []
183
+ }
184
+ ]
@@ -0,0 +1,30 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('event-cache', {
3
+ category: 'config',
4
+ defaults: {
5
+ name: { value: "" },
6
+ maxEntries: { value: 10000, validate: RED.validators.number() },
7
+ ttl: { value: 0, validate: RED.validators.number() }
8
+ },
9
+ label: function() {
10
+ return this.name || "Event Cache";
11
+ }
12
+ });
13
+ </script>
14
+
15
+ <script type="text/html" data-template-name="event-cache">
16
+ <div class="form-row">
17
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
18
+ <input type="text" id="node-config-input-name" placeholder="Event Cache">
19
+ </div>
20
+ <div class="form-row">
21
+ <label for="node-config-input-maxEntries"><i class="fa fa-database"></i> Max Entries</label>
22
+ <input type="number" id="node-config-input-maxEntries" placeholder="10000">
23
+ <div class="form-tips">Maximum number of topics to cache. Oldest entries are removed when limit is reached.</div>
24
+ </div>
25
+ <div class="form-row">
26
+ <label for="node-config-input-ttl"><i class="fa fa-clock-o"></i> TTL (ms)</label>
27
+ <input type="number" id="node-config-input-ttl" placeholder="0">
28
+ <div class="form-tips">Time-to-live in milliseconds. Set to 0 for no expiry.</div>
29
+ </div>
30
+ </script>