@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.
- 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 +74 -67
- package/nodes/global-setter.html +1 -1
- package/nodes/global-setter.js +168 -188
- 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-point-register.js +126 -0
- 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-service-read.js +58 -0
- 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-service-write.js +83 -0
- 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 +275 -3
- package/package.json +14 -6
- package/nodes/network-read.html +0 -56
- package/nodes/network-read.js +0 -59
- package/nodes/network-register.js +0 -161
- package/nodes/network-write.html +0 -64
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
84
|
+
utils.setStatusError(node, "invalid payload");
|
|
85
|
+
if (done) done();
|
|
67
86
|
return;
|
|
68
87
|
}
|
|
69
88
|
|
|
70
89
|
if (formattedValue === null) {
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
+
utils.setStatusChanged(node, `sent: ${valueString}`);
|
|
142
161
|
}
|
|
162
|
+
|
|
163
|
+
if (done) done();
|
|
143
164
|
});
|
|
144
165
|
|
|
145
166
|
node.on("close", function(done) {
|