node-red-contrib-event-calc 2.0.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 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
+ ]
@@ -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
@@ -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
 
@@ -162,9 +179,8 @@ module.exports = function(RED) {
162
179
  missingInputs: missingInputs,
163
180
  expression: node.expression
164
181
  },
165
- inputs: inputDetails,
166
182
  trigger: triggerTopic,
167
- timestamp: triggerTs
183
+ ts: triggerTs
168
184
  };
169
185
  node.send([null, errorMsg]);
170
186
  node.status({ fill: "yellow", shape: "ring", text: "NaN" });
@@ -174,12 +190,9 @@ module.exports = function(RED) {
174
190
  const msg = {
175
191
  topic: node.outputTopic,
176
192
  payload: result,
177
- topics: topics,
178
- inputs: inputDetails,
179
- timestamps: timestamps,
180
193
  expression: node.expression,
181
194
  trigger: triggerTopic,
182
- timestamp: triggerTs
195
+ ts: triggerTs
183
196
  };
184
197
 
185
198
  node.send([msg, null]);
@@ -202,15 +215,62 @@ module.exports = function(RED) {
202
215
  expression: node.expression,
203
216
  context: context
204
217
  },
205
- inputs: inputDetails,
206
218
  trigger: triggerTopic,
207
- timestamp: triggerTs
219
+ ts: triggerTs
208
220
  };
209
221
  node.send([null, errorMsg]);
210
222
  node.status({ fill: "red", shape: "ring", text: "eval error" });
211
223
  }
212
224
  }
213
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
+
214
274
  // Subscribe to inputs
215
275
  const latestValues = new Map();
216
276