@bldgblocks/node-red-contrib-control 0.1.33 → 0.1.36

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.
Files changed (113) hide show
  1. package/nodes/accumulate-block.html +18 -8
  2. package/nodes/accumulate-block.js +39 -44
  3. package/nodes/add-block.html +1 -1
  4. package/nodes/add-block.js +18 -11
  5. package/nodes/alarm-collector.html +260 -0
  6. package/nodes/alarm-collector.js +292 -0
  7. package/nodes/alarm-config.html +129 -0
  8. package/nodes/alarm-config.js +126 -0
  9. package/nodes/alarm-service.html +96 -0
  10. package/nodes/alarm-service.js +142 -0
  11. package/nodes/analog-switch-block.js +25 -36
  12. package/nodes/and-block.js +44 -15
  13. package/nodes/average-block.js +46 -41
  14. package/nodes/boolean-switch-block.js +10 -28
  15. package/nodes/boolean-to-number-block.html +18 -5
  16. package/nodes/boolean-to-number-block.js +24 -16
  17. package/nodes/cache-block.js +24 -37
  18. package/nodes/call-status-block.html +91 -32
  19. package/nodes/call-status-block.js +398 -115
  20. package/nodes/changeover-block.html +5 -0
  21. package/nodes/changeover-block.js +167 -162
  22. package/nodes/comment-block.html +1 -1
  23. package/nodes/comment-block.js +14 -9
  24. package/nodes/compare-block.html +14 -4
  25. package/nodes/compare-block.js +23 -18
  26. package/nodes/contextual-label-block.html +5 -0
  27. package/nodes/contextual-label-block.js +6 -16
  28. package/nodes/convert-block.html +25 -39
  29. package/nodes/convert-block.js +31 -16
  30. package/nodes/count-block.html +11 -5
  31. package/nodes/count-block.js +34 -32
  32. package/nodes/delay-block.js +58 -53
  33. package/nodes/divide-block.js +43 -45
  34. package/nodes/edge-block.html +17 -10
  35. package/nodes/edge-block.js +43 -41
  36. package/nodes/enum-switch-block.js +6 -6
  37. package/nodes/frequency-block.html +6 -1
  38. package/nodes/frequency-block.js +64 -74
  39. package/nodes/global-getter.html +51 -15
  40. package/nodes/global-getter.js +74 -67
  41. package/nodes/global-setter.html +1 -1
  42. package/nodes/global-setter.js +168 -188
  43. package/nodes/history-buffer.html +96 -0
  44. package/nodes/history-buffer.js +461 -0
  45. package/nodes/history-collector.html +29 -1
  46. package/nodes/history-collector.js +37 -16
  47. package/nodes/history-config.html +13 -1
  48. package/nodes/history-service.html +84 -0
  49. package/nodes/history-service.js +52 -0
  50. package/nodes/hysteresis-block.html +5 -0
  51. package/nodes/hysteresis-block.js +13 -16
  52. package/nodes/interpolate-block.html +20 -2
  53. package/nodes/interpolate-block.js +39 -50
  54. package/nodes/join.html +78 -0
  55. package/nodes/join.js +78 -0
  56. package/nodes/latch-block.js +12 -14
  57. package/nodes/load-sequence-block.js +102 -110
  58. package/nodes/max-block.js +26 -26
  59. package/nodes/memory-block.js +57 -58
  60. package/nodes/min-block.js +26 -25
  61. package/nodes/minmax-block.js +35 -34
  62. package/nodes/modulo-block.js +45 -43
  63. package/nodes/multiply-block.js +43 -41
  64. package/nodes/negate-block.html +17 -7
  65. package/nodes/negate-block.js +25 -19
  66. package/nodes/network-point-read.html +128 -0
  67. package/nodes/network-point-read.js +230 -0
  68. package/nodes/{network-register.html → network-point-register.html} +94 -7
  69. package/nodes/network-point-register.js +126 -0
  70. package/nodes/network-point-write.html +149 -0
  71. package/nodes/network-point-write.js +222 -0
  72. package/nodes/network-service-bridge.html +131 -0
  73. package/nodes/network-service-bridge.js +376 -0
  74. package/nodes/network-service-read.html +81 -0
  75. package/nodes/network-service-read.js +58 -0
  76. package/nodes/{network-point-registry.html → network-service-registry.html} +19 -4
  77. package/nodes/{network-point-registry.js → network-service-registry.js} +7 -2
  78. package/nodes/network-service-write.html +89 -0
  79. package/nodes/network-service-write.js +83 -0
  80. package/nodes/nullify-block.js +13 -15
  81. package/nodes/on-change-block.html +17 -9
  82. package/nodes/on-change-block.js +49 -46
  83. package/nodes/oneshot-block.html +13 -10
  84. package/nodes/oneshot-block.js +57 -75
  85. package/nodes/or-block.js +44 -15
  86. package/nodes/pid-block.html +54 -4
  87. package/nodes/pid-block.js +459 -248
  88. package/nodes/priority-block.js +24 -35
  89. package/nodes/rate-limit-block.js +70 -72
  90. package/nodes/rate-of-change-block.html +33 -14
  91. package/nodes/rate-of-change-block.js +74 -62
  92. package/nodes/round-block.html +14 -9
  93. package/nodes/round-block.js +32 -25
  94. package/nodes/saw-tooth-wave-block.js +49 -76
  95. package/nodes/scale-range-block.html +12 -6
  96. package/nodes/scale-range-block.js +46 -39
  97. package/nodes/sine-wave-block.js +49 -57
  98. package/nodes/string-builder-block.js +6 -6
  99. package/nodes/subtract-block.js +38 -34
  100. package/nodes/thermistor-block.js +44 -44
  101. package/nodes/tick-tock-block.js +32 -32
  102. package/nodes/time-sequence-block.js +30 -42
  103. package/nodes/triangle-wave-block.js +49 -69
  104. package/nodes/tstat-block.js +34 -44
  105. package/nodes/units-block.html +90 -69
  106. package/nodes/units-block.js +22 -30
  107. package/nodes/utils.js +275 -3
  108. package/package.json +14 -6
  109. package/nodes/network-read.html +0 -56
  110. package/nodes/network-read.js +0 -59
  111. package/nodes/network-register.js +0 -161
  112. package/nodes/network-write.html +0 -64
  113. package/nodes/network-write.js +0 -126
