node-red-contrib-event-calc 0.1.2 → 0.1.4
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/nodes/event-cache.js +84 -139
- package/nodes/event-calc.html +37 -21
- package/nodes/event-calc.js +73 -26
- package/nodes/event-chart.html +239 -0
- package/nodes/event-chart.js +106 -0
- package/nodes/event-simulator.html +156 -0
- package/nodes/event-simulator.js +185 -0
- package/nodes/event-topic.html +16 -30
- package/nodes/event-topic.js +33 -40
- package/package.json +4 -2
package/nodes/event-cache.js
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
|
15
|
-
const
|
|
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
|
-
//
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
69
|
-
|
|
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 =
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
83
|
+
const cache = globalContext.get(contextKey) || {};
|
|
84
|
+
cache[topic] = entry;
|
|
109
85
|
|
|
110
86
|
// Enforce max entries (LRU eviction - remove oldest)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
149
|
-
*
|
|
150
|
-
* @param {
|
|
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(
|
|
123
|
+
node.subscribe = function(topic, callback) {
|
|
155
124
|
const subId = `sub_${++instance.subscriptionCounter}`;
|
|
156
125
|
|
|
157
|
-
if (
|
|
158
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
258
|
-
instance.
|
|
259
|
-
instance.wildcardSubscriptions.clear();
|
|
198
|
+
// Don't clear the context cache - let it persist
|
|
199
|
+
instance.subscriptions.clear();
|
|
260
200
|
instance.emitter.removeAllListeners();
|
|
261
|
-
|
|
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 =
|
|
285
|
-
let
|
|
224
|
+
const instance = sharedInstances.get(node.id);
|
|
225
|
+
let subCount = 0;
|
|
286
226
|
if (instance) {
|
|
287
|
-
for (const subs of instance.
|
|
288
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
};
|
package/nodes/event-calc.html
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
outputTopic: { value: "calc/result" }
|
|
17
17
|
},
|
|
18
18
|
inputs: 1,
|
|
19
|
-
outputs:
|
|
19
|
+
outputs: 2,
|
|
20
|
+
outputLabels: ["result", "error"],
|
|
20
21
|
icon: "font-awesome/fa-calculator",
|
|
21
22
|
label: function() {
|
|
22
23
|
return this.name || this.expression || "event calc";
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
$.getJSON("event-cache/" + cacheId + "/topics", function(topics) {
|
|
35
36
|
cachedTopics = topics || [];
|
|
36
37
|
// Update existing autocomplete instances
|
|
37
|
-
$(".input-
|
|
38
|
+
$(".input-topic").each(function() {
|
|
38
39
|
$(this).autocomplete("option", "source", cachedTopics);
|
|
39
40
|
});
|
|
40
41
|
});
|
|
@@ -92,17 +93,17 @@
|
|
|
92
93
|
|
|
93
94
|
$('<span/>', { style: "flex: 0 0 auto;" }).text(" = ").appendTo(row);
|
|
94
95
|
|
|
95
|
-
const
|
|
96
|
+
const topicInput = $('<input/>', {
|
|
96
97
|
type: "text",
|
|
97
98
|
placeholder: "topic (type to search cached topics)",
|
|
98
|
-
class: "input-
|
|
99
|
+
class: "input-topic"
|
|
99
100
|
})
|
|
100
101
|
.css({ flex: "1 1 auto" })
|
|
101
|
-
.val(data.pattern || "")
|
|
102
|
+
.val(data.topic || data.pattern || "")
|
|
102
103
|
.appendTo(row);
|
|
103
104
|
|
|
104
|
-
// Add autocomplete to
|
|
105
|
-
|
|
105
|
+
// Add autocomplete to topic input
|
|
106
|
+
topicInput.autocomplete({
|
|
106
107
|
source: cachedTopics,
|
|
107
108
|
minLength: 0,
|
|
108
109
|
delay: 0
|
|
@@ -135,9 +136,9 @@
|
|
|
135
136
|
|
|
136
137
|
$("#node-input-inputMappings-list").editableList('items').each(function() {
|
|
137
138
|
const name = $(this).find(".input-name").val().trim();
|
|
138
|
-
const
|
|
139
|
-
if (name &&
|
|
140
|
-
node.inputMappings.push({ name: name,
|
|
139
|
+
const topic = $(this).find(".input-topic").val().trim();
|
|
140
|
+
if (name && topic) {
|
|
141
|
+
node.inputMappings.push({ name: name, topic: topic });
|
|
141
142
|
}
|
|
142
143
|
});
|
|
143
144
|
}
|
|
@@ -157,7 +158,7 @@
|
|
|
157
158
|
<label style="width:100%;"><i class="fa fa-sign-in"></i> Input Variables</label>
|
|
158
159
|
<ol id="node-input-inputMappings-list"></ol>
|
|
159
160
|
<div class="form-tips">
|
|
160
|
-
Map variable names to
|
|
161
|
+
Map variable names to topics.
|
|
161
162
|
</div>
|
|
162
163
|
</div>
|
|
163
164
|
<div class="form-row">
|
|
@@ -220,7 +221,7 @@
|
|
|
220
221
|
<h3>Properties</h3>
|
|
221
222
|
<dl class="message-properties">
|
|
222
223
|
<dt>Input Variables</dt>
|
|
223
|
-
<dd>Map variable names to
|
|
224
|
+
<dd>Map variable names to topics.</dd>
|
|
224
225
|
<dt>Expression</dt>
|
|
225
226
|
<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>
|
|
226
227
|
<dt>Trigger</dt>
|
|
@@ -231,7 +232,7 @@
|
|
|
231
232
|
</ul>
|
|
232
233
|
</dd>
|
|
233
234
|
<dt>Output Topic</dt>
|
|
234
|
-
<dd>Topic
|
|
235
|
+
<dd>Topic for output messages.</dd>
|
|
235
236
|
</dl>
|
|
236
237
|
|
|
237
238
|
<h3>Inputs</h3>
|
|
@@ -243,19 +244,40 @@
|
|
|
243
244
|
</dl>
|
|
244
245
|
|
|
245
246
|
<h3>Outputs</h3>
|
|
247
|
+
<p>The node has two outputs:</p>
|
|
248
|
+
<ol>
|
|
249
|
+
<li><b>Result</b> - Successful calculation results</li>
|
|
250
|
+
<li><b>Error</b> - Errors (NaN results, evaluation failures)</li>
|
|
251
|
+
</ol>
|
|
252
|
+
|
|
253
|
+
<h4>Output 1 (Result)</h4>
|
|
246
254
|
<dl class="message-properties">
|
|
247
255
|
<dt>payload <span class="property-type">any</span></dt>
|
|
248
256
|
<dd>The result of the expression</dd>
|
|
249
257
|
<dt>topic <span class="property-type">string</span></dt>
|
|
250
|
-
<dd>The
|
|
258
|
+
<dd>The output topic</dd>
|
|
259
|
+
<dt>topics <span class="property-type">object</span></dt>
|
|
260
|
+
<dd>Mapping of variable names to topics</dd>
|
|
261
|
+
<dt>timestamps <span class="property-type">object</span></dt>
|
|
262
|
+
<dd>Mapping of variable names to their cached timestamps</dd>
|
|
251
263
|
<dt>inputs <span class="property-type">object</span></dt>
|
|
252
|
-
<dd>
|
|
264
|
+
<dd>Full details of all input values</dd>
|
|
253
265
|
<dt>expression <span class="property-type">string</span></dt>
|
|
254
266
|
<dd>The expression that was evaluated</dd>
|
|
255
267
|
<dt>trigger <span class="property-type">string</span></dt>
|
|
256
268
|
<dd>The topic that triggered this calculation</dd>
|
|
257
269
|
</dl>
|
|
258
270
|
|
|
271
|
+
<h4>Output 2 (Error)</h4>
|
|
272
|
+
<dl class="message-properties">
|
|
273
|
+
<dt>payload.error <span class="property-type">string</span></dt>
|
|
274
|
+
<dd>Error message (e.g., "Expression resulted in NaN")</dd>
|
|
275
|
+
<dt>payload.missingInputs <span class="property-type">array</span></dt>
|
|
276
|
+
<dd>List of input variables that were undefined</dd>
|
|
277
|
+
<dt>payload.expression <span class="property-type">string</span></dt>
|
|
278
|
+
<dd>The expression that failed</dd>
|
|
279
|
+
</dl>
|
|
280
|
+
|
|
259
281
|
<h3>Built-in Functions</h3>
|
|
260
282
|
<p>The following functions are available in expressions:</p>
|
|
261
283
|
|
|
@@ -304,10 +326,4 @@
|
|
|
304
326
|
<li><code>ifelse(a > b, 'high', 'low')</code> - Conditional</li>
|
|
305
327
|
<li><code>pctChange(a, b)</code> - Percent change from b to a</li>
|
|
306
328
|
</ul>
|
|
307
|
-
|
|
308
|
-
<h3>Wildcard Examples</h3>
|
|
309
|
-
<ul>
|
|
310
|
-
<li><code>sensor?</code> - matches sensor1, sensorA (exactly one char)</li>
|
|
311
|
-
<li><code>sensors/*</code> - matches sensors/temp, sensors/room1/temp</li>
|
|
312
|
-
</ul>
|
|
313
329
|
</script>
|