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,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-cache - Config node providing central cache and event bus
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Map<topic, {value, ts, metadata}> for caching latest values
|
|
6
|
+
* - EventEmitter for notifying subscribers on updates
|
|
7
|
+
* - Wildcard matching (* for one or more chars, ? for exactly one char)
|
|
8
|
+
* - LRU eviction when maxEntries exceeded
|
|
9
|
+
* - Reference counting for cleanup
|
|
10
|
+
*/
|
|
11
|
+
module.exports = function(RED) {
|
|
12
|
+
const EventEmitter = require('events');
|
|
13
|
+
|
|
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
|
+
}
|
|
53
|
+
|
|
54
|
+
function EventCacheNode(config) {
|
|
55
|
+
RED.nodes.createNode(this, config);
|
|
56
|
+
const node = this;
|
|
57
|
+
|
|
58
|
+
node.name = config.name || 'Event Cache';
|
|
59
|
+
node.maxEntries = parseInt(config.maxEntries) || 10000;
|
|
60
|
+
node.ttl = parseInt(config.ttl) || 0; // 0 = no expiry
|
|
61
|
+
|
|
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(),
|
|
67
|
+
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(),
|
|
73
|
+
users: 0,
|
|
74
|
+
subscriptionCounter: 0
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const instance = cacheInstances.get(cacheKey);
|
|
79
|
+
instance.users++;
|
|
80
|
+
instance.emitter.setMaxListeners(100); // Allow many subscribers
|
|
81
|
+
|
|
82
|
+
// TTL cleanup interval
|
|
83
|
+
let ttlInterval = null;
|
|
84
|
+
if (node.ttl > 0) {
|
|
85
|
+
ttlInterval = setInterval(() => {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
for (const [topic, entry] of instance.cache) {
|
|
88
|
+
if (now - entry.ts > node.ttl) {
|
|
89
|
+
instance.cache.delete(topic);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}, Math.min(node.ttl, 60000)); // Check at most every minute
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Set a value in the cache and emit update event
|
|
97
|
+
* @param {string} topic - The topic key
|
|
98
|
+
* @param {any} value - The value to store
|
|
99
|
+
* @param {object} metadata - Optional metadata
|
|
100
|
+
*/
|
|
101
|
+
node.setValue = function(topic, value, metadata = {}) {
|
|
102
|
+
const entry = {
|
|
103
|
+
value: value,
|
|
104
|
+
ts: Date.now(),
|
|
105
|
+
metadata: metadata
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
instance.cache.set(topic, entry);
|
|
109
|
+
|
|
110
|
+
// 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);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Emit topic-specific update event
|
|
117
|
+
instance.emitter.emit('update', topic, entry);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get a value from the cache
|
|
122
|
+
* @param {string} topic - The topic key
|
|
123
|
+
* @returns {object|undefined} - The cached entry {value, ts, metadata} or undefined
|
|
124
|
+
*/
|
|
125
|
+
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;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
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
|
|
152
|
+
* @returns {string} - Subscription ID for unsubscribe
|
|
153
|
+
*/
|
|
154
|
+
node.subscribe = function(pattern, callback) {
|
|
155
|
+
const subId = `sub_${++instance.subscriptionCounter}`;
|
|
156
|
+
|
|
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);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return subId;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Unsubscribe from updates
|
|
178
|
+
* @param {string} subscriptionId - The subscription ID to remove
|
|
179
|
+
*/
|
|
180
|
+
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) {
|
|
188
|
+
if (subs.delete(subscriptionId)) {
|
|
189
|
+
// Clean up empty topic maps
|
|
190
|
+
if (subs.size === 0) {
|
|
191
|
+
instance.exactSubscriptions.delete(topic);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get all topics in cache
|
|
200
|
+
* @returns {string[]} - Array of all topic keys
|
|
201
|
+
*/
|
|
202
|
+
node.getTopics = function() {
|
|
203
|
+
return Array.from(instance.cache.keys());
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get the number of entries in the cache
|
|
208
|
+
* @returns {number} - Cache size
|
|
209
|
+
*/
|
|
210
|
+
node.size = function() {
|
|
211
|
+
return instance.cache.size;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Clear all entries from cache
|
|
216
|
+
*/
|
|
217
|
+
node.clear = function() {
|
|
218
|
+
instance.cache.clear();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Internal: dispatch updates to matching subscriptions
|
|
222
|
+
// Optimized: O(1) for exact matches, O(w) for wildcard patterns
|
|
223
|
+
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) {
|
|
228
|
+
try {
|
|
229
|
+
callback(topic, entry);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
RED.log.error(`[event-cache] Subscription callback error: ${err.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
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
|
+
};
|
|
247
|
+
instance.emitter.on('update', updateHandler);
|
|
248
|
+
|
|
249
|
+
// Cleanup on close
|
|
250
|
+
node.on('close', function(done) {
|
|
251
|
+
if (ttlInterval) {
|
|
252
|
+
clearInterval(ttlInterval);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
instance.users--;
|
|
256
|
+
if (instance.users <= 0) {
|
|
257
|
+
instance.cache.clear();
|
|
258
|
+
instance.exactSubscriptions.clear();
|
|
259
|
+
instance.wildcardSubscriptions.clear();
|
|
260
|
+
instance.emitter.removeAllListeners();
|
|
261
|
+
cacheInstances.delete(cacheKey);
|
|
262
|
+
}
|
|
263
|
+
done();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
RED.nodes.registerType("event-cache", EventCacheNode);
|
|
268
|
+
|
|
269
|
+
// HTTP Admin endpoint to clear cache
|
|
270
|
+
RED.httpAdmin.post("/event-cache/:id/clear", function(req, res) {
|
|
271
|
+
const node = RED.nodes.getNode(req.params.id);
|
|
272
|
+
if (node && node.clear) {
|
|
273
|
+
node.clear();
|
|
274
|
+
res.sendStatus(200);
|
|
275
|
+
} else {
|
|
276
|
+
res.sendStatus(404);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// HTTP Admin endpoint to get cache stats
|
|
281
|
+
RED.httpAdmin.get("/event-cache/:id/stats", function(req, res) {
|
|
282
|
+
const node = RED.nodes.getNode(req.params.id);
|
|
283
|
+
if (node) {
|
|
284
|
+
const instance = cacheInstances.get(node.id);
|
|
285
|
+
let exactSubCount = 0;
|
|
286
|
+
if (instance) {
|
|
287
|
+
for (const subs of instance.exactSubscriptions.values()) {
|
|
288
|
+
exactSubCount += subs.size;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
res.json({
|
|
292
|
+
size: node.size(),
|
|
293
|
+
topics: node.getTopics(),
|
|
294
|
+
maxEntries: node.maxEntries,
|
|
295
|
+
ttl: node.ttl,
|
|
296
|
+
subscriptions: {
|
|
297
|
+
exact: exactSubCount,
|
|
298
|
+
wildcard: instance ? instance.wildcardSubscriptions.size : 0,
|
|
299
|
+
exactTopics: instance ? instance.exactSubscriptions.size : 0
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
res.sendStatus(404);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// HTTP Admin endpoint to get topics only (for autocomplete)
|
|
308
|
+
RED.httpAdmin.get("/event-cache/:id/topics", function(req, res) {
|
|
309
|
+
const node = RED.nodes.getNode(req.params.id);
|
|
310
|
+
if (node) {
|
|
311
|
+
res.json(node.getTopics());
|
|
312
|
+
} else {
|
|
313
|
+
res.json([]);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// HTTP Admin endpoint to get topics from all caches
|
|
318
|
+
RED.httpAdmin.get("/event-cache/topics/all", function(req, res) {
|
|
319
|
+
const allTopics = new Set();
|
|
320
|
+
for (const [cacheKey, instance] of cacheInstances) {
|
|
321
|
+
for (const topic of instance.cache.keys()) {
|
|
322
|
+
allTopics.add(topic);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
res.json(Array.from(allTopics).sort());
|
|
326
|
+
});
|
|
327
|
+
};
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('event-calc', {
|
|
3
|
+
category: 'event calc',
|
|
4
|
+
color: '#87CEEB',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" },
|
|
7
|
+
cache: { value: "", type: "event-cache", required: true },
|
|
8
|
+
inputMappings: { value: [] },
|
|
9
|
+
expression: { value: "" },
|
|
10
|
+
triggerOn: { value: "any" },
|
|
11
|
+
outputTopic: { value: "calc/result" }
|
|
12
|
+
},
|
|
13
|
+
inputs: 1,
|
|
14
|
+
outputs: 1,
|
|
15
|
+
icon: "font-awesome/fa-calculator",
|
|
16
|
+
label: function() {
|
|
17
|
+
return this.name || this.expression || "event calc";
|
|
18
|
+
},
|
|
19
|
+
paletteLabel: "event calc",
|
|
20
|
+
oneditprepare: function() {
|
|
21
|
+
const node = this;
|
|
22
|
+
let cachedTopics = [];
|
|
23
|
+
|
|
24
|
+
// Fetch topics from cache for autocomplete
|
|
25
|
+
function fetchTopics() {
|
|
26
|
+
const cacheId = $("#node-input-cache").val();
|
|
27
|
+
if (cacheId) {
|
|
28
|
+
$.getJSON("event-cache/" + cacheId + "/topics", function(topics) {
|
|
29
|
+
cachedTopics = topics || [];
|
|
30
|
+
// Update existing autocomplete instances
|
|
31
|
+
$(".input-pattern").each(function() {
|
|
32
|
+
$(this).autocomplete("option", "source", cachedTopics);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Refresh topics when cache selection changes
|
|
39
|
+
$("#node-input-cache").on("change", fetchTopics);
|
|
40
|
+
|
|
41
|
+
// Initial fetch
|
|
42
|
+
setTimeout(fetchTopics, 100);
|
|
43
|
+
|
|
44
|
+
// Function picker - insert selected function at cursor
|
|
45
|
+
$("#node-function-picker").on("change", function() {
|
|
46
|
+
const func = $(this).val();
|
|
47
|
+
if (func) {
|
|
48
|
+
const exprInput = document.getElementById("node-input-expression");
|
|
49
|
+
const start = exprInput.selectionStart;
|
|
50
|
+
const end = exprInput.selectionEnd;
|
|
51
|
+
const text = exprInput.value;
|
|
52
|
+
|
|
53
|
+
// Insert function at cursor position
|
|
54
|
+
exprInput.value = text.substring(0, start) + func + text.substring(end);
|
|
55
|
+
|
|
56
|
+
// Position cursor inside the first parenthesis
|
|
57
|
+
const parenPos = func.indexOf('(');
|
|
58
|
+
if (parenPos !== -1) {
|
|
59
|
+
exprInput.selectionStart = exprInput.selectionEnd = start + parenPos + 1;
|
|
60
|
+
} else {
|
|
61
|
+
exprInput.selectionStart = exprInput.selectionEnd = start + func.length;
|
|
62
|
+
}
|
|
63
|
+
exprInput.focus();
|
|
64
|
+
|
|
65
|
+
// Reset dropdown
|
|
66
|
+
$(this).val("");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Input mappings editable list
|
|
71
|
+
const inputList = $("#node-input-inputMappings-list").css({
|
|
72
|
+
'min-height': '150px',
|
|
73
|
+
'min-width': '450px'
|
|
74
|
+
}).editableList({
|
|
75
|
+
addItem: function(container, i, data) {
|
|
76
|
+
const row = $('<div/>', { style: "display:flex; align-items:center; gap:5px;" }).appendTo(container);
|
|
77
|
+
|
|
78
|
+
$('<input/>', {
|
|
79
|
+
type: "text",
|
|
80
|
+
placeholder: "var (e.g. 'a')",
|
|
81
|
+
class: "input-name"
|
|
82
|
+
})
|
|
83
|
+
.css({ width: "25%", flex: "0 0 auto" })
|
|
84
|
+
.val(data.name || "")
|
|
85
|
+
.appendTo(row);
|
|
86
|
+
|
|
87
|
+
$('<span/>', { style: "flex: 0 0 auto;" }).text(" = ").appendTo(row);
|
|
88
|
+
|
|
89
|
+
const patternInput = $('<input/>', {
|
|
90
|
+
type: "text",
|
|
91
|
+
placeholder: "topic (type to search cached topics)",
|
|
92
|
+
class: "input-pattern"
|
|
93
|
+
})
|
|
94
|
+
.css({ flex: "1 1 auto" })
|
|
95
|
+
.val(data.pattern || "")
|
|
96
|
+
.appendTo(row);
|
|
97
|
+
|
|
98
|
+
// Add autocomplete to pattern input
|
|
99
|
+
patternInput.autocomplete({
|
|
100
|
+
source: cachedTopics,
|
|
101
|
+
minLength: 0,
|
|
102
|
+
delay: 0
|
|
103
|
+
}).on("focus", function() {
|
|
104
|
+
// Show all options on focus if empty
|
|
105
|
+
if (!$(this).val()) {
|
|
106
|
+
$(this).autocomplete("search", "");
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
removable: true,
|
|
111
|
+
sortable: true,
|
|
112
|
+
addButton: true
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Load existing inputs
|
|
116
|
+
if (node.inputMappings && node.inputMappings.length > 0) {
|
|
117
|
+
node.inputMappings.forEach(function(input) {
|
|
118
|
+
inputList.editableList('addItem', input);
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
// Add two default empty inputs
|
|
122
|
+
inputList.editableList('addItem', { name: 'a', pattern: '' });
|
|
123
|
+
inputList.editableList('addItem', { name: 'b', pattern: '' });
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
oneditsave: function() {
|
|
127
|
+
const node = this;
|
|
128
|
+
node.inputMappings = [];
|
|
129
|
+
|
|
130
|
+
$("#node-input-inputMappings-list").editableList('items').each(function() {
|
|
131
|
+
const name = $(this).find(".input-name").val().trim();
|
|
132
|
+
const pattern = $(this).find(".input-pattern").val().trim();
|
|
133
|
+
if (name && pattern) {
|
|
134
|
+
node.inputMappings.push({ name: name, pattern: pattern });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<script type="text/html" data-template-name="event-calc">
|
|
142
|
+
<div class="form-row">
|
|
143
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
144
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
145
|
+
</div>
|
|
146
|
+
<div class="form-row">
|
|
147
|
+
<label for="node-input-cache"><i class="fa fa-database"></i> Cache</label>
|
|
148
|
+
<input type="text" id="node-input-cache">
|
|
149
|
+
</div>
|
|
150
|
+
<div class="form-row">
|
|
151
|
+
<label style="width:100%;"><i class="fa fa-sign-in"></i> Input Variables</label>
|
|
152
|
+
<ol id="node-input-inputMappings-list"></ol>
|
|
153
|
+
<div class="form-tips">
|
|
154
|
+
Map variable names to topic patterns. Wildcards: <code>?</code> = one char, <code>*</code> = one or more chars
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="form-row">
|
|
158
|
+
<label for="node-input-expression"><i class="fa fa-code"></i> Expression</label>
|
|
159
|
+
<div style="display:flex; gap:5px;">
|
|
160
|
+
<input type="text" id="node-input-expression" placeholder="e.g. a + b, avg(a, b), round(a, 2)" style="flex:1;">
|
|
161
|
+
<select id="node-function-picker" style="width:120px;">
|
|
162
|
+
<option value="">+ Function</option>
|
|
163
|
+
<optgroup label="Math">
|
|
164
|
+
<option value="min(, )">min(a, b)</option>
|
|
165
|
+
<option value="max(, )">max(a, b)</option>
|
|
166
|
+
<option value="abs()">abs(x)</option>
|
|
167
|
+
<option value="sqrt()">sqrt(x)</option>
|
|
168
|
+
<option value="pow(, )">pow(base, exp)</option>
|
|
169
|
+
<option value="log()">log(x)</option>
|
|
170
|
+
<option value="log10()">log10(x)</option>
|
|
171
|
+
<option value="floor()">floor(x)</option>
|
|
172
|
+
<option value="ceil()">ceil(x)</option>
|
|
173
|
+
<option value="sin()">sin(x)</option>
|
|
174
|
+
<option value="cos()">cos(x)</option>
|
|
175
|
+
<option value="PI">PI</option>
|
|
176
|
+
</optgroup>
|
|
177
|
+
<optgroup label="Aggregation">
|
|
178
|
+
<option value="sum(, )">sum(a, b, ...)</option>
|
|
179
|
+
<option value="avg(, )">avg(a, b, ...)</option>
|
|
180
|
+
<option value="count(, )">count(a, b, ...)</option>
|
|
181
|
+
</optgroup>
|
|
182
|
+
<optgroup label="Utility">
|
|
183
|
+
<option value="round(, 2)">round(val, dec)</option>
|
|
184
|
+
<option value="clamp(, 0, 100)">clamp(val, min, max)</option>
|
|
185
|
+
<option value="map(, 0, 100, 0, 1)">map(val, in1, in2, out1, out2)</option>
|
|
186
|
+
<option value="lerp(, , 0.5)">lerp(a, b, t)</option>
|
|
187
|
+
<option value="ifelse( > , '', '')">ifelse(cond, true, false)</option>
|
|
188
|
+
<option value="between(, , )">between(val, min, max)</option>
|
|
189
|
+
<option value="delta(, )">delta(curr, prev)</option>
|
|
190
|
+
<option value="pctChange(, )">pctChange(curr, prev)</option>
|
|
191
|
+
</optgroup>
|
|
192
|
+
</select>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="form-tips">
|
|
195
|
+
JavaScript expression. Select a function to insert it at cursor position.
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
<div class="form-row">
|
|
199
|
+
<label for="node-input-triggerOn"><i class="fa fa-bolt"></i> Trigger</label>
|
|
200
|
+
<select id="node-input-triggerOn" style="width:70%;">
|
|
201
|
+
<option value="any">Any input updates</option>
|
|
202
|
+
<option value="all">Only when all inputs have values</option>
|
|
203
|
+
</select>
|
|
204
|
+
</div>
|
|
205
|
+
<div class="form-row">
|
|
206
|
+
<label for="node-input-outputTopic"><i class="fa fa-bookmark"></i> Output Topic</label>
|
|
207
|
+
<input type="text" id="node-input-outputTopic" placeholder="calc/result">
|
|
208
|
+
</div>
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<script type="text/html" data-help-name="event-calc">
|
|
212
|
+
<p>Subscribes to multiple topics and evaluates an expression when values update.</p>
|
|
213
|
+
|
|
214
|
+
<h3>Properties</h3>
|
|
215
|
+
<dl class="message-properties">
|
|
216
|
+
<dt>Input Variables</dt>
|
|
217
|
+
<dd>Map variable names to topic patterns. Each variable receives the latest value from matching topics.</dd>
|
|
218
|
+
<dt>Expression</dt>
|
|
219
|
+
<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>
|
|
220
|
+
<dt>Trigger</dt>
|
|
221
|
+
<dd>
|
|
222
|
+
<ul>
|
|
223
|
+
<li><b>Any input updates</b>: Recalculate whenever any subscribed topic updates</li>
|
|
224
|
+
<li><b>Only when all inputs have values</b>: Wait until all inputs have received at least one value</li>
|
|
225
|
+
</ul>
|
|
226
|
+
</dd>
|
|
227
|
+
<dt>Output Topic</dt>
|
|
228
|
+
<dd>Topic to set on output messages</dd>
|
|
229
|
+
</dl>
|
|
230
|
+
|
|
231
|
+
<h3>Inputs</h3>
|
|
232
|
+
<dl class="message-properties">
|
|
233
|
+
<dt class="optional">expression <span class="property-type">string</span></dt>
|
|
234
|
+
<dd>Dynamically update the expression</dd>
|
|
235
|
+
<dt class="optional">payload/topic = "recalc"</dt>
|
|
236
|
+
<dd>Force recalculation with current values</dd>
|
|
237
|
+
</dl>
|
|
238
|
+
|
|
239
|
+
<h3>Outputs</h3>
|
|
240
|
+
<dl class="message-properties">
|
|
241
|
+
<dt>payload <span class="property-type">any</span></dt>
|
|
242
|
+
<dd>The result of the expression</dd>
|
|
243
|
+
<dt>topic <span class="property-type">string</span></dt>
|
|
244
|
+
<dd>The configured output topic</dd>
|
|
245
|
+
<dt>inputs <span class="property-type">object</span></dt>
|
|
246
|
+
<dd>Details of all input values used in the calculation</dd>
|
|
247
|
+
<dt>expression <span class="property-type">string</span></dt>
|
|
248
|
+
<dd>The expression that was evaluated</dd>
|
|
249
|
+
<dt>trigger <span class="property-type">string</span></dt>
|
|
250
|
+
<dd>The topic that triggered this calculation</dd>
|
|
251
|
+
</dl>
|
|
252
|
+
|
|
253
|
+
<h3>Built-in Functions</h3>
|
|
254
|
+
<p>The following functions are available in expressions:</p>
|
|
255
|
+
|
|
256
|
+
<h4>Math</h4>
|
|
257
|
+
<ul>
|
|
258
|
+
<li><code>min(a, b, ...)</code> - Minimum value</li>
|
|
259
|
+
<li><code>max(a, b, ...)</code> - Maximum value</li>
|
|
260
|
+
<li><code>abs(x)</code> - Absolute value</li>
|
|
261
|
+
<li><code>sqrt(x)</code> - Square root</li>
|
|
262
|
+
<li><code>pow(base, exp)</code> - Power</li>
|
|
263
|
+
<li><code>log(x)</code>, <code>log10(x)</code> - Logarithms</li>
|
|
264
|
+
<li><code>exp(x)</code> - e^x</li>
|
|
265
|
+
<li><code>floor(x)</code>, <code>ceil(x)</code> - Rounding</li>
|
|
266
|
+
<li><code>sin(x)</code>, <code>cos(x)</code>, <code>tan(x)</code> - Trigonometry</li>
|
|
267
|
+
<li><code>PI</code>, <code>E</code> - Constants</li>
|
|
268
|
+
</ul>
|
|
269
|
+
|
|
270
|
+
<h4>Aggregation</h4>
|
|
271
|
+
<ul>
|
|
272
|
+
<li><code>sum(a, b, ...)</code> - Sum of values</li>
|
|
273
|
+
<li><code>avg(a, b, ...)</code> - Average of values</li>
|
|
274
|
+
<li><code>count(a, b, ...)</code> - Count of values</li>
|
|
275
|
+
</ul>
|
|
276
|
+
|
|
277
|
+
<h4>Utility</h4>
|
|
278
|
+
<ul>
|
|
279
|
+
<li><code>round(value, decimals)</code> - Round to N decimals</li>
|
|
280
|
+
<li><code>clamp(value, min, max)</code> - Constrain to range</li>
|
|
281
|
+
<li><code>map(value, inMin, inMax, outMin, outMax)</code> - Scale value</li>
|
|
282
|
+
<li><code>lerp(a, b, t)</code> - Linear interpolation</li>
|
|
283
|
+
<li><code>ifelse(cond, trueVal, falseVal)</code> - Conditional</li>
|
|
284
|
+
<li><code>between(value, min, max)</code> - Range check</li>
|
|
285
|
+
<li><code>delta(current, previous)</code> - Difference</li>
|
|
286
|
+
<li><code>pctChange(current, previous)</code> - Percentage change</li>
|
|
287
|
+
</ul>
|
|
288
|
+
|
|
289
|
+
<h3>Expression Examples</h3>
|
|
290
|
+
<ul>
|
|
291
|
+
<li><code>a + b</code> - Sum of two values</li>
|
|
292
|
+
<li><code>avg(a, b)</code> - Average of two values</li>
|
|
293
|
+
<li><code>max(a, b, c)</code> - Maximum of three values</li>
|
|
294
|
+
<li><code>sqrt(a * a + b * b)</code> - Pythagorean calculation</li>
|
|
295
|
+
<li><code>round(a, 2)</code> - Round to 2 decimals</li>
|
|
296
|
+
<li><code>clamp(a, 0, 100)</code> - Constrain to 0-100</li>
|
|
297
|
+
<li><code>map(a, 0, 1023, 0, 100)</code> - Scale ADC to percentage</li>
|
|
298
|
+
<li><code>ifelse(a > b, 'high', 'low')</code> - Conditional</li>
|
|
299
|
+
<li><code>pctChange(a, b)</code> - Percent change from b to a</li>
|
|
300
|
+
</ul>
|
|
301
|
+
|
|
302
|
+
<h3>Wildcard Examples</h3>
|
|
303
|
+
<ul>
|
|
304
|
+
<li><code>sensor?</code> - matches sensor1, sensorA (exactly one char)</li>
|
|
305
|
+
<li><code>sensors/*</code> - matches sensors/temp, sensors/room1/temp</li>
|
|
306
|
+
</ul>
|
|
307
|
+
</script>
|