@bldgblocks/node-red-contrib-control 0.1.34 → 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 (110) 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 +43 -13
  41. package/nodes/global-setter.html +1 -1
  42. package/nodes/global-setter.js +40 -12
  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-register.js → network-point-register.js} +18 -4
  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-read.js → network-service-read.js} +4 -3
  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-write.js → network-service-write.js} +3 -3
  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 +206 -3
  108. package/package.json +14 -6
  109. package/nodes/network-read.html +0 -56
  110. package/nodes/network-write.html +0 -65
@@ -41,7 +41,7 @@ module.exports = function(RED) {
41
41
  // This runs in background immediately after deployment
42
42
  (async function initialize() {
43
43
  if (!node.varName) {
44
- node.status({ fill: "red", shape: "ring", text: "no variable defined" });
44
+ utils.setStatusError(node, "no variable defined");
45
45
  return;
46
46
  }
47
47
  try {
@@ -49,14 +49,29 @@ module.exports = function(RED) {
49
49
  let state = await utils.getGlobalState(node, node.varName, node.storeName);
50
50
  if (!state || typeof state !== 'object' || !state.priority) {
51
51
  // If not, set default
52
- const newState = buildDefaultState();
53
- await utils.setGlobalState(node, node.varName, node.storeName, newState);
54
- node.status({ fill: "grey", shape: "dot", text: `initialized: default:${node.defaultValue}` });
52
+ state = buildDefaultState();
53
+ await utils.setGlobalState(node, node.varName, node.storeName, state);
54
+ utils.setStatusOK(node, `initialized: default:${node.defaultValue}`);
55
+ } else {
56
+ utils.setStatusOK(node, `loaded: ${state.value}`);
55
57
  }
58
+
59
+ // Send properly formed state object downstream after full initialization
60
+ // Allows network-register and other downstream nodes to register on startup
61
+ // Use setTimeout with delay to allow getter nodes time to establish their event listeners
62
+ setTimeout(() => {
63
+ // Emit event so getter nodes with 'always' update mode receive initial value
64
+ RED.events.emit("bldgblocks:global:value-changed", {
65
+ key: node.varName,
66
+ store: node.storeName,
67
+ data: state
68
+ });
69
+ node.send(state);
70
+ }, 500);
56
71
  } catch (err) {
57
72
  // Silently fail or log if init fails (DB down on boot?)
58
73
  node.error(`Init Error: ${err.message}`);
59
- node.status({ fill: "red", shape: "dot", text: "Init Error" });
74
+ utils.setStatusError(node, "Init Error");
60
75
  }
61
76
  })();
62
77
 
@@ -70,7 +85,7 @@ module.exports = function(RED) {
70
85
  if (!msg) return utils.sendError(node, msg, done, "invalid message");
71
86
 
72
87
  if (node.isBusy) {
73
- node.status({ fill: "yellow", shape: "ring", text: "busy - dropped msg" });
88
+ utils.setStatusBusy(node, "busy - dropped msg");
74
89
  if (done) done();
75
90
  return;
76
91
  }
@@ -101,7 +116,7 @@ module.exports = function(RED) {
101
116
 
102
117
  // Handle Reload
103
118
  if (msg.context === "reload") {
104
- RED.events.emit("bldgblocks-global-update", { key: node.varName, store: node.storeName, data: state });
119
+ RED.events.emit("bldgblocks:global:value-changed", { key: node.varName, store: node.storeName, data: state });
105
120
  await utils.setGlobalState(node, node.varName, node.storeName, state);
106
121
 
107
122
  prefix = state.activePriority === 'default' ? '' : 'P';
@@ -111,9 +126,14 @@ module.exports = function(RED) {
111
126
  }
112
127
 
113
128
  // Get Input Value
114
- const inputValue = RED.util.getMessageProperty(msg, node.inputProperty);
129
+ let inputValue;
130
+ try {
131
+ inputValue = RED.util.getMessageProperty(msg, node.inputProperty);
132
+ } catch (err) {
133
+ inputValue = undefined;
134
+ }
115
135
  if (inputValue === undefined) {
116
- return utils.sendError(node, msg, done, `msg.${node.inputProperty} not found`);
136
+ return utils.sendError(node, msg, done, `msg.${node.inputProperty} not found or invalid property path`);
117
137
  }
118
138
 
119
139
  // Update State
@@ -140,7 +160,9 @@ module.exports = function(RED) {
140
160
  if (value === state.value && priority === state.activePriority) {
141
161
  prefix = `${node.writePriority === 'default' ? '' : 'P'}`;
142
162
  const noChangeText = `no change: ${prefix}${node.writePriority}:${state.value}${state.units}`;
143
- node.status({ fill: "green", shape: "dot", text: noChangeText });
163
+ utils.setStatusUnchanged(node, noChangeText);
164
+ // Pass message through even if no context change
165
+ send({ ...state });
144
166
  if (done) done();
145
167
  return;
146
168
  }
@@ -168,12 +190,18 @@ module.exports = function(RED) {
168
190
 
169
191
  // Save (Async) and Emit
170
192
  await utils.setGlobalState(node, node.varName, node.storeName, state);
193
+ // *** REQUIRE DEFAULT STORE ***
194
+ // Require default store to keep values in memory for polled getter nodes so they are not constantly re-reading from DB
195
+ // to avoid hammering edge devices with repeated reads. Writes are only on change. On event (reactive) sends the data in the event.
196
+ if (node.storeName !== 'default') {
197
+ await utils.setGlobalState(node, node.varName, 'default', state);
198
+ }
171
199
 
172
200
  prefix = `${node.writePriority === 'default' ? '' : 'P'}`;
173
201
  const statePrefix = `${state.activePriority === 'default' ? '' : 'P'}`;
174
202
  const statusText = `write: ${prefix}${node.writePriority}:${inputValue}${state.units} > active: ${statePrefix}${state.activePriority}:${state.value}${state.units}`;
175
203
 
176
- RED.events.emit("bldgblocks-global-update", {
204
+ RED.events.emit("bldgblocks:global:value-changed", {
177
205
  key: node.varName,
178
206
  store: node.storeName,
179
207
  data: state
@@ -189,7 +217,7 @@ module.exports = function(RED) {
189
217
 
190
218
  node.on('close', function(removed, done) {
191
219
  if (removed && node.varName) {
192
- RED.events.removeAllListeners("bldgblocks-global-update");
220
+ RED.events.removeAllListeners("bldgblocks:global:value-changed");
193
221
  // Callback style safe for close
194
222
  node.context().global.set(node.varName, undefined, node.storeName, function() {
195
223
  done();
@@ -0,0 +1,96 @@
1
+ <script type="text/html" data-template-name="history-buffer">
2
+ <div class="form-row">
3
+ <label for="node-input-name" title="Display name shown on the canvas"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Trend History">
5
+ </div>
6
+ <div class="form-row">
7
+ <label for="node-input-bufferHours" title="How many hours of history to retain (default 3)"><i class="fa fa-clock-o"></i> Buffer Hours</label>
8
+ <input type="number" id="node-input-bufferHours" placeholder="3" min="0.01" max="24" step="0.01">
9
+ </div>
10
+ </script>
11
+
12
+ <script type="text/javascript">
13
+ RED.nodes.registerType("history-buffer", {
14
+ category: "bldgblocks history",
15
+ color: "#b9f2ff",
16
+ defaults: {
17
+ name: { value: "" },
18
+ bufferHours: {
19
+ value: 3,
20
+ required: true,
21
+ validate: function(v) {
22
+ const val = parseFloat(v);
23
+ return !isNaN(val) && val >= 0.01 && val <= 24;
24
+ }
25
+ }
26
+ },
27
+ inputs: 1,
28
+ outputs: 1,
29
+ inputLabels: ["data"],
30
+ outputLabels: ["chart"],
31
+ icon: "font-awesome/fa-history",
32
+ paletteLabel: "history buffer",
33
+ label: function() {
34
+ return this.name ? `${this.name} (${this.bufferHours}h)` : `history buffer (${this.bufferHours}h)`;
35
+ }
36
+ });
37
+ </script>
38
+
39
+ <script type="text/markdown" data-help-name="history-buffer">
40
+ Persistent trend history for FlowFuse Dashboard 2.0 `ui-chart` nodes.
41
+
42
+ Stores time-series data as hourly files on disk, surviving Node-RED restarts without blocking startup or using global context.
43
+
44
+ ### Inputs
45
+ : topic (string) : Series identifier (e.g., "Return Temp").
46
+ : payload (number) : Data value for this series.
47
+ : ts (number) : Timestamp in milliseconds (optional, auto-filled).
48
+
49
+ ### Outputs
50
+ : topic (string) : Series identifier.
51
+ : payload (number|array) : Single value (append) or array of points (replace).
52
+ : ts (number) : Timestamp in milliseconds.
53
+ : action (string) : `"replace"` on startup, `"append"` for live data.
54
+
55
+ ### ui-chart Configuration (IMPORTANT)
56
+
57
+ The `ui-chart` node **must** be configured to read data from the correct keys.
58
+
59
+ In the chart's **Properties** section, set:
60
+
61
+ | Property | Type | Value |
62
+ |----------|------|-------|
63
+ | **Series** | `key` | `topic` |
64
+ | **X** | `key` | `ts` |
65
+ | **Y** | `key` | `payload` |
66
+
67
+ **Critical:** The type must be `key` (not `msg`). This tells the chart to look *inside* each array element for these fields.
68
+
69
+ Also set:
70
+ - **X-Axis Type**: `Timescale`
71
+ - **X-Axis Limit**: Match your buffer hours or leave default
72
+
73
+ ### Storage
74
+
75
+ Data is stored as Line-Delimited JSON in `~/.node-red/.bldgblocks/trends/`:
76
+ - `buffer_<nodeid>.json` : Active buffer, committed every 30 seconds
77
+ - `trend_<timestamp>_<nodeid>.json` : Hourly rotation files
78
+
79
+ On startup:
80
+ 1. Waits 5 seconds for Node-RED to stabilize
81
+ 2. Loads all files within retention window
82
+ 3. Sends historical data with `action: "replace"`
83
+ 4. Begins accepting live data
84
+
85
+ Supports 0.01 to 24 hours retention. Old files pruned automatically.
86
+
87
+ ### Status
88
+ - **Green** : Ready
89
+ - **Blue** : Active, receiving data
90
+ - **Yellow** : Warning (file issue)
91
+ - **Red** : Error
92
+
93
+ ### References
94
+ - [ui-chart Documentation](https://dashboard.flowfuse.com/nodes/widgets/ui-chart.html)
95
+ - [GitHub](https://github.com/BldgBlocks/node-red-contrib-control.git)
96
+ </script>
@@ -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
+ };