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.
@@ -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>