@@ -0,0 +1,461 @@
1
+ module.exports = function(RED) {
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const readline = require('readline');
5
+ const utils = require('./utils')(RED);
6
+
7
+ function HistoryBufferNode(config) {
8
+ RED.nodes.createNode(this, config);
9
+ const node = this;
10
+
11
+ // Configuration
12
+ node.name = config.name;
13
+ node.bufferHours = parseFloat(config.bufferHours) || 3;
14
+
15
+ // Validate configuration
16
+ if (isNaN(node.bufferHours) || node.bufferHours < 0.01 || node.bufferHours > 24) {
17
+ node.bufferHours = 3;
18
+ }
19
+
20
+ // Constants
21
+ const TRENDS_DIR = getTrendsDirPath();
22
+ // Use node ID in filename to prevent collisions between multiple history nodes
23
+ const BUFFER_FILE = path.join(TRENDS_DIR, `buffer_${node.id}.json`);
24
+ // Legacy file path for migration
25
+ const LEGACY_BUFFER_FILE = path.join(TRENDS_DIR, 'buffer_current.json');
26
+
27
+ const COMMIT_INTERVAL_MS = 30 * 1000; // 30 seconds
28
+ const PRUNE_INTERVAL_MS = 60 * 1000; // 60 seconds
29
+ const STARTUP_DELAY_MS = 5000; // 5 seconds before loading history
30
+ const MAX_BUFFER_AGE_MS = node.bufferHours * 3600 * 1000;
31
+
32
+ // State
33
+ let liveBuffer = []; // Accumulate points since last commit to BUFFER_FILE
34
+ let commitTimer = null;
35
+ let pruneTimer = null;
36
+ let messageCount = 0;
37
+ let cachedChunkCount = 0; // Cached count of historical files
38
+ let isInitializing = true; // Flag: initialization in progress
39
+ let queuedMessages = []; // Queue messages during initialization
40
+
41
+ // Status throttling
42
+ let lastStatusUpdate = 0;
43
+
44
+ utils.setStatusOK(node, `ready, buffer: ${node.bufferHours}h`);
45
+
46
+ // =====================================================================
47
+ // Directory Helper
48
+ // =====================================================================
49
+ function getTrendsDirPath() {
50
+ const userDir = RED.settings.userDir ||
51
+ process.env.NODE_RED_HOME ||
52
+ path.join(require('os').homedir(), '.node-red');
53
+ return path.join(userDir, '.bldgblocks', 'trends');
54
+ }
55
+
56
+ // =====================================================================
57
+ // Initialize from files on startup (Async/Streaming)
58
+ // =====================================================================
59
+ async function initializeFromFiles() {
60
+ // 1. Ensure directory exists
61
+ try {
62
+ await fs.promises.mkdir(TRENDS_DIR, { recursive: true });
63
+ } catch (err) {
64
+ node.error(`Failed to create trends directory: ${err.message}`);
65
+ return;
66
+ }
67
+
68
+ // 2. Rotate/Migrate buffers
69
+ // Only rotate if they are "stale" (from a previous hour), otherwise append to them.
70
+ await rotateStartupBuffers();
71
+
72
+ // 3. Find and filter valid trend files
73
+ const historicalFiles = await getHistoricalFiles();
74
+
75
+ const validFiles = historicalFiles.filter(fileName => {
76
+ // Filename format: trend_TIMESTAMP_NODEID.json
77
+ // We split by '_' and take index 1.
78
+ const parts = fileName.split('_');
79
+ const timestamp = parseInt(parts[1]);
80
+ if (isNaN(timestamp)) return false;
81
+
82
+ // Simple check: is the file from within our retention window?
83
+ // Timestamp in filename is the time of rotation (end of that file's period)
84
+ const ageMs = Date.now() - (timestamp * 1000);
85
+ return ageMs <= MAX_BUFFER_AGE_MS + (3600 * 1000); // Add 1h grace period
86
+ });
87
+
88
+ updateStatus(`loading ${validFiles.length} files...`, true);
89
+
90
+ // 4. Stream load all data into memory for the chart
91
+ // We load into a single array because the Chart node needs a 'replace' action with full dataset.
92
+ // Streaming happens file-by-line to avoid loading full file string content into RAM.
93
+ let allHistory = [];
94
+
95
+ // 4a. Load Trend Files
96
+ for (let i = 0; i < validFiles.length; i++) {
97
+ const filePath = path.join(TRENDS_DIR, validFiles[i]);
98
+ try {
99
+ await streamLoadFile(filePath, allHistory);
100
+ } catch (err) {
101
+ node.warn(`Failed to process ${validFiles[i]}: ${err.message}`);
102
+ }
103
+
104
+ if (i % 2 === 0) updateStatus(`loading ${i + 1}/${validFiles.length}...`);
105
+ }
106
+
107
+
108
+ // 4b. Load Active Buffer (if it wasn't rotated)
109
+ try {
110
+ await fs.promises.access(BUFFER_FILE);
111
+ await streamLoadFile(BUFFER_FILE, allHistory);
112
+ } catch (err) {
113
+ // No active buffer, that's fine
114
+ }
115
+
116
+ // 5. Finalize setup
117
+ finalizeAndSend(allHistory, validFiles.length);
118
+ }
119
+
120
+ async function rotateStartupBuffers() {
121
+ const now = new Date();
122
+ const currentTimestamp = Math.floor(now.getTime() / 1000);
123
+
124
+ // Check for this node's specific buffer
125
+ try {
126
+ const stats = await fs.promises.stat(BUFFER_FILE);
127
+ const fileModifiedTime = new Date(stats.mtime);
128
+
129
+ // Rotation Logic:
130
+ // If the file is from a different hour than NOW, it is "stale" and should be rotated.
131
+ // If it is from current hour, keep it active (resume appending).
132
+ // This prevents creating many small files on repeated reboots.
133
+ const isStale = (fileModifiedTime.getHours() !== now.getHours()) ||
134
+ (now.getTime() - stats.mtimeMs > 3600 * 1000 * 1.5); // Safety: > 1.5h old
135
+
136
+ if (isStale) {
137
+ const fileTs = Math.floor(stats.mtimeMs / 1000);
138
+ const newName = path.join(TRENDS_DIR, `trend_${fileTs}_${node.id}.json`);
139
+ await fs.promises.rename(BUFFER_FILE, newName);
140
+ }
141
+
142
+ } catch (err) {
143
+ // File likely doesn't exist, ignore
144
+ }
145
+
146
+ // Check for legacy buffer (migration)
147
+ try {
148
+ await fs.promises.access(LEGACY_BUFFER_FILE);
149
+ const legacyName = path.join(TRENDS_DIR, `trend_${currentTimestamp}_legacy.json`);
150
+ await fs.promises.rename(LEGACY_BUFFER_FILE, legacyName);
151
+ } catch (err) {
152
+ // Ignore
153
+ }
154
+ }
155
+
156
+ async function getHistoricalFiles() {
157
+ try {
158
+ const files = await fs.promises.readdir(TRENDS_DIR);
159
+ // Filter for our files: trend_TIMESTAMP_*.json
160
+ return files
161
+ .filter(f => f.startsWith('trend_') && f.endsWith('.json'))
162
+ .sort(); // String sort works for fixed-length timestamps usually, but numeric would be safer
163
+ } catch (err) {
164
+ return [];
165
+ }
166
+ }
167
+
168
+ function streamLoadFile(filePath, accumulatorArray) {
169
+ return new Promise((resolve, reject) => {
170
+ const fileStream = fs.createReadStream(filePath);
171
+
172
+ fileStream.on('error', (err) => {
173
+ // Log the specific error before resolving
174
+ node.warn(`Stream read error for ${path.basename(filePath)}: ${err.message}`);
175
+ resolve();
176
+ });
177
+
178
+ const rl = readline.createInterface({
179
+ input: fileStream,
180
+ crlfDelay: Infinity
181
+ });
182
+
183
+ rl.on('line', (line) => {
184
+ if (!line.trim()) return;
185
+ try {
186
+ const pt = JSON.parse(line);
187
+ // Optional: we could validate timestamp here if needed
188
+ accumulatorArray.push(pt);
189
+ } catch (err) {
190
+ // Skip malformed lines
191
+ }
192
+ });
193
+
194
+ rl.on('close', () => {
195
+ resolve();
196
+ });
197
+ });
198
+ }
199
+
200
+ function finalizeAndSend(allHistory, fileCount) {
201
+ if (allHistory.length > 0) {
202
+ // Send history replace message
203
+ node.send({
204
+ topic: config.topic || node.name || 'history',
205
+ payload: allHistory,
206
+ action: 'replace'
207
+ });
208
+ }
209
+
210
+ isInitializing = false;
211
+ cachedChunkCount = fileCount;
212
+
213
+ startCommitTimer();
214
+ startPruneTimer();
215
+
216
+ // Dump queue
217
+ if (queuedMessages.length > 0) {
218
+ const toProcess = queuedMessages.splice(0, queuedMessages.length);
219
+
220
+ toProcess.forEach(qMsg => {
221
+ liveBuffer.push(qMsg);
222
+ node.send({
223
+ topic: qMsg.topic,
224
+ payload: {
225
+ topic: qMsg.topic,
226
+ payload: qMsg.payload,
227
+ ts: qMsg.ts
228
+ },
229
+ action: 'append'
230
+ });
231
+ });
232
+ }
233
+
234
+ updateStatus(`${messageCount} msgs, ${cachedChunkCount} chunks, buf: ${liveBuffer.length}`, true);
235
+ }
236
+
237
+ // =====================================================================
238
+ // Timers & File Management
239
+ // =====================================================================
240
+ function startCommitTimer() {
241
+ if (commitTimer) clearInterval(commitTimer);
242
+
243
+ commitTimer = setInterval(async () => {
244
+ // Yield to rotation or empty buffer
245
+ if (liveBuffer.length === 0 || node.isRotating) return;
246
+
247
+ // Take control of current points
248
+ const pointsToCommit = liveBuffer;
249
+ // Reset live buffer immediately so new messages go into next batch
250
+ liveBuffer = [];
251
+
252
+ const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n');
253
+
254
+ try {
255
+ // Only add prefix newline if file exists and has content (simplified check)
256
+ // We'll let the next append handle its own newline or just ensure we join efficiently.
257
+ // Actually, safest is to append WITH a preceding newline if file exists.
258
+
259
+ let prefix = '';
260
+ try {
261
+ const stats = await fs.promises.stat(BUFFER_FILE);
262
+ if (stats.size > 0) prefix = '\n';
263
+ } catch(e) { /* File doesn't exist, no prefix needed */ }
264
+
265
+ await fs.promises.appendFile(BUFFER_FILE, prefix + lines);
266
+ } catch (err) {
267
+ node.warn(`Buffer commit failed: ${err.message}`);
268
+ // Put points back at the start of buffer if write failed
269
+ // Use concat to avoid stack overflow with large arrays
270
+ liveBuffer = pointsToCommit.concat(liveBuffer);
271
+ }
272
+ }, COMMIT_INTERVAL_MS);
273
+ }
274
+
275
+ function startPruneTimer() {
276
+ if (pruneTimer) clearInterval(pruneTimer);
277
+ pruneTimer = setInterval(() => pruneOldChunks(), PRUNE_INTERVAL_MS);
278
+ }
279
+
280
+ async function pruneOldChunks() {
281
+ const files = await getHistoricalFiles();
282
+ const now = Date.now();
283
+
284
+ for (const file of files) {
285
+ const parts = file.split('_');
286
+ const timestamp = parseInt(parts[1]);
287
+ if (isNaN(timestamp)) continue;
288
+
289
+ // Age check
290
+ const ageMs = now - (timestamp * 1000);
291
+ if (ageMs > MAX_BUFFER_AGE_MS) {
292
+ try {
293
+ await fs.promises.unlink(path.join(TRENDS_DIR, file));
294
+ cachedChunkCount = Math.max(0, cachedChunkCount - 1);
295
+ } catch (err) {
296
+ node.warn(`Prune failed for ${file}: ${err.message}`);
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ async function checkHourBoundary() {
303
+ const currentHour = new Date().getHours();
304
+
305
+ if (node.lastRotationHour === undefined) {
306
+ node.lastRotationHour = currentHour;
307
+ return;
308
+ }
309
+
310
+ if (currentHour !== node.lastRotationHour) {
311
+ // Prevent race condition:
312
+ // If multiple msgs arrive while rotating, ignore them.
313
+ if (node.isRotating) return;
314
+
315
+ node.isRotating = true; // Lock
316
+
317
+ try {
318
+ await rotateBuffer();
319
+ // Update hour only on success to allow retry if it fails
320
+ node.lastRotationHour = currentHour;
321
+ } catch (err) {
322
+ node.warn(`Rotation failed: ${err.message}`);
323
+ } finally {
324
+ node.isRotating = false; // Unlock
325
+ }
326
+ }
327
+ }
328
+
329
+ async function rotateBuffer() {
330
+ // Force commit of anything pending in memory before rotation
331
+ // Note: In this architecture, liveBuffer is usually empty due to commit timer,
332
+ // but might have recent points.
333
+ // We just append to file, THEN rename.
334
+
335
+ if (liveBuffer.length > 0) {
336
+ const pointsToCommit = liveBuffer;
337
+ liveBuffer = []; // Clear memory
338
+ const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n');
339
+ try {
340
+ let prefix = '';
341
+ try {
342
+ const stats = await fs.promises.stat(BUFFER_FILE);
343
+ if (stats.size > 0) prefix = '\n';
344
+ } catch(e) { /* File doesn't exist */ }
345
+
346
+ await fs.promises.appendFile(BUFFER_FILE, prefix + lines);
347
+ } catch (err) {
348
+ // If append fails, we might lose these points during rotation,
349
+ // put them back and abort rotation. Use concat for safety.
350
+ liveBuffer = pointsToCommit.concat(liveBuffer);
351
+ node.warn(`Rotation aborted, append failed: ${err.message}`);
352
+ return;
353
+ }
354
+ }
355
+
356
+ // Perform Rotation (Rename)
357
+ const timestamp = Math.floor(Date.now() / 1000);
358
+ const newName = path.join(TRENDS_DIR, `trend_${timestamp}_${node.id}.json`);
359
+
360
+ try {
361
+ await fs.promises.access(BUFFER_FILE);
362
+ await fs.promises.rename(BUFFER_FILE, newName);
363
+ cachedChunkCount++;
364
+ } catch (err) {
365
+ // BUFFER_FILE doesn't exist - no data to rotate
366
+ }
367
+ }
368
+
369
+ function updateStatus(text, force) {
370
+ const now = Date.now();
371
+ if (force || (now - lastStatusUpdate > 1000)) {
372
+ utils.setStatusChanged(node, text);
373
+ lastStatusUpdate = now;
374
+ }
375
+ }
376
+
377
+ // =====================================================================
378
+ // Message Handler
379
+ // =====================================================================
380
+ node.on('input', function(msg, send, done) {
381
+ send = send || function() { node.send.apply(node, arguments); };
382
+
383
+ if (!msg || !msg.hasOwnProperty('payload')) {
384
+ if (done) done();
385
+ return;
386
+ }
387
+
388
+ // Initialization Queue
389
+ if (isInitializing) {
390
+ queuedMessages.push({
391
+ topic: msg.topic,
392
+ payload: msg.payload,
393
+ ts: msg.ts || Date.now()
394
+ });
395
+ if (done) done();
396
+ return;
397
+ }
398
+
399
+ // Check if we need to rotate files
400
+ checkHourBoundary();
401
+
402
+ // Process Message
403
+ const ts = msg.ts || Date.now();
404
+
405
+ // Add to in-memory buffer for the next commit cycle
406
+ liveBuffer.push({
407
+ topic: msg.topic,
408
+ payload: msg.payload,
409
+ ts: ts
410
+ });
411
+
412
+ // Pass through to chart immediately
413
+ send({
414
+ topic: msg.topic,
415
+ payload: {
416
+ topic: msg.topic,
417
+ payload: msg.payload,
418
+ ts: ts
419
+ },
420
+ action: 'append'
421
+ });
422
+
423
+ messageCount++;
424
+
425
+ // Status update (throttled internally to 1s)
426
+ updateStatus(`${messageCount} msgs, ${cachedChunkCount} chunks, buf: ${liveBuffer.length}`, messageCount === 1);
427
+
428
+ if (done) done();
429
+ });
430
+
431
+ // =====================================================================
432
+ // Shutdown
433
+ // =====================================================================
434
+ node.on('close', function(done) {
435
+ if (commitTimer) clearInterval(commitTimer);
436
+ if (pruneTimer) clearInterval(pruneTimer);
437
+
438
+ // Attempt one final sync save if data exists
439
+ if (liveBuffer.length > 0) {
440
+ const lines = liveBuffer.map(p => JSON.stringify(p)).join('\n') + '\n';
441
+ try {
442
+ // We must use Sync here because Node-RED close can lead to process exit
443
+ // before async callbacks fire.
444
+ fs.appendFileSync(BUFFER_FILE, lines);
445
+ } catch (e) {
446
+ // ignore
447
+ }
448
+ }
449
+
450
+ utils.setStatusOK(node, 'closed');
451
+ done();
452
+ });
453
+
454
+ // Start
455
+ setTimeout(() => {
456
+ initializeFromFiles();
457
+ }, STARTUP_DELAY_MS);
458
+ }
459
+
460
+ RED.nodes.registerType("history-buffer", HistoryBufferNode);
461
+ };
@@ -11,6 +11,11 @@
11
11
  <label for="node-input-seriesName" title="Specify the measurement series name"><i class="fa fa-list"></i> Series</label>
12
12
  <input type="text" id="node-input-seriesName">
13
13
  </div>
14
+ <div class="form-row">
15
+ <label for="node-input-inputProperty" title="Source for value extraction (msg property or jsonata expression)"><i class="fa fa-folder-open"></i> Input Value</label>
16
+ <input type="text" id="node-input-inputProperty" placeholder="payload">
17
+ <input type="hidden" id="node-input-inputPropertyType">
18
+ </div>
14
19
  <div class="form-row">
15
20
  <label for="node-input-tags"><i class="fa fa-tags"></i> Tags</label>
16
21
  <input type="hidden" id="node-input-tags">
@@ -49,12 +54,14 @@
49
54
  defaults: {
50
55
  historyConfig: { value: "", type: "history-config", required: true },
51
56
  seriesName: { value: "", required: true },
57
+ inputProperty: { value: "payload" },
58
+ inputPropertyType: { value: "msg" },
52
59
  tags: { value: "" },
53
60
  storageType: { value: "batchObject" },
54
61
  name: { value: "" }
55
62
  },
56
63
  inputs: 1,
57
- outputs: 1,
64
+ outputs: 0,
58
65
  icon: "file.png",
59
66
  paletteLabel: "history collector",
60
67
  label: function() {
@@ -67,6 +74,17 @@
67
74
  const tagContainer = $("#node-input-tag-container");
68
75
  const hiddenTagInput = $("#node-input-tags");
69
76
 
77
+ // Initialize typed input for input property (msg or jsonata)
78
+ try {
79
+ $("#node-input-inputProperty").typedInput({
80
+ default: "msg",
81
+ types: ["msg", "jsonata"],
82
+ typeField: "#node-input-inputPropertyType"
83
+ }).typedInput("type", node.inputPropertyType || "msg").typedInput("value", node.inputProperty);
84
+ } catch (err) {
85
+ console.error("Error initializing inputProperty typedInput:", err);
86
+ }
87
+
70
88
  // Initialize empty typedInputs first
71
89
  seriesInput.typedInput({
72
90
  types: [{ value: "series", options: [] }]
@@ -147,7 +165,10 @@
147
165
  },
148
166
  oneditsave: function() {
149
167
  const seriesInput = $("#node-input-seriesName");
168
+ const inputProperty = $("#node-input-inputProperty");
150
169
  this.seriesName = seriesInput.typedInput('value');
170
+ this.inputProperty = inputProperty.typedInput('value');
171
+ this.inputPropertyType = inputProperty.typedInput('type');
151
172
  this.storageType = $("#node-input-storageType").val();
152
173
  // tags are already in the hidden input thanks to the checkbox change handler
153
174
  }
@@ -275,6 +296,13 @@ The node supports five `storageType` options, each producing a unique output for
275
296
  - Timestamp: Generated as nanoseconds (ms * 1e6) for InfluxDB v2 precision.
276
297
  - Error Handling: Invalid configurations or payloads trigger warnings and status updates (red ring).
277
298
 
299
+ ### Status
300
+ - Green (dot): Configuration update
301
+ - Blue (dot): Data collected successfully
302
+ - Blue (ring): No state change
303
+ - Red (ring): Configuration error or invalid payload
304
+ - Yellow (ring): Warning (validation issue)
305
+
278
306
  ### References
279
307
  - [Node-RED Documentation](https://nodered.org/docs/)
280
308
  - [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control)
@@ -1,10 +1,14 @@
1
1
  module.exports = function(RED) {
2
+ const utils = require("./utils")(RED);
3
+
2
4
  function HistoryCollectorNode(config) {
3
5
  RED.nodes.createNode(this, config);
4
6
  this.historyConfig = RED.nodes.getNode(config.historyConfig);
5
7
  this.seriesName = config.seriesName;
6
8
  this.storageType = config.storageType || 'memory';
7
9
  this.tags = config.tags || '';
10
+ this.inputProperty = config.inputProperty || "payload";
11
+ this.inputPropertyType = config.inputPropertyType || "msg";
8
12
  const node = this;
9
13
 
10
14
  // Parse tags into key-value object
@@ -24,33 +28,48 @@ module.exports = function(RED) {
24
28
  return tags;
25
29
  }
26
30
 
27
- node.on('input', function(msg) {
31
+ node.on('input', async function(msg, send, done) {
32
+ send = send || function() { node.send.apply(node, arguments); };
33
+
28
34
  // Guard against invalid message
29
35
  if (!msg) {
30
- node.status({ fill: "red", shape: "ring", text: "invalid message" });
36
+ utils.setStatusError(node, "invalid message");
31
37
  node.error('Invalid message received');
38
+ if (done) done();
32
39
  return;
33
40
  }
34
41
 
35
42
  // Validate configuration
36
43
  if (!node.historyConfig) {
37
- node.status({ fill: "red", shape: "ring", text: "missing history config" });
44
+ utils.setStatusError(node, "missing history config");
38
45
  node.error('Missing history configuration', msg);
46
+ if (done) done();
39
47
  return;
40
48
  }
41
49
  if (!node.seriesName) {
42
- node.status({ fill: "red", shape: "ring", text: "missing series name" });
50
+ utils.setStatusError(node, "missing series name");
43
51
  node.error('Missing series name', msg);
52
+ if (done) done();
44
53
  return;
45
54
  }
46
55
  if (!node.historyConfig.name) {
47
- node.status({ fill: "red", shape: "ring", text: "missing bucket name" });
56
+ utils.setStatusError(node, "missing bucket name");
48
57
  node.error('Missing bucket name in history configuration', msg);
58
+ if (done) done();
59
+ return;
60
+ }
61
+
62
+ // Evaluate input property (msg or jsonata)
63
+ let payloadValue;
64
+ try {
65
+ payloadValue = await utils.evaluateNodeProperty(node.inputProperty, node.inputPropertyType, node, msg);
66
+ } catch (err) {
67
+ utils.setStatusError(node, "input evaluation error");
68
+ if (done) done();
49
69
  return;
50
70
  }
51
71
 
52
72
  // Validate payload
53
- let payloadValue = msg.payload;
54
73
  let formattedValue;
55
74
  if (typeof payloadValue === 'number') {
56
75
  formattedValue = isNaN(payloadValue) ? null : payloadValue;
@@ -62,14 +81,14 @@ module.exports = function(RED) {
62
81
  formattedValue = parseInt(payloadValue); // Handle InfluxDB integer format
63
82
  }
64
83
  } else {
65
- node.status({ fill: "red", shape: "ring", text: "invalid payload" });
66
- node.warn(`Invalid payload type: ${typeof payloadValue}`);
84
+ utils.setStatusError(node, "invalid payload");
85
+ if (done) done();
67
86
  return;
68
87
  }
69
88
 
70
89
  if (formattedValue === null) {
71
- node.status({ fill: "red", shape: "ring", text: "invalid payload" });
72
- node.warn(`Invalid payload value: ${msg.payload}`);
90
+ utils.setStatusError(node, "invalid payload");
91
+ if (done) done();
73
92
  return;
74
93
  }
75
94
 
@@ -85,7 +104,7 @@ module.exports = function(RED) {
85
104
  const line = `${escapedMeasurementName}${tagsString ? ',' + tagsString : ''} value=${valueString} ${timestamp}`;
86
105
 
87
106
  // Set initial status
88
- node.status({ fill: "green", shape: "dot", text: "configuration received" });
107
+ utils.setStatusOK(node, "configuration received");
89
108
 
90
109
  // Handle storage type
91
110
  if (node.storageType === 'memory') {
@@ -101,12 +120,12 @@ module.exports = function(RED) {
101
120
  }
102
121
 
103
122
  node.context().global.set(contextKey, bucketData);
104
- node.status({ fill: "blue", shape: "dot", text: `stored: ${valueString}` });
123
+ utils.setStatusChanged(node, `stored: ${valueString}`);
105
124
  } else if (node.storageType === 'lineProtocol') {
106
125
  msg.measurement = escapedMeasurementName;
107
126
  msg.payload = line;
108
127
  node.send(msg);
109
- node.status({ fill: "blue", shape: "dot", text: `sent: ${valueString}` });
128
+ utils.setStatusChanged(node, `sent: ${valueString}`);
110
129
  } else if (node.storageType === 'object') {
111
130
  msg.measurement = escapedMeasurementName;
112
131
  msg.payload = {
@@ -116,7 +135,7 @@ module.exports = function(RED) {
116
135
  timestamp: timestamp
117
136
  };
118
137
  node.send(msg);
119
- node.status({ fill: "blue", shape: "dot", text: `sent: ${valueString}` });
138
+ utils.setStatusChanged(node, `sent: ${valueString}`);
120
139
  } else if (node.storageType === 'objectArray') {
121
140
  msg.measurement = escapedMeasurementName;
122
141
  msg.timestamp = timestamp;
@@ -127,7 +146,7 @@ module.exports = function(RED) {
127
146
  tagsObj
128
147
  ]
129
148
  node.send(msg);
130
- node.status({ fill: "blue", shape: "dot", text: `sent: ${valueString}` });
149
+ utils.setStatusChanged(node, `sent: ${valueString}`);
131
150
  } else if (node.storageType === 'batchObject') {
132
151
  msg.payload = {
133
152
  measurement: escapedMeasurementName,
@@ -138,8 +157,10 @@ module.exports = function(RED) {
138
157
  tags: tagsObj
139
158
  }
140
159
  node.send(msg);
141
- node.status({ fill: "blue", shape: "dot", text: `sent: ${valueString}` });
160
+ utils.setStatusChanged(node, `sent: ${valueString}`);
142
161
  }
162
+
163
+ if (done) done();
143
164
  });
144
165
 
145
166
  node.on("close", function(done) {