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 +247 -0
- package/examples/event-calc-example.json +184 -0
- package/nodes/event-cache.html +30 -0
- package/nodes/event-cache.js +327 -0
- package/nodes/event-calc.html +307 -0
- package/nodes/event-calc.js +217 -0
- package/nodes/event-in.html +66 -0
- package/nodes/event-in.js +80 -0
- package/nodes/event-topic.html +130 -0
- package/nodes/event-topic.js +140 -0
- package/package.json +42 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-calc - Calculation node for multi-topic expressions
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Maps multiple variables to topic patterns
|
|
6
|
+
* - Evaluates JavaScript expressions when inputs update
|
|
7
|
+
* - Trigger modes: 'any' (any input updates) or 'all' (all inputs have values)
|
|
8
|
+
* - Safe expression evaluation using Function constructor
|
|
9
|
+
* - Dynamic expression update via input message
|
|
10
|
+
* - Built-in helper functions for common operations
|
|
11
|
+
*/
|
|
12
|
+
module.exports = function(RED) {
|
|
13
|
+
// Helper functions available in expressions
|
|
14
|
+
const helpers = {
|
|
15
|
+
// Math shortcuts
|
|
16
|
+
min: (...args) => Math.min(...args.flat()),
|
|
17
|
+
max: (...args) => Math.max(...args.flat()),
|
|
18
|
+
abs: (x) => Math.abs(x),
|
|
19
|
+
sqrt: (x) => Math.sqrt(x),
|
|
20
|
+
pow: (base, exp) => Math.pow(base, exp),
|
|
21
|
+
log: (x) => Math.log(x),
|
|
22
|
+
log10: (x) => Math.log10(x),
|
|
23
|
+
exp: (x) => Math.exp(x),
|
|
24
|
+
floor: (x) => Math.floor(x),
|
|
25
|
+
ceil: (x) => Math.ceil(x),
|
|
26
|
+
sin: (x) => Math.sin(x),
|
|
27
|
+
cos: (x) => Math.cos(x),
|
|
28
|
+
tan: (x) => Math.tan(x),
|
|
29
|
+
PI: Math.PI,
|
|
30
|
+
E: Math.E,
|
|
31
|
+
|
|
32
|
+
// Aggregation
|
|
33
|
+
sum: (...args) => args.flat().reduce((a, b) => a + b, 0),
|
|
34
|
+
avg: (...args) => {
|
|
35
|
+
const flat = args.flat();
|
|
36
|
+
return flat.length > 0 ? flat.reduce((a, b) => a + b, 0) / flat.length : 0;
|
|
37
|
+
},
|
|
38
|
+
count: (...args) => args.flat().length,
|
|
39
|
+
|
|
40
|
+
// Utility
|
|
41
|
+
round: (value, decimals = 0) => {
|
|
42
|
+
const factor = Math.pow(10, decimals);
|
|
43
|
+
return Math.round(value * factor) / factor;
|
|
44
|
+
},
|
|
45
|
+
clamp: (value, min, max) => Math.min(Math.max(value, min), max),
|
|
46
|
+
map: (value, inMin, inMax, outMin, outMax) => {
|
|
47
|
+
return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
|
|
48
|
+
},
|
|
49
|
+
lerp: (a, b, t) => a + (b - a) * t,
|
|
50
|
+
|
|
51
|
+
// Boolean/conditional helpers
|
|
52
|
+
ifelse: (condition, trueVal, falseVal) => condition ? trueVal : falseVal,
|
|
53
|
+
between: (value, min, max) => value >= min && value <= max,
|
|
54
|
+
|
|
55
|
+
// Delta/change detection (returns difference)
|
|
56
|
+
delta: (current, previous) => current - previous,
|
|
57
|
+
pctChange: (current, previous) => previous !== 0 ? ((current - previous) / previous) * 100 : 0
|
|
58
|
+
};
|
|
59
|
+
function EventCalcNode(config) {
|
|
60
|
+
RED.nodes.createNode(this, config);
|
|
61
|
+
const node = this;
|
|
62
|
+
|
|
63
|
+
node.cacheConfig = RED.nodes.getNode(config.cache);
|
|
64
|
+
node.inputMappings = config.inputMappings || [];
|
|
65
|
+
node.expression = config.expression || '';
|
|
66
|
+
node.triggerOn = config.triggerOn || 'any';
|
|
67
|
+
node.outputTopic = config.outputTopic || 'calc/result';
|
|
68
|
+
|
|
69
|
+
const subscriptionIds = [];
|
|
70
|
+
const latestValues = new Map(); // name -> { topic, value, ts }
|
|
71
|
+
|
|
72
|
+
if (!node.cacheConfig) {
|
|
73
|
+
node.status({ fill: "red", shape: "ring", text: "no cache configured" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (node.inputMappings.length === 0) {
|
|
78
|
+
node.status({ fill: "yellow", shape: "ring", text: "no inputs defined" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!node.expression) {
|
|
83
|
+
node.status({ fill: "yellow", shape: "ring", text: "no expression" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Attempt to calculate and output result
|
|
89
|
+
*/
|
|
90
|
+
function tryCalculate(triggerTopic) {
|
|
91
|
+
// Check if we should trigger
|
|
92
|
+
if (node.triggerOn === 'all') {
|
|
93
|
+
// All inputs must have values
|
|
94
|
+
for (const input of node.inputMappings) {
|
|
95
|
+
if (!latestValues.has(input.name)) {
|
|
96
|
+
return; // Not all values available yet
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// At least one value must exist to calculate
|
|
102
|
+
if (latestValues.size === 0) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Build context object for expression evaluation
|
|
107
|
+
const context = {};
|
|
108
|
+
const inputDetails = {};
|
|
109
|
+
|
|
110
|
+
for (const input of node.inputMappings) {
|
|
111
|
+
const data = latestValues.get(input.name);
|
|
112
|
+
if (data) {
|
|
113
|
+
context[input.name] = data.value;
|
|
114
|
+
inputDetails[input.name] = {
|
|
115
|
+
topic: data.topic,
|
|
116
|
+
value: data.value,
|
|
117
|
+
ts: data.ts
|
|
118
|
+
};
|
|
119
|
+
} else {
|
|
120
|
+
context[input.name] = undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Evaluate expression safely
|
|
125
|
+
try {
|
|
126
|
+
// Create a function with named parameters from context + helpers
|
|
127
|
+
const allParams = { ...helpers, ...context };
|
|
128
|
+
const paramNames = Object.keys(allParams);
|
|
129
|
+
const paramValues = Object.values(allParams);
|
|
130
|
+
|
|
131
|
+
// Build function body with helpers and variables available
|
|
132
|
+
const fn = new Function(...paramNames, `return ${node.expression};`);
|
|
133
|
+
const result = fn(...paramValues);
|
|
134
|
+
|
|
135
|
+
const msg = {
|
|
136
|
+
topic: node.outputTopic,
|
|
137
|
+
payload: result,
|
|
138
|
+
inputs: inputDetails,
|
|
139
|
+
expression: node.expression,
|
|
140
|
+
trigger: triggerTopic,
|
|
141
|
+
timestamp: Date.now()
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
node.send(msg);
|
|
145
|
+
|
|
146
|
+
// Store result back in cache so it can be used by other calculations
|
|
147
|
+
node.cacheConfig.setValue(node.outputTopic, result, {
|
|
148
|
+
source: 'event-calc',
|
|
149
|
+
expression: node.expression,
|
|
150
|
+
inputs: Object.keys(inputDetails)
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Update status with result (truncate if too long)
|
|
154
|
+
const resultStr = String(result);
|
|
155
|
+
const displayResult = resultStr.length > 15 ? resultStr.substring(0, 12) + '...' : resultStr;
|
|
156
|
+
node.status({ fill: "green", shape: "dot", text: `= ${displayResult}` });
|
|
157
|
+
|
|
158
|
+
} catch (err) {
|
|
159
|
+
node.status({ fill: "red", shape: "ring", text: "eval error" });
|
|
160
|
+
node.error(`Expression evaluation failed: ${err.message}`, { expression: node.expression, context: context });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Subscribe to each input pattern
|
|
165
|
+
for (const input of node.inputMappings) {
|
|
166
|
+
if (!input.name || !input.pattern) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const subId = node.cacheConfig.subscribe(input.pattern, (topic, entry) => {
|
|
171
|
+
latestValues.set(input.name, {
|
|
172
|
+
topic: topic,
|
|
173
|
+
value: entry.value,
|
|
174
|
+
ts: entry.ts
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
tryCalculate(topic);
|
|
178
|
+
});
|
|
179
|
+
subscriptionIds.push(subId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
node.status({ fill: "green", shape: "dot", text: "ready" });
|
|
183
|
+
|
|
184
|
+
// Handle input messages for dynamic updates
|
|
185
|
+
node.on('input', function(msg, send, done) {
|
|
186
|
+
// For Node-RED 0.x compatibility
|
|
187
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
188
|
+
done = done || function(err) { if (err) node.error(err, msg); };
|
|
189
|
+
|
|
190
|
+
// Allow expression update via message
|
|
191
|
+
if (msg.expression && typeof msg.expression === 'string') {
|
|
192
|
+
node.expression = msg.expression;
|
|
193
|
+
node.status({ fill: "blue", shape: "dot", text: "expr updated" });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Force recalculation
|
|
197
|
+
if (msg.payload === 'recalc' || msg.topic === 'recalc') {
|
|
198
|
+
tryCalculate('manual');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
done();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
node.on('close', function(done) {
|
|
205
|
+
for (const subId of subscriptionIds) {
|
|
206
|
+
if (node.cacheConfig) {
|
|
207
|
+
node.cacheConfig.unsubscribe(subId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
subscriptionIds.length = 0;
|
|
211
|
+
latestValues.clear();
|
|
212
|
+
done();
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
RED.nodes.registerType("event-calc", EventCalcNode);
|
|
217
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('event-in', {
|
|
3
|
+
category: 'event calc',
|
|
4
|
+
color: '#87CEEB',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" },
|
|
7
|
+
cache: { value: "", type: "event-cache", required: true },
|
|
8
|
+
topicField: { value: "topic" },
|
|
9
|
+
valueField: { value: "payload" }
|
|
10
|
+
},
|
|
11
|
+
inputs: 1,
|
|
12
|
+
outputs: 1,
|
|
13
|
+
icon: "font-awesome/fa-arrow-circle-right",
|
|
14
|
+
label: function() {
|
|
15
|
+
return this.name || "event in";
|
|
16
|
+
},
|
|
17
|
+
paletteLabel: "event in"
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<script type="text/html" data-template-name="event-in">
|
|
22
|
+
<div class="form-row">
|
|
23
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
24
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
25
|
+
</div>
|
|
26
|
+
<div class="form-row">
|
|
27
|
+
<label for="node-input-cache"><i class="fa fa-database"></i> Cache</label>
|
|
28
|
+
<input type="text" id="node-input-cache">
|
|
29
|
+
</div>
|
|
30
|
+
<div class="form-row">
|
|
31
|
+
<label for="node-input-topicField"><i class="fa fa-bookmark"></i> Topic Field</label>
|
|
32
|
+
<input type="text" id="node-input-topicField" placeholder="topic">
|
|
33
|
+
<div class="form-tips">Message property containing the topic (e.g., <code>topic</code> for <code>msg.topic</code>)</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="form-row">
|
|
36
|
+
<label for="node-input-valueField"><i class="fa fa-cube"></i> Value Field</label>
|
|
37
|
+
<input type="text" id="node-input-valueField" placeholder="payload">
|
|
38
|
+
<div class="form-tips">Message property containing the value (e.g., <code>payload</code> for <code>msg.payload</code>)</div>
|
|
39
|
+
</div>
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<script type="text/html" data-help-name="event-in">
|
|
43
|
+
<p>Receives messages and pushes values to the event cache.</p>
|
|
44
|
+
|
|
45
|
+
<h3>Properties</h3>
|
|
46
|
+
<dl class="message-properties">
|
|
47
|
+
<dt>Cache</dt>
|
|
48
|
+
<dd>The event-cache config node to use</dd>
|
|
49
|
+
<dt>Topic Field</dt>
|
|
50
|
+
<dd>Message property containing the topic (default: <code>msg.topic</code>)</dd>
|
|
51
|
+
<dt>Value Field</dt>
|
|
52
|
+
<dd>Message property containing the value (default: <code>msg.payload</code>)</dd>
|
|
53
|
+
</dl>
|
|
54
|
+
|
|
55
|
+
<h3>Inputs</h3>
|
|
56
|
+
<p>Any message with a topic and value. The topic is used as the cache key.</p>
|
|
57
|
+
|
|
58
|
+
<h3>Outputs</h3>
|
|
59
|
+
<p>The original message is passed through unchanged, allowing this node to be inserted into existing flows.</p>
|
|
60
|
+
|
|
61
|
+
<h3>Details</h3>
|
|
62
|
+
<p>This node extracts a topic and value from incoming messages and stores them in the configured event-cache.
|
|
63
|
+
Other nodes (event-topic, event-calc) can then subscribe to these cached values.</p>
|
|
64
|
+
|
|
65
|
+
<p>Use this node to feed data from any source into the event cache system.</p>
|
|
66
|
+
</script>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-in - Input node that pushes data to the event cache
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Receives messages from any upstream Node-RED node
|
|
6
|
+
* - Configurable topic and value extraction from message
|
|
7
|
+
* - Pass-through: forwards original message after caching
|
|
8
|
+
*/
|
|
9
|
+
module.exports = function(RED) {
|
|
10
|
+
function EventInNode(config) {
|
|
11
|
+
RED.nodes.createNode(this, config);
|
|
12
|
+
const node = this;
|
|
13
|
+
|
|
14
|
+
node.cacheConfig = RED.nodes.getNode(config.cache);
|
|
15
|
+
node.topicField = config.topicField || 'topic';
|
|
16
|
+
node.valueField = config.valueField || 'payload';
|
|
17
|
+
|
|
18
|
+
if (!node.cacheConfig) {
|
|
19
|
+
node.status({ fill: "red", shape: "ring", text: "no cache configured" });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
node.status({ fill: "green", shape: "dot", text: "ready" });
|
|
24
|
+
|
|
25
|
+
node.on('input', function(msg, send, done) {
|
|
26
|
+
// For Node-RED 0.x compatibility
|
|
27
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
28
|
+
done = done || function(err) { if (err) node.error(err, msg); };
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Extract topic using RED.util.getMessageProperty
|
|
32
|
+
let topic;
|
|
33
|
+
if (node.topicField.startsWith('msg.')) {
|
|
34
|
+
topic = RED.util.getMessageProperty(msg, node.topicField.substring(4));
|
|
35
|
+
} else {
|
|
36
|
+
topic = RED.util.getMessageProperty(msg, node.topicField);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!topic || typeof topic !== 'string') {
|
|
40
|
+
node.status({ fill: "yellow", shape: "ring", text: "missing topic" });
|
|
41
|
+
done(new Error(`Topic not found at msg.${node.topicField} or not a string`));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Extract value
|
|
46
|
+
let value;
|
|
47
|
+
if (node.valueField.startsWith('msg.')) {
|
|
48
|
+
value = RED.util.getMessageProperty(msg, node.valueField.substring(4));
|
|
49
|
+
} else {
|
|
50
|
+
value = RED.util.getMessageProperty(msg, node.valueField);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Build metadata from msg properties
|
|
54
|
+
const metadata = {
|
|
55
|
+
_msgid: msg._msgid
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Push to cache
|
|
59
|
+
node.cacheConfig.setValue(topic, value, metadata);
|
|
60
|
+
|
|
61
|
+
// Truncate topic for status display
|
|
62
|
+
const displayTopic = topic.length > 20 ? topic.substring(0, 17) + '...' : topic;
|
|
63
|
+
node.status({ fill: "green", shape: "dot", text: displayTopic });
|
|
64
|
+
|
|
65
|
+
// Pass through the message
|
|
66
|
+
send(msg);
|
|
67
|
+
done();
|
|
68
|
+
} catch (err) {
|
|
69
|
+
node.status({ fill: "red", shape: "ring", text: "error" });
|
|
70
|
+
done(err);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
node.on('close', function(done) {
|
|
75
|
+
done();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
RED.nodes.registerType("event-in", EventInNode);
|
|
80
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('event-topic', {
|
|
3
|
+
category: 'event calc',
|
|
4
|
+
color: '#87CEEB',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" },
|
|
7
|
+
cache: { value: "", type: "event-cache", required: true },
|
|
8
|
+
pattern: { value: "*" },
|
|
9
|
+
outputFormat: { value: "value" },
|
|
10
|
+
outputOnStart: { value: false }
|
|
11
|
+
},
|
|
12
|
+
inputs: 1,
|
|
13
|
+
outputs: 1,
|
|
14
|
+
icon: "font-awesome/fa-filter",
|
|
15
|
+
label: function() {
|
|
16
|
+
return this.name || this.pattern || "event topic";
|
|
17
|
+
},
|
|
18
|
+
paletteLabel: "event topic",
|
|
19
|
+
oneditprepare: function() {
|
|
20
|
+
const node = this;
|
|
21
|
+
|
|
22
|
+
// Fetch topics from cache for autocomplete
|
|
23
|
+
function fetchTopics() {
|
|
24
|
+
const cacheId = $("#node-input-cache").val();
|
|
25
|
+
if (cacheId) {
|
|
26
|
+
$.getJSON("event-cache/" + cacheId + "/topics", function(topics) {
|
|
27
|
+
$("#node-input-pattern").autocomplete("option", "source", topics || []);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Setup autocomplete on pattern input
|
|
33
|
+
$("#node-input-pattern").autocomplete({
|
|
34
|
+
source: [],
|
|
35
|
+
minLength: 0,
|
|
36
|
+
delay: 0
|
|
37
|
+
}).on("focus", function() {
|
|
38
|
+
if (!$(this).val() || $(this).val() === "*") {
|
|
39
|
+
$(this).autocomplete("search", "");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Refresh topics when cache selection changes
|
|
44
|
+
$("#node-input-cache").on("change", fetchTopics);
|
|
45
|
+
|
|
46
|
+
// Initial fetch
|
|
47
|
+
setTimeout(fetchTopics, 100);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<script type="text/html" data-template-name="event-topic">
|
|
53
|
+
<div class="form-row">
|
|
54
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
55
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
56
|
+
</div>
|
|
57
|
+
<div class="form-row">
|
|
58
|
+
<label for="node-input-cache"><i class="fa fa-database"></i> Cache</label>
|
|
59
|
+
<input type="text" id="node-input-cache">
|
|
60
|
+
</div>
|
|
61
|
+
<div class="form-row">
|
|
62
|
+
<label for="node-input-pattern"><i class="fa fa-bookmark"></i> Topic Pattern</label>
|
|
63
|
+
<input type="text" id="node-input-pattern" placeholder="*">
|
|
64
|
+
<div class="form-tips">
|
|
65
|
+
Wildcards: <code>?</code> = exactly one character, <code>*</code> = one or more characters.<br>
|
|
66
|
+
Examples: <code>sensor?</code> matches sensor1, <code>sensors/*</code> matches sensors/temp
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="form-row">
|
|
70
|
+
<label for="node-input-outputFormat"><i class="fa fa-sign-out"></i> Output Format</label>
|
|
71
|
+
<select id="node-input-outputFormat" style="width:70%;">
|
|
72
|
+
<option value="value">Value only (msg.payload = value)</option>
|
|
73
|
+
<option value="full">Full entry (msg.payload = {value, ts, metadata})</option>
|
|
74
|
+
<option value="all">All matching (msg.payload = {topic: value, ...})</option>
|
|
75
|
+
</select>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="form-row">
|
|
78
|
+
<label> </label>
|
|
79
|
+
<input type="checkbox" id="node-input-outputOnStart" style="display:inline-block; width:auto; vertical-align:top;">
|
|
80
|
+
<label for="node-input-outputOnStart" style="width:auto;">Output existing cached values on deploy</label>
|
|
81
|
+
</div>
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<script type="text/html" data-help-name="event-topic">
|
|
85
|
+
<p>Subscribes to a topic pattern and outputs when matching topics update in the cache.</p>
|
|
86
|
+
|
|
87
|
+
<h3>Properties</h3>
|
|
88
|
+
<dl class="message-properties">
|
|
89
|
+
<dt>Cache</dt>
|
|
90
|
+
<dd>The event-cache config node to use</dd>
|
|
91
|
+
<dt>Topic Pattern</dt>
|
|
92
|
+
<dd>Pattern with wildcards. <code>?</code> = exactly one character, <code>*</code> = one or more characters.</dd>
|
|
93
|
+
<dt>Output Format</dt>
|
|
94
|
+
<dd>
|
|
95
|
+
<ul>
|
|
96
|
+
<li><b>Value only</b>: <code>msg.payload</code> contains just the value</li>
|
|
97
|
+
<li><b>Full entry</b>: <code>msg.payload</code> contains <code>{value, ts, metadata}</code></li>
|
|
98
|
+
<li><b>All matching</b>: <code>msg.payload</code> contains all cached values matching the pattern</li>
|
|
99
|
+
</ul>
|
|
100
|
+
</dd>
|
|
101
|
+
<dt>Output on deploy</dt>
|
|
102
|
+
<dd>If checked, outputs all currently cached values matching the pattern when the flow starts</dd>
|
|
103
|
+
</dl>
|
|
104
|
+
|
|
105
|
+
<h3>Inputs</h3>
|
|
106
|
+
<dl class="message-properties">
|
|
107
|
+
<dt class="optional">pattern <span class="property-type">string</span></dt>
|
|
108
|
+
<dd>Dynamically change the subscription pattern</dd>
|
|
109
|
+
<dt class="optional">payload/topic = "refresh"</dt>
|
|
110
|
+
<dd>Output all currently cached values matching the pattern</dd>
|
|
111
|
+
</dl>
|
|
112
|
+
|
|
113
|
+
<h3>Outputs</h3>
|
|
114
|
+
<dl class="message-properties">
|
|
115
|
+
<dt>topic <span class="property-type">string</span></dt>
|
|
116
|
+
<dd>The topic that was updated</dd>
|
|
117
|
+
<dt>payload <span class="property-type">any</span></dt>
|
|
118
|
+
<dd>The value (format depends on Output Format setting)</dd>
|
|
119
|
+
<dt>timestamp <span class="property-type">number</span></dt>
|
|
120
|
+
<dd>Unix timestamp when the value was cached</dd>
|
|
121
|
+
</dl>
|
|
122
|
+
|
|
123
|
+
<h3>Wildcard Examples</h3>
|
|
124
|
+
<ul>
|
|
125
|
+
<li><code>sensor?</code> - matches sensor1, sensorA (exactly one char)</li>
|
|
126
|
+
<li><code>sensors/*</code> - matches sensors/temp, sensors/room1 (one or more chars after /)</li>
|
|
127
|
+
<li><code>*/temp</code> - matches room1/temp, sensors/temp</li>
|
|
128
|
+
<li><code>*</code> - matches any topic with one or more characters</li>
|
|
129
|
+
</ul>
|
|
130
|
+
</script>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-topic - Subscription node for topic patterns
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Subscribes to cache using MQTT-style topic patterns
|
|
6
|
+
* - Outputs when matching topics update
|
|
7
|
+
* - Multiple output formats: value only, full entry, or all matching
|
|
8
|
+
* - Optional output of existing values on start
|
|
9
|
+
* - Dynamic pattern change via input message
|
|
10
|
+
*/
|
|
11
|
+
module.exports = function(RED) {
|
|
12
|
+
function EventTopicNode(config) {
|
|
13
|
+
RED.nodes.createNode(this, config);
|
|
14
|
+
const node = this;
|
|
15
|
+
|
|
16
|
+
node.cacheConfig = RED.nodes.getNode(config.cache);
|
|
17
|
+
node.pattern = config.pattern || '#';
|
|
18
|
+
node.outputFormat = config.outputFormat || 'value';
|
|
19
|
+
node.outputOnStart = config.outputOnStart || false;
|
|
20
|
+
|
|
21
|
+
let subscriptionId = null;
|
|
22
|
+
|
|
23
|
+
if (!node.cacheConfig) {
|
|
24
|
+
node.status({ fill: "red", shape: "ring", text: "no cache configured" });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build output message based on configured format
|
|
30
|
+
*/
|
|
31
|
+
function buildOutputMessage(topic, entry) {
|
|
32
|
+
switch (node.outputFormat) {
|
|
33
|
+
case 'value':
|
|
34
|
+
return {
|
|
35
|
+
topic: topic,
|
|
36
|
+
payload: entry.value,
|
|
37
|
+
timestamp: entry.ts
|
|
38
|
+
};
|
|
39
|
+
case 'full':
|
|
40
|
+
return {
|
|
41
|
+
topic: topic,
|
|
42
|
+
payload: {
|
|
43
|
+
value: entry.value,
|
|
44
|
+
ts: entry.ts,
|
|
45
|
+
metadata: entry.metadata
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
case 'all':
|
|
49
|
+
const all = node.cacheConfig.getMatching(node.pattern);
|
|
50
|
+
const values = {};
|
|
51
|
+
for (const [t, e] of all) {
|
|
52
|
+
values[t] = e.value;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
topic: topic,
|
|
56
|
+
payload: values,
|
|
57
|
+
trigger: {
|
|
58
|
+
topic: topic,
|
|
59
|
+
value: entry.value
|
|
60
|
+
},
|
|
61
|
+
timestamp: entry.ts
|
|
62
|
+
};
|
|
63
|
+
default:
|
|
64
|
+
return {
|
|
65
|
+
topic: topic,
|
|
66
|
+
payload: entry.value
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Subscribe to the current pattern
|
|
73
|
+
*/
|
|
74
|
+
function subscribe() {
|
|
75
|
+
subscriptionId = node.cacheConfig.subscribe(node.pattern, (topic, entry) => {
|
|
76
|
+
const msg = buildOutputMessage(topic, entry);
|
|
77
|
+
node.send(msg);
|
|
78
|
+
|
|
79
|
+
// Truncate topic for status display
|
|
80
|
+
const displayTopic = topic.length > 20 ? topic.substring(0, 17) + '...' : topic;
|
|
81
|
+
node.status({ fill: "green", shape: "dot", text: displayTopic });
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Initial subscription
|
|
86
|
+
subscribe();
|
|
87
|
+
node.status({ fill: "green", shape: "dot", text: node.pattern });
|
|
88
|
+
|
|
89
|
+
// Output existing values on start if configured
|
|
90
|
+
if (node.outputOnStart) {
|
|
91
|
+
setImmediate(() => {
|
|
92
|
+
const matching = node.cacheConfig.getMatching(node.pattern);
|
|
93
|
+
for (const [topic, entry] of matching) {
|
|
94
|
+
const msg = buildOutputMessage(topic, entry);
|
|
95
|
+
node.send(msg);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Handle input messages for dynamic pattern change
|
|
101
|
+
node.on('input', function(msg, send, done) {
|
|
102
|
+
// For Node-RED 0.x compatibility
|
|
103
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
104
|
+
done = done || function(err) { if (err) node.error(err, msg); };
|
|
105
|
+
|
|
106
|
+
if (msg.pattern && typeof msg.pattern === 'string') {
|
|
107
|
+
// Unsubscribe from old pattern
|
|
108
|
+
if (subscriptionId && node.cacheConfig) {
|
|
109
|
+
node.cacheConfig.unsubscribe(subscriptionId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Update pattern and resubscribe
|
|
113
|
+
node.pattern = msg.pattern;
|
|
114
|
+
subscribe();
|
|
115
|
+
|
|
116
|
+
node.status({ fill: "blue", shape: "dot", text: node.pattern });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Allow manual trigger to output all current values
|
|
120
|
+
if (msg.topic === 'refresh' || msg.payload === 'refresh') {
|
|
121
|
+
const matching = node.cacheConfig.getMatching(node.pattern);
|
|
122
|
+
for (const [topic, entry] of matching) {
|
|
123
|
+
const outMsg = buildOutputMessage(topic, entry);
|
|
124
|
+
send(outMsg);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
done();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
node.on('close', function(done) {
|
|
132
|
+
if (subscriptionId && node.cacheConfig) {
|
|
133
|
+
node.cacheConfig.unsubscribe(subscriptionId);
|
|
134
|
+
}
|
|
135
|
+
done();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
RED.nodes.registerType("event-topic", EventTopicNode);
|
|
140
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-event-calc",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Node-RED nodes for event caching and calculations with topic wildcard patterns",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Holger Amort"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"node-red",
|
|
10
|
+
"events",
|
|
11
|
+
"cache",
|
|
12
|
+
"wildcards",
|
|
13
|
+
"reactive",
|
|
14
|
+
"calculation",
|
|
15
|
+
"streaming"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"homepage": "https://github.com/ErnstHolger/node-red#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/ErnstHolger/node-red/issues"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/ErnstHolger/node-red.git"
|
|
25
|
+
},
|
|
26
|
+
"node-red": {
|
|
27
|
+
"version": ">=2.0.0",
|
|
28
|
+
"nodes": {
|
|
29
|
+
"event-cache": "nodes/event-cache.js",
|
|
30
|
+
"event-in": "nodes/event-in.js",
|
|
31
|
+
"event-topic": "nodes/event-topic.js",
|
|
32
|
+
"event-calc": "nodes/event-calc.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18.0.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"publish:dry": "npm publish --dry-run"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {}
|
|
42
|
+
}
|