node-red-contrib-event-calc 3.3.3 → 3.3.6
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 +36 -2
- package/examples/change-detection-example.json +448 -0
- package/nodes/event-cache.js +18 -2
- package/nodes/event-calc.js +67 -2
- package/nodes/event-flatten.html +53 -0
- package/nodes/event-flatten.js +25 -0
- package/package.json +2 -1
- package/node-red-contrib-event-calc-3.3.2.tgz +0 -0
- package/nul +0 -0
- package/playwright.config.js +0 -22
- package/tests/external-trigger.spec.js +0 -141
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ This package provides a local in-memory event hub with topic-based publish/subsc
|
|
|
11
11
|
```
|
|
12
12
|
┌──────────────────────────────────────────────────────────────┐
|
|
13
13
|
│ event-cache (config node) │
|
|
14
|
-
│ • Stores: Map<topic, {value, ts, metadata}>
|
|
14
|
+
│ • Stores: Map<topic, {value, ts, metadata, previous}> │
|
|
15
15
|
│ • Event emitter for topic updates │
|
|
16
16
|
│ • LRU eviction, optional TTL │
|
|
17
17
|
└──────────────────────────────────────────────────────────────┘
|
|
@@ -190,6 +190,30 @@ Real-time charting node for visualizing cached event data.
|
|
|
190
190
|
| `delta(current, previous)` | Difference |
|
|
191
191
|
| `pctChange(current, previous)` | Percentage change |
|
|
192
192
|
|
|
193
|
+
### Change Detection
|
|
194
|
+
| Function | Description |
|
|
195
|
+
|----------|-------------|
|
|
196
|
+
| `now()` | Current timestamp in milliseconds |
|
|
197
|
+
| `prev('varName')` | Previous value of a variable |
|
|
198
|
+
| `hasChanged('varName')` | `true` if value differs from previous (false on first message) |
|
|
199
|
+
| `timeSinceLastChange('varName')` | Milliseconds since the value last changed |
|
|
200
|
+
|
|
201
|
+
The cache automatically tracks the previous value for every topic. On the first message, `prev()` returns the same value as the current (so `hasChanged()` returns `false` and delta is `0`).
|
|
202
|
+
|
|
203
|
+
### Date/Time
|
|
204
|
+
| Function | Description |
|
|
205
|
+
|----------|-------------|
|
|
206
|
+
| `hour()` | Current hour (0-23) |
|
|
207
|
+
| `minute()` | Current minute (0-59) |
|
|
208
|
+
| `second()` | Current second (0-59) |
|
|
209
|
+
| `day()` | Day of week (0=Sun, 1=Mon, ..., 6=Sat) |
|
|
210
|
+
| `dayOfMonth()` | Day of month (1-31) |
|
|
211
|
+
| `month()` | Month (1-12) |
|
|
212
|
+
| `year()` | Full year (e.g. 2026) |
|
|
213
|
+
| `isWeekday()` | `true` if Monday-Friday |
|
|
214
|
+
| `isWeekend()` | `true` if Saturday or Sunday |
|
|
215
|
+
| `hoursBetween(start, end)` | `true` if current hour is within range (wraps midnight) |
|
|
216
|
+
|
|
193
217
|
## Expression Examples
|
|
194
218
|
|
|
195
219
|
| Expression | Description |
|
|
@@ -203,6 +227,12 @@ Real-time charting node for visualizing cached event data.
|
|
|
203
227
|
| `map(a, 0, 1023, 0, 100)` | Scale ADC to % |
|
|
204
228
|
| `ifelse(a > b, 'high', 'low')` | Conditional |
|
|
205
229
|
| `pctChange(a, b)` | % change from b to a |
|
|
230
|
+
| `hasChanged('temp')` | Did temperature just change? |
|
|
231
|
+
| `temp - prev('temp')` | Delta from previous value |
|
|
232
|
+
| `round(timeSinceLastChange('temp') / 1000, 1)` | Seconds since last change |
|
|
233
|
+
| `isWeekday() && hoursBetween(8, 18)` | During business hours? |
|
|
234
|
+
| `hoursBetween(22, 6)` | Night shift (wraps midnight) |
|
|
235
|
+
| `ifelse(isWeekend(), temp * 0.8, temp)` | Reduce setpoint on weekends |
|
|
206
236
|
|
|
207
237
|
## API (for custom nodes)
|
|
208
238
|
|
|
@@ -216,7 +246,11 @@ cache.setValue('topic/path', 42, { source: 'sensor' });
|
|
|
216
246
|
|
|
217
247
|
// Get a value
|
|
218
248
|
const entry = cache.getValue('topic/path');
|
|
219
|
-
// { value: 42, ts: 1704000000000, metadata: { source: 'sensor' } }
|
|
249
|
+
// { value: 42, ts: 1704000000000, metadata: { source: 'sensor' }, previous: { value: 40, ts: ..., metadata: ... } }
|
|
250
|
+
|
|
251
|
+
// Get previous value
|
|
252
|
+
const prev = cache.getPrevious('topic/path');
|
|
253
|
+
// { value: 40, ts: 1703999990000, metadata: { source: 'sensor' } }
|
|
220
254
|
|
|
221
255
|
// Subscribe to updates
|
|
222
256
|
const subId = cache.subscribe('sensors/room1/temp', (topic, entry) => {
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "change-detection-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Change Detection Example",
|
|
6
|
+
"disabled": false,
|
|
7
|
+
"info": "Demonstrates hasChanged(), timeSinceLastChange(), now(), and prev() functions.\n\n1. Click 'Send 22' then 'Send 22' again (same value) - hasChanged returns false\n2. Click 'Send 25' (different value) - hasChanged returns true\n3. Watch timeSinceLastChange grow between clicks"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "cache-cd",
|
|
11
|
+
"type": "event-cache",
|
|
12
|
+
"name": "CD Cache",
|
|
13
|
+
"maxEntries": "10000",
|
|
14
|
+
"ttl": "0"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "inject-val-22",
|
|
18
|
+
"type": "inject",
|
|
19
|
+
"z": "change-detection-flow",
|
|
20
|
+
"name": "Send 22",
|
|
21
|
+
"props": [
|
|
22
|
+
{ "p": "topic", "vt": "str" },
|
|
23
|
+
{ "p": "payload" }
|
|
24
|
+
],
|
|
25
|
+
"topic": "sensor/temperature",
|
|
26
|
+
"payload": "22",
|
|
27
|
+
"payloadType": "num",
|
|
28
|
+
"repeat": "",
|
|
29
|
+
"crontab": "",
|
|
30
|
+
"once": false,
|
|
31
|
+
"onceDelay": 0.1,
|
|
32
|
+
"x": 130,
|
|
33
|
+
"y": 100,
|
|
34
|
+
"wires": [["event-in-cd"]]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "inject-val-25",
|
|
38
|
+
"type": "inject",
|
|
39
|
+
"z": "change-detection-flow",
|
|
40
|
+
"name": "Send 25",
|
|
41
|
+
"props": [
|
|
42
|
+
{ "p": "topic", "vt": "str" },
|
|
43
|
+
{ "p": "payload" }
|
|
44
|
+
],
|
|
45
|
+
"topic": "sensor/temperature",
|
|
46
|
+
"payload": "25",
|
|
47
|
+
"payloadType": "num",
|
|
48
|
+
"repeat": "",
|
|
49
|
+
"crontab": "",
|
|
50
|
+
"once": false,
|
|
51
|
+
"onceDelay": 0.1,
|
|
52
|
+
"x": 130,
|
|
53
|
+
"y": 160,
|
|
54
|
+
"wires": [["event-in-cd"]]
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "inject-val-30",
|
|
58
|
+
"type": "inject",
|
|
59
|
+
"z": "change-detection-flow",
|
|
60
|
+
"name": "Send 30",
|
|
61
|
+
"props": [
|
|
62
|
+
{ "p": "topic", "vt": "str" },
|
|
63
|
+
{ "p": "payload" }
|
|
64
|
+
],
|
|
65
|
+
"topic": "sensor/temperature",
|
|
66
|
+
"payload": "30",
|
|
67
|
+
"payloadType": "num",
|
|
68
|
+
"repeat": "",
|
|
69
|
+
"crontab": "",
|
|
70
|
+
"once": false,
|
|
71
|
+
"onceDelay": 0.1,
|
|
72
|
+
"x": 130,
|
|
73
|
+
"y": 220,
|
|
74
|
+
"wires": [["event-in-cd"]]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "event-in-cd",
|
|
78
|
+
"type": "event-in",
|
|
79
|
+
"z": "change-detection-flow",
|
|
80
|
+
"name": "",
|
|
81
|
+
"cache": "cache-cd",
|
|
82
|
+
"topicField": "topic",
|
|
83
|
+
"valueField": "payload",
|
|
84
|
+
"x": 330,
|
|
85
|
+
"y": 160,
|
|
86
|
+
"wires": [[]]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": "calc-has-changed",
|
|
90
|
+
"type": "event-calc",
|
|
91
|
+
"z": "change-detection-flow",
|
|
92
|
+
"name": "Has Changed?",
|
|
93
|
+
"cache": "cache-cd",
|
|
94
|
+
"inputMappings": [
|
|
95
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
96
|
+
],
|
|
97
|
+
"expression": "hasChanged('temp')",
|
|
98
|
+
"triggerOn": "any",
|
|
99
|
+
"outputTopic": "calc/has_changed",
|
|
100
|
+
"x": 330,
|
|
101
|
+
"y": 300,
|
|
102
|
+
"wires": [["debug-changed"], []]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "debug-changed",
|
|
106
|
+
"type": "debug",
|
|
107
|
+
"z": "change-detection-flow",
|
|
108
|
+
"name": "hasChanged result",
|
|
109
|
+
"active": true,
|
|
110
|
+
"tosidebar": true,
|
|
111
|
+
"console": false,
|
|
112
|
+
"tostatus": true,
|
|
113
|
+
"complete": "payload",
|
|
114
|
+
"targetType": "msg",
|
|
115
|
+
"statusVal": "payload",
|
|
116
|
+
"statusType": "auto",
|
|
117
|
+
"x": 560,
|
|
118
|
+
"y": 300,
|
|
119
|
+
"wires": []
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"id": "calc-time-since",
|
|
123
|
+
"type": "event-calc",
|
|
124
|
+
"z": "change-detection-flow",
|
|
125
|
+
"name": "Time Since Change (sec)",
|
|
126
|
+
"cache": "cache-cd",
|
|
127
|
+
"inputMappings": [
|
|
128
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
129
|
+
],
|
|
130
|
+
"expression": "round(timeSinceLastChange('temp') / 1000, 1)",
|
|
131
|
+
"triggerOn": "any",
|
|
132
|
+
"outputTopic": "calc/time_since_change",
|
|
133
|
+
"x": 370,
|
|
134
|
+
"y": 370,
|
|
135
|
+
"wires": [["debug-time-since"], []]
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"id": "debug-time-since",
|
|
139
|
+
"type": "debug",
|
|
140
|
+
"z": "change-detection-flow",
|
|
141
|
+
"name": "timeSinceLastChange (s)",
|
|
142
|
+
"active": true,
|
|
143
|
+
"tosidebar": true,
|
|
144
|
+
"console": false,
|
|
145
|
+
"tostatus": true,
|
|
146
|
+
"complete": "payload",
|
|
147
|
+
"targetType": "msg",
|
|
148
|
+
"statusVal": "payload",
|
|
149
|
+
"statusType": "auto",
|
|
150
|
+
"x": 610,
|
|
151
|
+
"y": 370,
|
|
152
|
+
"wires": []
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"id": "calc-prev-value",
|
|
156
|
+
"type": "event-calc",
|
|
157
|
+
"z": "change-detection-flow",
|
|
158
|
+
"name": "Previous Value",
|
|
159
|
+
"cache": "cache-cd",
|
|
160
|
+
"inputMappings": [
|
|
161
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
162
|
+
],
|
|
163
|
+
"expression": "prev('temp')",
|
|
164
|
+
"triggerOn": "any",
|
|
165
|
+
"outputTopic": "calc/prev_value",
|
|
166
|
+
"x": 340,
|
|
167
|
+
"y": 440,
|
|
168
|
+
"wires": [["debug-prev"], []]
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"id": "debug-prev",
|
|
172
|
+
"type": "debug",
|
|
173
|
+
"z": "change-detection-flow",
|
|
174
|
+
"name": "prev() result",
|
|
175
|
+
"active": true,
|
|
176
|
+
"tosidebar": true,
|
|
177
|
+
"console": false,
|
|
178
|
+
"tostatus": true,
|
|
179
|
+
"complete": "payload",
|
|
180
|
+
"targetType": "msg",
|
|
181
|
+
"statusVal": "payload",
|
|
182
|
+
"statusType": "auto",
|
|
183
|
+
"x": 560,
|
|
184
|
+
"y": 440,
|
|
185
|
+
"wires": []
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"id": "calc-delta",
|
|
189
|
+
"type": "event-calc",
|
|
190
|
+
"z": "change-detection-flow",
|
|
191
|
+
"name": "Delta (current - prev)",
|
|
192
|
+
"cache": "cache-cd",
|
|
193
|
+
"inputMappings": [
|
|
194
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
195
|
+
],
|
|
196
|
+
"expression": "temp - prev('temp')",
|
|
197
|
+
"triggerOn": "any",
|
|
198
|
+
"outputTopic": "calc/delta",
|
|
199
|
+
"x": 360,
|
|
200
|
+
"y": 510,
|
|
201
|
+
"wires": [["debug-delta"], []]
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"id": "debug-delta",
|
|
205
|
+
"type": "debug",
|
|
206
|
+
"z": "change-detection-flow",
|
|
207
|
+
"name": "delta result",
|
|
208
|
+
"active": true,
|
|
209
|
+
"tosidebar": true,
|
|
210
|
+
"console": false,
|
|
211
|
+
"tostatus": true,
|
|
212
|
+
"complete": "payload",
|
|
213
|
+
"targetType": "msg",
|
|
214
|
+
"statusVal": "payload",
|
|
215
|
+
"statusType": "auto",
|
|
216
|
+
"x": 560,
|
|
217
|
+
"y": 510,
|
|
218
|
+
"wires": []
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"id": "calc-now",
|
|
222
|
+
"type": "event-calc",
|
|
223
|
+
"z": "change-detection-flow",
|
|
224
|
+
"name": "Current Time (now)",
|
|
225
|
+
"cache": "cache-cd",
|
|
226
|
+
"inputMappings": [
|
|
227
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
228
|
+
],
|
|
229
|
+
"expression": "now()",
|
|
230
|
+
"triggerOn": "any",
|
|
231
|
+
"outputTopic": "calc/now",
|
|
232
|
+
"x": 350,
|
|
233
|
+
"y": 580,
|
|
234
|
+
"wires": [["debug-now"], []]
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"id": "debug-now",
|
|
238
|
+
"type": "debug",
|
|
239
|
+
"z": "change-detection-flow",
|
|
240
|
+
"name": "now() result",
|
|
241
|
+
"active": true,
|
|
242
|
+
"tosidebar": true,
|
|
243
|
+
"console": false,
|
|
244
|
+
"tostatus": true,
|
|
245
|
+
"complete": "payload",
|
|
246
|
+
"targetType": "msg",
|
|
247
|
+
"statusVal": "payload",
|
|
248
|
+
"statusType": "auto",
|
|
249
|
+
"x": 560,
|
|
250
|
+
"y": 580,
|
|
251
|
+
"wires": []
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"id": "comment-cd-1",
|
|
255
|
+
"type": "comment",
|
|
256
|
+
"z": "change-detection-flow",
|
|
257
|
+
"name": "1. Click inject buttons to send values",
|
|
258
|
+
"info": "Send the same value twice to see hasChanged=false.\nSend a different value to see hasChanged=true.",
|
|
259
|
+
"x": 190,
|
|
260
|
+
"y": 40,
|
|
261
|
+
"wires": []
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"id": "comment-cd-2",
|
|
265
|
+
"type": "comment",
|
|
266
|
+
"z": "change-detection-flow",
|
|
267
|
+
"name": "2. Watch debug panel for results of each function",
|
|
268
|
+
"info": "",
|
|
269
|
+
"x": 600,
|
|
270
|
+
"y": 40,
|
|
271
|
+
"wires": []
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"id": "comment-cd-3",
|
|
275
|
+
"type": "comment",
|
|
276
|
+
"z": "change-detection-flow",
|
|
277
|
+
"name": "--- Date/Time Functions ---",
|
|
278
|
+
"info": "hour(), minute(), day(), isWeekday(), isWeekend(), hoursBetween(start, end)",
|
|
279
|
+
"x": 170,
|
|
280
|
+
"y": 660,
|
|
281
|
+
"wires": []
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"id": "calc-hour",
|
|
285
|
+
"type": "event-calc",
|
|
286
|
+
"z": "change-detection-flow",
|
|
287
|
+
"name": "Current Hour",
|
|
288
|
+
"cache": "cache-cd",
|
|
289
|
+
"inputMappings": [
|
|
290
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
291
|
+
],
|
|
292
|
+
"expression": "hour()",
|
|
293
|
+
"triggerOn": "any",
|
|
294
|
+
"outputTopic": "calc/hour",
|
|
295
|
+
"x": 330,
|
|
296
|
+
"y": 710,
|
|
297
|
+
"wires": [["debug-hour"], []]
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"id": "debug-hour",
|
|
301
|
+
"type": "debug",
|
|
302
|
+
"z": "change-detection-flow",
|
|
303
|
+
"name": "hour() result",
|
|
304
|
+
"active": true,
|
|
305
|
+
"tosidebar": true,
|
|
306
|
+
"console": false,
|
|
307
|
+
"tostatus": true,
|
|
308
|
+
"complete": "payload",
|
|
309
|
+
"targetType": "msg",
|
|
310
|
+
"statusVal": "payload",
|
|
311
|
+
"statusType": "auto",
|
|
312
|
+
"x": 560,
|
|
313
|
+
"y": 710,
|
|
314
|
+
"wires": []
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"id": "calc-day",
|
|
318
|
+
"type": "event-calc",
|
|
319
|
+
"z": "change-detection-flow",
|
|
320
|
+
"name": "Day of Week (0=Sun)",
|
|
321
|
+
"cache": "cache-cd",
|
|
322
|
+
"inputMappings": [
|
|
323
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
324
|
+
],
|
|
325
|
+
"expression": "day()",
|
|
326
|
+
"triggerOn": "any",
|
|
327
|
+
"outputTopic": "calc/day",
|
|
328
|
+
"x": 350,
|
|
329
|
+
"y": 780,
|
|
330
|
+
"wires": [["debug-day"], []]
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"id": "debug-day",
|
|
334
|
+
"type": "debug",
|
|
335
|
+
"z": "change-detection-flow",
|
|
336
|
+
"name": "day() result",
|
|
337
|
+
"active": true,
|
|
338
|
+
"tosidebar": true,
|
|
339
|
+
"console": false,
|
|
340
|
+
"tostatus": true,
|
|
341
|
+
"complete": "payload",
|
|
342
|
+
"targetType": "msg",
|
|
343
|
+
"statusVal": "payload",
|
|
344
|
+
"statusType": "auto",
|
|
345
|
+
"x": 560,
|
|
346
|
+
"y": 780,
|
|
347
|
+
"wires": []
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
"id": "calc-is-weekday",
|
|
351
|
+
"type": "event-calc",
|
|
352
|
+
"z": "change-detection-flow",
|
|
353
|
+
"name": "Is Weekday?",
|
|
354
|
+
"cache": "cache-cd",
|
|
355
|
+
"inputMappings": [
|
|
356
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
357
|
+
],
|
|
358
|
+
"expression": "isWeekday()",
|
|
359
|
+
"triggerOn": "any",
|
|
360
|
+
"outputTopic": "calc/is_weekday",
|
|
361
|
+
"x": 330,
|
|
362
|
+
"y": 850,
|
|
363
|
+
"wires": [["debug-weekday"], []]
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
"id": "debug-weekday",
|
|
367
|
+
"type": "debug",
|
|
368
|
+
"z": "change-detection-flow",
|
|
369
|
+
"name": "isWeekday() result",
|
|
370
|
+
"active": true,
|
|
371
|
+
"tosidebar": true,
|
|
372
|
+
"console": false,
|
|
373
|
+
"tostatus": true,
|
|
374
|
+
"complete": "payload",
|
|
375
|
+
"targetType": "msg",
|
|
376
|
+
"statusVal": "payload",
|
|
377
|
+
"statusType": "auto",
|
|
378
|
+
"x": 570,
|
|
379
|
+
"y": 850,
|
|
380
|
+
"wires": []
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
"id": "calc-business-hours",
|
|
384
|
+
"type": "event-calc",
|
|
385
|
+
"z": "change-detection-flow",
|
|
386
|
+
"name": "Business Hours? (8-18)",
|
|
387
|
+
"cache": "cache-cd",
|
|
388
|
+
"inputMappings": [
|
|
389
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
390
|
+
],
|
|
391
|
+
"expression": "isWeekday() && hoursBetween(8, 18)",
|
|
392
|
+
"triggerOn": "any",
|
|
393
|
+
"outputTopic": "calc/business_hours",
|
|
394
|
+
"x": 370,
|
|
395
|
+
"y": 920,
|
|
396
|
+
"wires": [["debug-business"], []]
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"id": "debug-business",
|
|
400
|
+
"type": "debug",
|
|
401
|
+
"z": "change-detection-flow",
|
|
402
|
+
"name": "business hours result",
|
|
403
|
+
"active": true,
|
|
404
|
+
"tosidebar": true,
|
|
405
|
+
"console": false,
|
|
406
|
+
"tostatus": true,
|
|
407
|
+
"complete": "payload",
|
|
408
|
+
"targetType": "msg",
|
|
409
|
+
"statusVal": "payload",
|
|
410
|
+
"statusType": "auto",
|
|
411
|
+
"x": 590,
|
|
412
|
+
"y": 920,
|
|
413
|
+
"wires": []
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
"id": "calc-night-shift",
|
|
417
|
+
"type": "event-calc",
|
|
418
|
+
"z": "change-detection-flow",
|
|
419
|
+
"name": "Night Shift? (22-6)",
|
|
420
|
+
"cache": "cache-cd",
|
|
421
|
+
"inputMappings": [
|
|
422
|
+
{ "name": "temp", "pattern": "sensor/temperature" }
|
|
423
|
+
],
|
|
424
|
+
"expression": "hoursBetween(22, 6)",
|
|
425
|
+
"triggerOn": "any",
|
|
426
|
+
"outputTopic": "calc/night_shift",
|
|
427
|
+
"x": 360,
|
|
428
|
+
"y": 990,
|
|
429
|
+
"wires": [["debug-night"], []]
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
"id": "debug-night",
|
|
433
|
+
"type": "debug",
|
|
434
|
+
"z": "change-detection-flow",
|
|
435
|
+
"name": "night shift result",
|
|
436
|
+
"active": true,
|
|
437
|
+
"tosidebar": true,
|
|
438
|
+
"console": false,
|
|
439
|
+
"tostatus": true,
|
|
440
|
+
"complete": "payload",
|
|
441
|
+
"targetType": "msg",
|
|
442
|
+
"statusVal": "payload",
|
|
443
|
+
"statusType": "auto",
|
|
444
|
+
"x": 580,
|
|
445
|
+
"y": 990,
|
|
446
|
+
"wires": []
|
|
447
|
+
}
|
|
448
|
+
]
|
package/nodes/event-cache.js
CHANGED
|
@@ -74,13 +74,18 @@ module.exports = function(RED) {
|
|
|
74
74
|
* @param {object} metadata - Optional metadata
|
|
75
75
|
*/
|
|
76
76
|
node.setValue = function(topic, value, metadata = {}) {
|
|
77
|
+
const cache = globalContext.get(contextKey) || {};
|
|
78
|
+
const existing = cache[topic];
|
|
79
|
+
|
|
77
80
|
const entry = {
|
|
78
81
|
value: value,
|
|
79
82
|
ts: Date.now(),
|
|
80
|
-
metadata: metadata
|
|
83
|
+
metadata: metadata,
|
|
84
|
+
previous: existing
|
|
85
|
+
? { value: existing.value, ts: existing.ts, metadata: existing.metadata }
|
|
86
|
+
: { value: value, ts: Date.now(), metadata: metadata }
|
|
81
87
|
};
|
|
82
88
|
|
|
83
|
-
const cache = globalContext.get(contextKey) || {};
|
|
84
89
|
cache[topic] = entry;
|
|
85
90
|
|
|
86
91
|
// Enforce max entries (LRU eviction - remove oldest)
|
|
@@ -114,6 +119,17 @@ module.exports = function(RED) {
|
|
|
114
119
|
return cache[topic];
|
|
115
120
|
};
|
|
116
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Get the previous value for a topic
|
|
124
|
+
* @param {string} topic - The topic key
|
|
125
|
+
* @returns {object|undefined} - The previous entry {value, ts, metadata} or undefined
|
|
126
|
+
*/
|
|
127
|
+
node.getPrevious = function(topic) {
|
|
128
|
+
const cache = globalContext.get(contextKey) || {};
|
|
129
|
+
const entry = cache[topic];
|
|
130
|
+
return entry ? entry.previous : undefined;
|
|
131
|
+
};
|
|
132
|
+
|
|
117
133
|
/**
|
|
118
134
|
* Subscribe to updates for a specific topic
|
|
119
135
|
* @param {string} topic - The exact topic to subscribe to
|
package/nodes/event-calc.js
CHANGED
|
@@ -55,7 +55,24 @@ module.exports = function(RED) {
|
|
|
55
55
|
|
|
56
56
|
// Delta/change detection (returns difference)
|
|
57
57
|
delta: (current, previous) => current - previous,
|
|
58
|
-
pctChange: (current, previous) => previous !== 0 ? ((current - previous) / previous) * 100 : 0
|
|
58
|
+
pctChange: (current, previous) => previous !== 0 ? ((current - previous) / previous) * 100 : 0,
|
|
59
|
+
|
|
60
|
+
// Date/time helpers (all based on local time)
|
|
61
|
+
hour: () => new Date().getHours(),
|
|
62
|
+
minute: () => new Date().getMinutes(),
|
|
63
|
+
second: () => new Date().getSeconds(),
|
|
64
|
+
day: () => new Date().getDay(), // 0=Sun, 1=Mon, ..., 6=Sat
|
|
65
|
+
dayOfMonth: () => new Date().getDate(),
|
|
66
|
+
month: () => new Date().getMonth() + 1, // 1-12
|
|
67
|
+
year: () => new Date().getFullYear(),
|
|
68
|
+
isWeekday: () => { const d = new Date().getDay(); return d >= 1 && d <= 5; },
|
|
69
|
+
isWeekend: () => { const d = new Date().getDay(); return d === 0 || d === 6; },
|
|
70
|
+
hoursBetween: (startHour, endHour) => {
|
|
71
|
+
const h = new Date().getHours();
|
|
72
|
+
return startHour <= endHour
|
|
73
|
+
? h >= startHour && h < endHour
|
|
74
|
+
: h >= startHour || h < endHour; // wraps midnight
|
|
75
|
+
}
|
|
59
76
|
};
|
|
60
77
|
function EventCalcNode(config) {
|
|
61
78
|
RED.nodes.createNode(this, config);
|
|
@@ -146,7 +163,7 @@ module.exports = function(RED) {
|
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
try {
|
|
149
|
-
const allParams = { ...helpers, ...context };
|
|
166
|
+
const allParams = { ...helpers, ...cacheHelpers, ...context };
|
|
150
167
|
const paramNames = Object.keys(allParams);
|
|
151
168
|
const paramValues = Object.values(allParams);
|
|
152
169
|
|
|
@@ -206,6 +223,54 @@ module.exports = function(RED) {
|
|
|
206
223
|
}
|
|
207
224
|
}
|
|
208
225
|
|
|
226
|
+
// Dynamic helpers that need access to cache (created per-node)
|
|
227
|
+
const cacheHelpers = {
|
|
228
|
+
/**
|
|
229
|
+
* now() - Returns current timestamp in milliseconds
|
|
230
|
+
*/
|
|
231
|
+
now: () => Date.now(),
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* hasChanged(varName) - Returns true if the variable's current value
|
|
235
|
+
* differs from its previous value. Returns false on first message.
|
|
236
|
+
*/
|
|
237
|
+
hasChanged: (varName) => {
|
|
238
|
+
const data = latestValues.get(varName);
|
|
239
|
+
if (!data || !data.topic) return false;
|
|
240
|
+
const entry = node.cacheConfig.getValue(data.topic);
|
|
241
|
+
if (!entry || !entry.previous) return false;
|
|
242
|
+
return entry.value !== entry.previous.value;
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* timeSinceLastChange(varName) - Returns milliseconds since the value
|
|
247
|
+
* last changed. If never changed, returns time since first message.
|
|
248
|
+
*/
|
|
249
|
+
timeSinceLastChange: (varName) => {
|
|
250
|
+
const data = latestValues.get(varName);
|
|
251
|
+
if (!data || !data.topic) return 0;
|
|
252
|
+
const entry = node.cacheConfig.getValue(data.topic);
|
|
253
|
+
if (!entry) return 0;
|
|
254
|
+
if (!entry.previous || entry.value === entry.previous.value) {
|
|
255
|
+
// Value hasn't changed - return time since previous timestamp
|
|
256
|
+
// (which is when the last different value was set)
|
|
257
|
+
return Date.now() - (entry.previous ? entry.previous.ts : entry.ts);
|
|
258
|
+
}
|
|
259
|
+
// Value just changed - return time since this update
|
|
260
|
+
return Date.now() - entry.ts;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* prev(varName) - Returns the previous value for a variable
|
|
265
|
+
*/
|
|
266
|
+
prev: (varName) => {
|
|
267
|
+
const data = latestValues.get(varName);
|
|
268
|
+
if (!data || !data.topic) return undefined;
|
|
269
|
+
const prev = node.cacheConfig.getPrevious(data.topic);
|
|
270
|
+
return prev ? prev.value : undefined;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
209
274
|
// Subscribe to inputs
|
|
210
275
|
const latestValues = new Map();
|
|
211
276
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.event-calc-white-text { fill: #ffffff !important; }
|
|
3
|
+
.red-ui-palette-node[data-palette-type="event-flatten"] .red-ui-palette-label { color: #ffffff !important; }
|
|
4
|
+
</style>
|
|
5
|
+
|
|
6
|
+
<script type="text/javascript">
|
|
7
|
+
RED.nodes.registerType('event-flatten', {
|
|
8
|
+
category: 'event calc',
|
|
9
|
+
color: '#758467',
|
|
10
|
+
defaults: {
|
|
11
|
+
name: { value: "" }
|
|
12
|
+
},
|
|
13
|
+
inputs: 1,
|
|
14
|
+
outputs: 1,
|
|
15
|
+
icon: "font-awesome/fa-expand",
|
|
16
|
+
label: function() {
|
|
17
|
+
return this.name || "event flatten";
|
|
18
|
+
},
|
|
19
|
+
paletteLabel: "event flatten",
|
|
20
|
+
labelStyle: function() { return (this.name ? "node_label_italic" : "") + " event-calc-white-text"; }
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script type="text/html" data-template-name="event-flatten">
|
|
25
|
+
<div class="form-row">
|
|
26
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
27
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
28
|
+
</div>
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<script type="text/html" data-help-name="event-flatten">
|
|
32
|
+
<p>Flattens <code>msg.payload</code> into the top-level message object.</p>
|
|
33
|
+
|
|
34
|
+
<h3>Behavior</h3>
|
|
35
|
+
<p>Takes all properties from <code>msg.payload</code> (must be a plain object) and assigns them
|
|
36
|
+
directly onto <code>msg</code>, then removes <code>msg.payload</code>.</p>
|
|
37
|
+
|
|
38
|
+
<h3>Example</h3>
|
|
39
|
+
<pre>
|
|
40
|
+
Input:
|
|
41
|
+
msg.payload = { topic: "sensor/temp", value: 25.5, timestamp: "2026-02-06T..." }
|
|
42
|
+
|
|
43
|
+
Output:
|
|
44
|
+
msg.topic = "sensor/temp"
|
|
45
|
+
msg.value = 25.5
|
|
46
|
+
msg.timestamp = "2026-02-06T..."</pre>
|
|
47
|
+
|
|
48
|
+
<h3>Status</h3>
|
|
49
|
+
<ul>
|
|
50
|
+
<li><b>Green</b> - Successfully flattened, shows number of fields merged</li>
|
|
51
|
+
<li><b>Yellow</b> - Payload was not a plain object, message passed through unchanged</li>
|
|
52
|
+
</ul>
|
|
53
|
+
</script>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function EventFlattenNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
|
|
6
|
+
node.on('input', function(msg, send, done) {
|
|
7
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
8
|
+
done = done || function(err) { if (err) node.error(err, msg); };
|
|
9
|
+
|
|
10
|
+
const payload = msg.payload;
|
|
11
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
12
|
+
delete msg.payload;
|
|
13
|
+
Object.assign(msg, payload);
|
|
14
|
+
node.status({ fill: "green", shape: "dot", text: `${Object.keys(payload).length} fields` });
|
|
15
|
+
} else {
|
|
16
|
+
node.status({ fill: "yellow", shape: "ring", text: "payload not an object" });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
send(msg);
|
|
20
|
+
done();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
RED.nodes.registerType("event-flatten", EventFlattenNode);
|
|
25
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-event-calc",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.6",
|
|
4
4
|
"description": "Node-RED nodes for event caching and calculations",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Holger Amort"
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"event-in": "nodes/event-in.js",
|
|
30
30
|
"event-topic": "nodes/event-topic.js",
|
|
31
31
|
"event-calc": "nodes/event-calc.js",
|
|
32
|
+
"event-flatten": "nodes/event-flatten.js",
|
|
32
33
|
"event-preview": "nodes/event-preview.js",
|
|
33
34
|
"event-simulator": "nodes/event-simulator.js",
|
|
34
35
|
"event-chart": "nodes/event-chart.js"
|
|
Binary file
|
package/nul
DELETED
|
File without changes
|
package/playwright.config.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
const { defineConfig } = require('@playwright/test');
|
|
3
|
-
|
|
4
|
-
module.exports = defineConfig({
|
|
5
|
-
testDir: './tests',
|
|
6
|
-
fullyParallel: false,
|
|
7
|
-
forbidOnly: !!process.env.CI,
|
|
8
|
-
retries: process.env.CI ? 2 : 0,
|
|
9
|
-
workers: 1,
|
|
10
|
-
reporter: 'html',
|
|
11
|
-
use: {
|
|
12
|
-
baseURL: 'http://localhost:1880',
|
|
13
|
-
trace: 'on-first-retry',
|
|
14
|
-
screenshot: 'only-on-failure',
|
|
15
|
-
},
|
|
16
|
-
projects: [
|
|
17
|
-
{
|
|
18
|
-
name: 'chromium',
|
|
19
|
-
use: { browserName: 'chromium' },
|
|
20
|
-
},
|
|
21
|
-
],
|
|
22
|
-
});
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
const { test, expect } = require('@playwright/test');
|
|
3
|
-
|
|
4
|
-
test.describe('Event Calc - External Trigger Feature', () => {
|
|
5
|
-
test.beforeEach(async ({ page }) => {
|
|
6
|
-
// Navigate to Node-RED editor
|
|
7
|
-
await page.goto('/');
|
|
8
|
-
// Wait for Node-RED to load
|
|
9
|
-
await page.waitForSelector('#red-ui-palette', { timeout: 30000 });
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test('should show External Trigger checkbox in event-calc node configuration', async ({ page }) => {
|
|
13
|
-
// Search for the event-calc node in the palette
|
|
14
|
-
const paletteSearch = page.locator('#red-ui-palette-search input');
|
|
15
|
-
await paletteSearch.fill('event calc');
|
|
16
|
-
|
|
17
|
-
// Wait for search results
|
|
18
|
-
await page.waitForTimeout(500);
|
|
19
|
-
|
|
20
|
-
// Find the event-calc node in the palette
|
|
21
|
-
const eventCalcNode = page.locator('.red-ui-palette-node[data-palette-type="event-calc"]');
|
|
22
|
-
await expect(eventCalcNode).toBeVisible();
|
|
23
|
-
|
|
24
|
-
// Drag the node to the workspace
|
|
25
|
-
const workspace = page.locator('#red-ui-workspace-chart');
|
|
26
|
-
await eventCalcNode.dragTo(workspace);
|
|
27
|
-
|
|
28
|
-
// Double-click on the newly created node to open the editor
|
|
29
|
-
// First find the node in the workspace
|
|
30
|
-
const nodeInWorkspace = page.locator('.red-ui-flow-node-group').last();
|
|
31
|
-
await nodeInWorkspace.dblclick();
|
|
32
|
-
|
|
33
|
-
// Wait for the edit dialog to open
|
|
34
|
-
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
35
|
-
|
|
36
|
-
// Verify the External Trigger checkbox exists
|
|
37
|
-
const externalTriggerCheckbox = page.locator('#node-input-externalTrigger');
|
|
38
|
-
await expect(externalTriggerCheckbox).toBeVisible();
|
|
39
|
-
|
|
40
|
-
// Verify it's unchecked by default
|
|
41
|
-
await expect(externalTriggerCheckbox).not.toBeChecked();
|
|
42
|
-
|
|
43
|
-
// Check the checkbox
|
|
44
|
-
await externalTriggerCheckbox.check();
|
|
45
|
-
await expect(externalTriggerCheckbox).toBeChecked();
|
|
46
|
-
|
|
47
|
-
// Verify the label is correct
|
|
48
|
-
const label = page.locator('label[for="node-input-externalTrigger"]');
|
|
49
|
-
await expect(label).toContainText('External Trigger');
|
|
50
|
-
await expect(label).toContainText('calculate on any input message');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('should toggle External Trigger checkbox', async ({ page }) => {
|
|
54
|
-
// Search for the event-calc node
|
|
55
|
-
const paletteSearch = page.locator('#red-ui-palette-search input');
|
|
56
|
-
await paletteSearch.fill('event calc');
|
|
57
|
-
await page.waitForTimeout(500);
|
|
58
|
-
|
|
59
|
-
// Find and drag the node
|
|
60
|
-
const eventCalcNode = page.locator('.red-ui-palette-node[data-palette-type="event-calc"]');
|
|
61
|
-
const workspace = page.locator('#red-ui-workspace-chart');
|
|
62
|
-
await eventCalcNode.dragTo(workspace);
|
|
63
|
-
|
|
64
|
-
// Open the node editor
|
|
65
|
-
const nodeInWorkspace = page.locator('.red-ui-flow-node-group').last();
|
|
66
|
-
await nodeInWorkspace.dblclick();
|
|
67
|
-
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
68
|
-
|
|
69
|
-
const externalTriggerCheckbox = page.locator('#node-input-externalTrigger');
|
|
70
|
-
|
|
71
|
-
// Toggle on
|
|
72
|
-
await externalTriggerCheckbox.check();
|
|
73
|
-
await expect(externalTriggerCheckbox).toBeChecked();
|
|
74
|
-
|
|
75
|
-
// Toggle off
|
|
76
|
-
await externalTriggerCheckbox.uncheck();
|
|
77
|
-
await expect(externalTriggerCheckbox).not.toBeChecked();
|
|
78
|
-
|
|
79
|
-
// Toggle on again
|
|
80
|
-
await externalTriggerCheckbox.check();
|
|
81
|
-
await expect(externalTriggerCheckbox).toBeChecked();
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('should persist External Trigger setting after save', async ({ page }) => {
|
|
85
|
-
// Search for event-cache node first (required config)
|
|
86
|
-
const paletteSearch = page.locator('#red-ui-palette-search input');
|
|
87
|
-
await paletteSearch.fill('event cache');
|
|
88
|
-
await page.waitForTimeout(500);
|
|
89
|
-
|
|
90
|
-
// Add event-cache config node if needed
|
|
91
|
-
const eventCacheNode = page.locator('.red-ui-palette-node[data-palette-type="event-cache"]');
|
|
92
|
-
if (await eventCacheNode.isVisible()) {
|
|
93
|
-
const workspace = page.locator('#red-ui-workspace-chart');
|
|
94
|
-
await eventCacheNode.dragTo(workspace);
|
|
95
|
-
|
|
96
|
-
// Configure the cache node
|
|
97
|
-
const cacheNodeInWorkspace = page.locator('.red-ui-flow-node-group').last();
|
|
98
|
-
await cacheNodeInWorkspace.dblclick();
|
|
99
|
-
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
100
|
-
|
|
101
|
-
// Set a name for the cache
|
|
102
|
-
const nameInput = page.locator('#node-input-name');
|
|
103
|
-
await nameInput.fill('Test Cache');
|
|
104
|
-
|
|
105
|
-
// Save the cache node
|
|
106
|
-
const doneButton = page.locator('.red-ui-tray-footer button').filter({ hasText: 'Done' });
|
|
107
|
-
await doneButton.click();
|
|
108
|
-
await page.waitForTimeout(500);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Now add the event-calc node
|
|
112
|
-
await paletteSearch.clear();
|
|
113
|
-
await paletteSearch.fill('event calc');
|
|
114
|
-
await page.waitForTimeout(500);
|
|
115
|
-
|
|
116
|
-
const eventCalcNode = page.locator('.red-ui-palette-node[data-palette-type="event-calc"]');
|
|
117
|
-
const workspace = page.locator('#red-ui-workspace-chart');
|
|
118
|
-
await eventCalcNode.dragTo(workspace);
|
|
119
|
-
|
|
120
|
-
// Open the node editor
|
|
121
|
-
const calcNodeInWorkspace = page.locator('.red-ui-flow-node-group').last();
|
|
122
|
-
await calcNodeInWorkspace.dblclick();
|
|
123
|
-
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
124
|
-
|
|
125
|
-
// Check the External Trigger checkbox
|
|
126
|
-
const externalTriggerCheckbox = page.locator('#node-input-externalTrigger');
|
|
127
|
-
await externalTriggerCheckbox.check();
|
|
128
|
-
|
|
129
|
-
// Save the node configuration
|
|
130
|
-
const doneButton = page.locator('.red-ui-tray-footer button').filter({ hasText: 'Done' });
|
|
131
|
-
await doneButton.click();
|
|
132
|
-
await page.waitForTimeout(500);
|
|
133
|
-
|
|
134
|
-
// Reopen the node editor
|
|
135
|
-
await calcNodeInWorkspace.dblclick();
|
|
136
|
-
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
137
|
-
|
|
138
|
-
// Verify the checkbox is still checked
|
|
139
|
-
await expect(externalTriggerCheckbox).toBeChecked();
|
|
140
|
-
});
|
|
141
|
-
});
|