@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.
- package/nodes/accumulate-block.html +18 -8
- package/nodes/accumulate-block.js +39 -44
- package/nodes/add-block.html +1 -1
- package/nodes/add-block.js +18 -11
- package/nodes/alarm-collector.html +260 -0
- package/nodes/alarm-collector.js +292 -0
- package/nodes/alarm-config.html +129 -0
- package/nodes/alarm-config.js +126 -0
- package/nodes/alarm-service.html +96 -0
- package/nodes/alarm-service.js +142 -0
- package/nodes/analog-switch-block.js +25 -36
- package/nodes/and-block.js +44 -15
- package/nodes/average-block.js +46 -41
- package/nodes/boolean-switch-block.js +10 -28
- package/nodes/boolean-to-number-block.html +18 -5
- package/nodes/boolean-to-number-block.js +24 -16
- package/nodes/cache-block.js +24 -37
- package/nodes/call-status-block.html +91 -32
- package/nodes/call-status-block.js +398 -115
- package/nodes/changeover-block.html +5 -0
- package/nodes/changeover-block.js +167 -162
- package/nodes/comment-block.html +1 -1
- package/nodes/comment-block.js +14 -9
- package/nodes/compare-block.html +14 -4
- package/nodes/compare-block.js +23 -18
- package/nodes/contextual-label-block.html +5 -0
- package/nodes/contextual-label-block.js +6 -16
- package/nodes/convert-block.html +25 -39
- package/nodes/convert-block.js +31 -16
- package/nodes/count-block.html +11 -5
- package/nodes/count-block.js +34 -32
- package/nodes/delay-block.js +58 -53
- package/nodes/divide-block.js +43 -45
- package/nodes/edge-block.html +17 -10
- package/nodes/edge-block.js +43 -41
- package/nodes/enum-switch-block.js +6 -6
- package/nodes/frequency-block.html +6 -1
- package/nodes/frequency-block.js +64 -74
- package/nodes/global-getter.html +51 -15
- package/nodes/global-getter.js +43 -13
- package/nodes/global-setter.html +1 -1
- package/nodes/global-setter.js +40 -12
- package/nodes/history-buffer.html +96 -0
- package/nodes/history-buffer.js +461 -0
- package/nodes/history-collector.html +29 -1
- package/nodes/history-collector.js +37 -16
- package/nodes/history-config.html +13 -1
- package/nodes/history-service.html +84 -0
- package/nodes/history-service.js +52 -0
- package/nodes/hysteresis-block.html +5 -0
- package/nodes/hysteresis-block.js +13 -16
- package/nodes/interpolate-block.html +20 -2
- package/nodes/interpolate-block.js +39 -50
- package/nodes/join.html +78 -0
- package/nodes/join.js +78 -0
- package/nodes/latch-block.js +12 -14
- package/nodes/load-sequence-block.js +102 -110
- package/nodes/max-block.js +26 -26
- package/nodes/memory-block.js +57 -58
- package/nodes/min-block.js +26 -25
- package/nodes/minmax-block.js +35 -34
- package/nodes/modulo-block.js +45 -43
- package/nodes/multiply-block.js +43 -41
- package/nodes/negate-block.html +17 -7
- package/nodes/negate-block.js +25 -19
- package/nodes/network-point-read.html +128 -0
- package/nodes/network-point-read.js +230 -0
- package/nodes/{network-register.html → network-point-register.html} +94 -7
- package/nodes/{network-register.js → network-point-register.js} +18 -4
- package/nodes/network-point-write.html +149 -0
- package/nodes/network-point-write.js +222 -0
- package/nodes/network-service-bridge.html +131 -0
- package/nodes/network-service-bridge.js +376 -0
- package/nodes/network-service-read.html +81 -0
- package/nodes/{network-read.js → network-service-read.js} +4 -3
- package/nodes/{network-point-registry.html → network-service-registry.html} +19 -4
- package/nodes/{network-point-registry.js → network-service-registry.js} +7 -2
- package/nodes/network-service-write.html +89 -0
- package/nodes/{network-write.js → network-service-write.js} +3 -3
- package/nodes/nullify-block.js +13 -15
- package/nodes/on-change-block.html +17 -9
- package/nodes/on-change-block.js +49 -46
- package/nodes/oneshot-block.html +13 -10
- package/nodes/oneshot-block.js +57 -75
- package/nodes/or-block.js +44 -15
- package/nodes/pid-block.html +54 -4
- package/nodes/pid-block.js +459 -248
- package/nodes/priority-block.js +24 -35
- package/nodes/rate-limit-block.js +70 -72
- package/nodes/rate-of-change-block.html +33 -14
- package/nodes/rate-of-change-block.js +74 -62
- package/nodes/round-block.html +14 -9
- package/nodes/round-block.js +32 -25
- package/nodes/saw-tooth-wave-block.js +49 -76
- package/nodes/scale-range-block.html +12 -6
- package/nodes/scale-range-block.js +46 -39
- package/nodes/sine-wave-block.js +49 -57
- package/nodes/string-builder-block.js +6 -6
- package/nodes/subtract-block.js +38 -34
- package/nodes/thermistor-block.js +44 -44
- package/nodes/tick-tock-block.js +32 -32
- package/nodes/time-sequence-block.js +30 -42
- package/nodes/triangle-wave-block.js +49 -69
- package/nodes/tstat-block.js +34 -44
- package/nodes/units-block.html +90 -69
- package/nodes/units-block.js +22 -30
- package/nodes/utils.js +206 -3
- package/package.json +14 -6
- package/nodes/network-read.html +0 -56
- package/nodes/network-write.html +0 -65
package/nodes/global-setter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
53
|
-
await utils.setGlobalState(node, node.varName, node.storeName,
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
};
|