@bldgblocks/node-red-contrib-control 0.1.36 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/nodes/alarm-collector.html +11 -0
- package/nodes/alarm-collector.js +13 -0
- package/nodes/alarm-service.js +2 -2
- package/nodes/and-block.js +1 -1
- package/nodes/call-status-block.html +83 -56
- package/nodes/call-status-block.js +335 -248
- package/nodes/changeover-block.html +30 -31
- package/nodes/changeover-block.js +287 -389
- package/nodes/contextual-label-block.js +3 -3
- package/nodes/delay-block.js +74 -13
- package/nodes/global-getter.js +29 -14
- package/nodes/global-setter.js +8 -5
- package/nodes/history-buffer.js +37 -29
- package/nodes/history-collector.js +13 -4
- package/nodes/history-service.js +21 -7
- package/nodes/network-point-read.js +5 -0
- package/nodes/network-service-bridge.js +43 -11
- package/nodes/or-block.js +1 -1
- package/nodes/priority-block.js +1 -1
- package/nodes/tstat-block.html +34 -79
- package/nodes/tstat-block.js +223 -345
- package/nodes/utils.js +1 -1
- package/package.json +90 -75
|
@@ -8,7 +8,7 @@ module.exports = function(RED) {
|
|
|
8
8
|
node.contextPropertyName = config.contextPropertyName || "in1";
|
|
9
9
|
node.removeLabel = config.removeLabel || false;
|
|
10
10
|
|
|
11
|
-
utils.setStatusOK(node,
|
|
11
|
+
utils.setStatusOK(node, node.removeLabel ? "remove" : `set -> ${node.contextPropertyName}`);
|
|
12
12
|
|
|
13
13
|
node.on("input", function(msg, send, done) {
|
|
14
14
|
send = send || function() { node.send.apply(node, arguments); };
|
|
@@ -23,10 +23,10 @@ module.exports = function(RED) {
|
|
|
23
23
|
// Set or remove context property
|
|
24
24
|
if (node.removeLabel) {
|
|
25
25
|
delete msg.context;
|
|
26
|
-
utils.setStatusChanged(node,
|
|
26
|
+
utils.setStatusChanged(node, `${msg.payload} -> removed`);
|
|
27
27
|
} else {
|
|
28
28
|
msg.context = node.contextPropertyName;
|
|
29
|
-
utils.setStatusChanged(node,
|
|
29
|
+
utils.setStatusChanged(node, `${msg.payload} -> ${node.contextPropertyName}`);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
send(msg);
|
package/nodes/delay-block.js
CHANGED
|
@@ -5,14 +5,19 @@ module.exports = function(RED) {
|
|
|
5
5
|
RED.nodes.createNode(this, config);
|
|
6
6
|
const node = this;
|
|
7
7
|
|
|
8
|
+
// Unit multipliers (constant, computed once)
|
|
9
|
+
const delayOnMultiplier = config.delayOnUnits === "seconds" ? 1000 : config.delayOnUnits === "minutes" ? 60000 : 1;
|
|
10
|
+
const delayOffMultiplier = config.delayOffUnits === "seconds" ? 1000 : config.delayOffUnits === "minutes" ? 60000 : 1;
|
|
11
|
+
|
|
8
12
|
// Initialize state
|
|
9
13
|
node.name = config.name;
|
|
10
14
|
node.state = false;
|
|
11
15
|
node.desired = false;
|
|
12
|
-
node.delayOn = parseFloat(config.delayOn) *
|
|
13
|
-
node.delayOff = parseFloat(config.delayOff) *
|
|
16
|
+
node.delayOn = parseFloat(config.delayOn) * delayOnMultiplier;
|
|
17
|
+
node.delayOff = parseFloat(config.delayOff) * delayOffMultiplier;
|
|
14
18
|
|
|
15
19
|
let timeoutId = null;
|
|
20
|
+
let pendingDone = null; // Track deferred done() for in-flight timers
|
|
16
21
|
node.isBusy = false;
|
|
17
22
|
|
|
18
23
|
node.on("input", async function(msg, send, done) {
|
|
@@ -46,21 +51,21 @@ module.exports = function(RED) {
|
|
|
46
51
|
utils.requiresEvaluation(config.delayOnType)
|
|
47
52
|
? utils.evaluateNodeProperty(config.delayOn, config.delayOnType, node, msg)
|
|
48
53
|
.then(val => parseFloat(val))
|
|
49
|
-
: Promise.resolve(
|
|
54
|
+
: Promise.resolve(parseFloat(config.delayOn)),
|
|
50
55
|
);
|
|
51
56
|
|
|
52
57
|
evaluations.push(
|
|
53
58
|
utils.requiresEvaluation(config.delayOffType)
|
|
54
59
|
? utils.evaluateNodeProperty(config.delayOff, config.delayOffType, node, msg)
|
|
55
60
|
.then(val => parseFloat(val))
|
|
56
|
-
: Promise.resolve(
|
|
61
|
+
: Promise.resolve(parseFloat(config.delayOff)),
|
|
57
62
|
);
|
|
58
63
|
|
|
59
64
|
const results = await Promise.all(evaluations);
|
|
60
65
|
|
|
61
|
-
// Update runtime with evaluated values
|
|
62
|
-
if (!isNaN(results[0])) node.delayOn = results[0] *
|
|
63
|
-
if (!isNaN(results[1])) node.delayOff = results[1] *
|
|
66
|
+
// Update runtime with evaluated values (apply unit multiplier to raw config value)
|
|
67
|
+
if (!isNaN(results[0])) node.delayOn = results[0] * delayOnMultiplier;
|
|
68
|
+
if (!isNaN(results[1])) node.delayOff = results[1] * delayOffMultiplier;
|
|
64
69
|
} catch (err) {
|
|
65
70
|
node.error(`Error evaluating properties: ${err.message}`);
|
|
66
71
|
if (done) done();
|
|
@@ -98,7 +103,13 @@ module.exports = function(RED) {
|
|
|
98
103
|
clearTimeout(timeoutId);
|
|
99
104
|
timeoutId = null;
|
|
100
105
|
}
|
|
106
|
+
// Complete any deferred done from a cancelled timer
|
|
107
|
+
if (pendingDone) {
|
|
108
|
+
pendingDone();
|
|
109
|
+
pendingDone = null;
|
|
110
|
+
}
|
|
101
111
|
node.state = false;
|
|
112
|
+
node.desired = false;
|
|
102
113
|
utils.setStatusOK(node, "reset");
|
|
103
114
|
}
|
|
104
115
|
if (done) done();
|
|
@@ -160,44 +171,89 @@ module.exports = function(RED) {
|
|
|
160
171
|
|
|
161
172
|
if (!node.state && inputValue === true) {
|
|
162
173
|
if (node.desired) {
|
|
174
|
+
// Already awaiting true, ignore duplicate
|
|
163
175
|
if (done) done();
|
|
164
176
|
return;
|
|
165
177
|
}
|
|
166
178
|
if (timeoutId) {
|
|
167
179
|
clearTimeout(timeoutId);
|
|
180
|
+
timeoutId = null;
|
|
181
|
+
}
|
|
182
|
+
// Complete any prior deferred done before starting new timer
|
|
183
|
+
if (pendingDone) {
|
|
184
|
+
pendingDone();
|
|
185
|
+
pendingDone = null;
|
|
168
186
|
}
|
|
169
187
|
utils.setStatusUnchanged(node, "awaiting true");
|
|
170
188
|
node.desired = true;
|
|
189
|
+
|
|
190
|
+
// Clone msg for the timer callback so we don't hold the original
|
|
191
|
+
const delayedMsg = RED.util.cloneMessage(msg);
|
|
192
|
+
// Defer done — this message isn't complete until the timer fires or is cancelled
|
|
193
|
+
pendingDone = done;
|
|
194
|
+
|
|
171
195
|
timeoutId = setTimeout(() => {
|
|
172
196
|
node.state = true;
|
|
173
|
-
|
|
174
|
-
delete
|
|
197
|
+
delayedMsg.payload = true;
|
|
198
|
+
delete delayedMsg.context;
|
|
175
199
|
utils.setStatusChanged(node, "in: true, out: true");
|
|
176
|
-
send(
|
|
200
|
+
send(delayedMsg);
|
|
177
201
|
timeoutId = null;
|
|
202
|
+
if (pendingDone) {
|
|
203
|
+
pendingDone();
|
|
204
|
+
pendingDone = null;
|
|
205
|
+
}
|
|
178
206
|
}, node.delayOn);
|
|
207
|
+
|
|
208
|
+
// Don't call done() here — it's deferred to the timer callback
|
|
209
|
+
return;
|
|
179
210
|
} else if (node.state && inputValue === false) {
|
|
180
211
|
if (node.desired === false) {
|
|
212
|
+
// Already awaiting false, ignore duplicate
|
|
181
213
|
if (done) done();
|
|
182
214
|
return;
|
|
183
215
|
}
|
|
184
216
|
if (timeoutId) {
|
|
185
217
|
clearTimeout(timeoutId);
|
|
218
|
+
timeoutId = null;
|
|
219
|
+
}
|
|
220
|
+
// Complete any prior deferred done before starting new timer
|
|
221
|
+
if (pendingDone) {
|
|
222
|
+
pendingDone();
|
|
223
|
+
pendingDone = null;
|
|
186
224
|
}
|
|
187
225
|
utils.setStatusUnchanged(node, "awaiting false");
|
|
188
226
|
node.desired = false;
|
|
227
|
+
|
|
228
|
+
// Clone msg for the timer callback so we don't hold the original
|
|
229
|
+
const delayedMsg = RED.util.cloneMessage(msg);
|
|
230
|
+
// Defer done — this message isn't complete until the timer fires or is cancelled
|
|
231
|
+
pendingDone = done;
|
|
232
|
+
|
|
189
233
|
timeoutId = setTimeout(() => {
|
|
190
234
|
node.state = false;
|
|
191
|
-
|
|
192
|
-
delete
|
|
235
|
+
delayedMsg.payload = false;
|
|
236
|
+
delete delayedMsg.context;
|
|
193
237
|
utils.setStatusChanged(node, "in: false, out: false");
|
|
194
|
-
send(
|
|
238
|
+
send(delayedMsg);
|
|
195
239
|
timeoutId = null;
|
|
240
|
+
if (pendingDone) {
|
|
241
|
+
pendingDone();
|
|
242
|
+
pendingDone = null;
|
|
243
|
+
}
|
|
196
244
|
}, node.delayOff);
|
|
245
|
+
|
|
246
|
+
// Don't call done() here — it's deferred to the timer callback
|
|
247
|
+
return;
|
|
197
248
|
} else {
|
|
198
249
|
if (timeoutId) {
|
|
199
250
|
clearTimeout(timeoutId);
|
|
200
251
|
timeoutId = null;
|
|
252
|
+
// Complete deferred done from the cancelled timer's message
|
|
253
|
+
if (pendingDone) {
|
|
254
|
+
pendingDone();
|
|
255
|
+
pendingDone = null;
|
|
256
|
+
}
|
|
201
257
|
utils.setStatusUnchanged(node, `canceled awaiting ${node.state}`);
|
|
202
258
|
} else {
|
|
203
259
|
utils.setStatusUnchanged(node, "no change");
|
|
@@ -217,6 +273,11 @@ module.exports = function(RED) {
|
|
|
217
273
|
clearTimeout(timeoutId);
|
|
218
274
|
timeoutId = null;
|
|
219
275
|
}
|
|
276
|
+
// Complete any deferred done on shutdown
|
|
277
|
+
if (pendingDone) {
|
|
278
|
+
pendingDone();
|
|
279
|
+
pendingDone = null;
|
|
280
|
+
}
|
|
220
281
|
done();
|
|
221
282
|
});
|
|
222
283
|
}
|
package/nodes/global-getter.js
CHANGED
|
@@ -10,10 +10,11 @@ module.exports = function(RED) {
|
|
|
10
10
|
node.detail = config.detail;
|
|
11
11
|
|
|
12
12
|
let setterNode = null;
|
|
13
|
-
let
|
|
14
|
-
let
|
|
13
|
+
let retryTimeout = null;
|
|
14
|
+
let healthCheckTimeout = null;
|
|
15
15
|
let updateListener = null;
|
|
16
16
|
let retryCount = 0;
|
|
17
|
+
let closed = false;
|
|
17
18
|
const retryDelays = [0, 100, 500, 1000, 2000, 4000, 8000, 16000];
|
|
18
19
|
const maxRetries = retryDelays.length - 1;
|
|
19
20
|
|
|
@@ -85,9 +86,9 @@ module.exports = function(RED) {
|
|
|
85
86
|
|
|
86
87
|
RED.events.on("bldgblocks:global:value-changed", updateListener);
|
|
87
88
|
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
if (retryTimeout) {
|
|
90
|
+
clearTimeout(retryTimeout);
|
|
91
|
+
retryTimeout = null;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
utils.setStatusOK(node, "Connected");
|
|
@@ -98,6 +99,11 @@ module.exports = function(RED) {
|
|
|
98
99
|
|
|
99
100
|
function startHealthCheck() {
|
|
100
101
|
const check = () => {
|
|
102
|
+
if (closed) return;
|
|
103
|
+
if (!updateListener) {
|
|
104
|
+
healthCheckTimeout = setTimeout(check, 30000);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
101
107
|
const listeners = RED.events.listeners("bldgblocks:global:value-changed");
|
|
102
108
|
const hasOurListener = listeners.includes(updateListener);
|
|
103
109
|
if (!hasOurListener) {
|
|
@@ -106,13 +112,14 @@ module.exports = function(RED) {
|
|
|
106
112
|
utils.setStatusOK(node, "Reconnected");
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
|
-
setTimeout(check, 30000);
|
|
115
|
+
healthCheckTimeout = setTimeout(check, 30000);
|
|
110
116
|
};
|
|
111
|
-
setTimeout(check, 30000);
|
|
117
|
+
healthCheckTimeout = setTimeout(check, 30000);
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
function subscribeWithRetry() {
|
|
115
|
-
|
|
121
|
+
function attempt() {
|
|
122
|
+
if (closed) return;
|
|
116
123
|
if (retryCount >= maxRetries) {
|
|
117
124
|
utils.sendError(node, null, null, "Connection failed");
|
|
118
125
|
return;
|
|
@@ -122,9 +129,9 @@ module.exports = function(RED) {
|
|
|
122
129
|
return;
|
|
123
130
|
}
|
|
124
131
|
retryCount++;
|
|
125
|
-
setTimeout(
|
|
126
|
-
}
|
|
127
|
-
setTimeout(
|
|
132
|
+
retryTimeout = setTimeout(attempt, retryDelays[Math.min(retryCount, maxRetries - 1)]);
|
|
133
|
+
}
|
|
134
|
+
retryTimeout = setTimeout(attempt, retryDelays[0]);
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
// --- INPUT HANDLER ---
|
|
@@ -160,10 +167,18 @@ module.exports = function(RED) {
|
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
node.on('close', function(removed, done) {
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
170
|
+
closed = true;
|
|
171
|
+
if (healthCheckTimeout) {
|
|
172
|
+
clearTimeout(healthCheckTimeout);
|
|
173
|
+
healthCheckTimeout = null;
|
|
174
|
+
}
|
|
175
|
+
if (retryTimeout) {
|
|
176
|
+
clearTimeout(retryTimeout);
|
|
177
|
+
retryTimeout = null;
|
|
178
|
+
}
|
|
179
|
+
if (updateListener) {
|
|
166
180
|
RED.events.removeListener("bldgblocks:global:value-changed", updateListener);
|
|
181
|
+
updateListener = null;
|
|
167
182
|
}
|
|
168
183
|
done();
|
|
169
184
|
});
|
package/nodes/global-setter.js
CHANGED
|
@@ -59,7 +59,8 @@ module.exports = function(RED) {
|
|
|
59
59
|
// Send properly formed state object downstream after full initialization
|
|
60
60
|
// Allows network-register and other downstream nodes to register on startup
|
|
61
61
|
// Use setTimeout with delay to allow getter nodes time to establish their event listeners
|
|
62
|
-
setTimeout(() => {
|
|
62
|
+
initTimer = setTimeout(() => {
|
|
63
|
+
initTimer = null;
|
|
63
64
|
// Emit event so getter nodes with 'always' update mode receive initial value
|
|
64
65
|
RED.events.emit("bldgblocks:global:value-changed", {
|
|
65
66
|
key: node.varName,
|
|
@@ -120,7 +121,7 @@ module.exports = function(RED) {
|
|
|
120
121
|
await utils.setGlobalState(node, node.varName, node.storeName, state);
|
|
121
122
|
|
|
122
123
|
prefix = state.activePriority === 'default' ? '' : 'P';
|
|
123
|
-
const statusText = `reload: ${prefix}${state.activePriority}:${state.value}${state.units}`;
|
|
124
|
+
const statusText = `reload: ${prefix}${state.activePriority}:${state.value}${state.units || ''}`;
|
|
124
125
|
|
|
125
126
|
return utils.sendSuccess(node, { ...state }, done, statusText, null, "dot");
|
|
126
127
|
}
|
|
@@ -158,8 +159,10 @@ module.exports = function(RED) {
|
|
|
158
159
|
|
|
159
160
|
// Check for change
|
|
160
161
|
if (value === state.value && priority === state.activePriority) {
|
|
162
|
+
// Ensure payload stays in sync with value
|
|
163
|
+
state.payload = state.value;
|
|
161
164
|
prefix = `${node.writePriority === 'default' ? '' : 'P'}`;
|
|
162
|
-
const noChangeText = `no change: ${prefix}${node.writePriority}:${state.value}${state.units}`;
|
|
165
|
+
const noChangeText = `no change: ${prefix}${node.writePriority}:${state.value}${state.units || ''}`;
|
|
163
166
|
utils.setStatusUnchanged(node, noChangeText);
|
|
164
167
|
// Pass message through even if no context change
|
|
165
168
|
send({ ...state });
|
|
@@ -199,7 +202,7 @@ module.exports = function(RED) {
|
|
|
199
202
|
|
|
200
203
|
prefix = `${node.writePriority === 'default' ? '' : 'P'}`;
|
|
201
204
|
const statePrefix = `${state.activePriority === 'default' ? '' : 'P'}`;
|
|
202
|
-
const statusText = `write: ${prefix}${node.writePriority}:${inputValue}${state.units} > active: ${statePrefix}${state.activePriority}:${state.value}${state.units}`;
|
|
205
|
+
const statusText = `write: ${prefix}${node.writePriority}:${inputValue}${state.units || ''} > active: ${statePrefix}${state.activePriority}:${state.value}${state.units || ''}`;
|
|
203
206
|
|
|
204
207
|
RED.events.emit("bldgblocks:global:value-changed", {
|
|
205
208
|
key: node.varName,
|
|
@@ -216,8 +219,8 @@ module.exports = function(RED) {
|
|
|
216
219
|
});
|
|
217
220
|
|
|
218
221
|
node.on('close', function(removed, done) {
|
|
222
|
+
if (initTimer) { clearTimeout(initTimer); initTimer = null; }
|
|
219
223
|
if (removed && node.varName) {
|
|
220
|
-
RED.events.removeAllListeners("bldgblocks:global:value-changed");
|
|
221
224
|
// Callback style safe for close
|
|
222
225
|
node.context().global.set(node.varName, undefined, node.storeName, function() {
|
|
223
226
|
done();
|
package/nodes/history-buffer.js
CHANGED
|
@@ -20,9 +20,10 @@ module.exports = function(RED) {
|
|
|
20
20
|
// Constants
|
|
21
21
|
const TRENDS_DIR = getTrendsDirPath();
|
|
22
22
|
// Use node ID in filename to prevent collisions between multiple history nodes
|
|
23
|
-
const BUFFER_FILE = path.join(TRENDS_DIR, `buffer_${node.id}.
|
|
24
|
-
// Legacy file
|
|
23
|
+
const BUFFER_FILE = path.join(TRENDS_DIR, `buffer_${node.id}.jsonl`);
|
|
24
|
+
// Legacy file paths for migration
|
|
25
25
|
const LEGACY_BUFFER_FILE = path.join(TRENDS_DIR, 'buffer_current.json');
|
|
26
|
+
const LEGACY_BUFFER_FILE_JSON = path.join(TRENDS_DIR, `buffer_${node.id}.json`);
|
|
26
27
|
|
|
27
28
|
const COMMIT_INTERVAL_MS = 30 * 1000; // 30 seconds
|
|
28
29
|
const PRUNE_INTERVAL_MS = 60 * 1000; // 60 seconds
|
|
@@ -33,7 +34,8 @@ module.exports = function(RED) {
|
|
|
33
34
|
let liveBuffer = []; // Accumulate points since last commit to BUFFER_FILE
|
|
34
35
|
let commitTimer = null;
|
|
35
36
|
let pruneTimer = null;
|
|
36
|
-
let messageCount = 0;
|
|
37
|
+
let messageCount = 0; // Total messages received
|
|
38
|
+
let activeBufferCount = 0; // Messages in current buffer file (resets on rotate)
|
|
37
39
|
let cachedChunkCount = 0; // Cached count of historical files
|
|
38
40
|
let isInitializing = true; // Flag: initialization in progress
|
|
39
41
|
let queuedMessages = []; // Queue messages during initialization
|
|
@@ -121,6 +123,26 @@ module.exports = function(RED) {
|
|
|
121
123
|
const now = new Date();
|
|
122
124
|
const currentTimestamp = Math.floor(now.getTime() / 1000);
|
|
123
125
|
|
|
126
|
+
// Migrate old .json buffer to .jsonl if it exists
|
|
127
|
+
try {
|
|
128
|
+
await fs.promises.access(LEGACY_BUFFER_FILE_JSON);
|
|
129
|
+
// If the new .jsonl doesn't exist, rename; otherwise append and delete
|
|
130
|
+
try {
|
|
131
|
+
await fs.promises.access(BUFFER_FILE);
|
|
132
|
+
// Both exist: append old into new, then remove old
|
|
133
|
+
const oldData = await fs.promises.readFile(LEGACY_BUFFER_FILE_JSON);
|
|
134
|
+
if (oldData.length > 0) {
|
|
135
|
+
await fs.promises.appendFile(BUFFER_FILE, oldData);
|
|
136
|
+
}
|
|
137
|
+
await fs.promises.unlink(LEGACY_BUFFER_FILE_JSON);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
// New .jsonl doesn't exist, just rename
|
|
140
|
+
await fs.promises.rename(LEGACY_BUFFER_FILE_JSON, BUFFER_FILE);
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// No legacy .json buffer, nothing to migrate
|
|
144
|
+
}
|
|
145
|
+
|
|
124
146
|
// Check for this node's specific buffer
|
|
125
147
|
try {
|
|
126
148
|
const stats = await fs.promises.stat(BUFFER_FILE);
|
|
@@ -135,7 +157,7 @@ module.exports = function(RED) {
|
|
|
135
157
|
|
|
136
158
|
if (isStale) {
|
|
137
159
|
const fileTs = Math.floor(stats.mtimeMs / 1000);
|
|
138
|
-
const newName = path.join(TRENDS_DIR, `trend_${fileTs}_${node.id}.
|
|
160
|
+
const newName = path.join(TRENDS_DIR, `trend_${fileTs}_${node.id}.jsonl`);
|
|
139
161
|
await fs.promises.rename(BUFFER_FILE, newName);
|
|
140
162
|
}
|
|
141
163
|
|
|
@@ -146,7 +168,7 @@ module.exports = function(RED) {
|
|
|
146
168
|
// Check for legacy buffer (migration)
|
|
147
169
|
try {
|
|
148
170
|
await fs.promises.access(LEGACY_BUFFER_FILE);
|
|
149
|
-
const legacyName = path.join(TRENDS_DIR, `trend_${currentTimestamp}_legacy.
|
|
171
|
+
const legacyName = path.join(TRENDS_DIR, `trend_${currentTimestamp}_legacy.jsonl`);
|
|
150
172
|
await fs.promises.rename(LEGACY_BUFFER_FILE, legacyName);
|
|
151
173
|
} catch (err) {
|
|
152
174
|
// Ignore
|
|
@@ -156,9 +178,9 @@ module.exports = function(RED) {
|
|
|
156
178
|
async function getHistoricalFiles() {
|
|
157
179
|
try {
|
|
158
180
|
const files = await fs.promises.readdir(TRENDS_DIR);
|
|
159
|
-
// Filter for our files: trend_TIMESTAMP_*.json
|
|
181
|
+
// Filter for our files: trend_TIMESTAMP_*.json or .jsonl
|
|
160
182
|
return files
|
|
161
|
-
.filter(f => f.startsWith('trend_') && f.endsWith('.json'))
|
|
183
|
+
.filter(f => f.startsWith('trend_') && (f.endsWith('.json') || f.endsWith('.jsonl')))
|
|
162
184
|
.sort(); // String sort works for fixed-length timestamps usually, but numeric would be safer
|
|
163
185
|
} catch (err) {
|
|
164
186
|
return [];
|
|
@@ -249,20 +271,10 @@ module.exports = function(RED) {
|
|
|
249
271
|
// Reset live buffer immediately so new messages go into next batch
|
|
250
272
|
liveBuffer = [];
|
|
251
273
|
|
|
252
|
-
const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n');
|
|
274
|
+
const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n') + '\n';
|
|
253
275
|
|
|
254
276
|
try {
|
|
255
|
-
|
|
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);
|
|
277
|
+
await fs.promises.appendFile(BUFFER_FILE, lines);
|
|
266
278
|
} catch (err) {
|
|
267
279
|
node.warn(`Buffer commit failed: ${err.message}`);
|
|
268
280
|
// Put points back at the start of buffer if write failed
|
|
@@ -335,15 +347,9 @@ module.exports = function(RED) {
|
|
|
335
347
|
if (liveBuffer.length > 0) {
|
|
336
348
|
const pointsToCommit = liveBuffer;
|
|
337
349
|
liveBuffer = []; // Clear memory
|
|
338
|
-
const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n');
|
|
350
|
+
const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n') + '\n';
|
|
339
351
|
try {
|
|
340
|
-
|
|
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);
|
|
352
|
+
await fs.promises.appendFile(BUFFER_FILE, lines);
|
|
347
353
|
} catch (err) {
|
|
348
354
|
// If append fails, we might lose these points during rotation,
|
|
349
355
|
// put them back and abort rotation. Use concat for safety.
|
|
@@ -355,12 +361,13 @@ module.exports = function(RED) {
|
|
|
355
361
|
|
|
356
362
|
// Perform Rotation (Rename)
|
|
357
363
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
358
|
-
const newName = path.join(TRENDS_DIR, `trend_${timestamp}_${node.id}.
|
|
364
|
+
const newName = path.join(TRENDS_DIR, `trend_${timestamp}_${node.id}.jsonl`);
|
|
359
365
|
|
|
360
366
|
try {
|
|
361
367
|
await fs.promises.access(BUFFER_FILE);
|
|
362
368
|
await fs.promises.rename(BUFFER_FILE, newName);
|
|
363
369
|
cachedChunkCount++;
|
|
370
|
+
activeBufferCount = 0; // Reset active buffer count after rotation
|
|
364
371
|
} catch (err) {
|
|
365
372
|
// BUFFER_FILE doesn't exist - no data to rotate
|
|
366
373
|
}
|
|
@@ -421,9 +428,10 @@ module.exports = function(RED) {
|
|
|
421
428
|
});
|
|
422
429
|
|
|
423
430
|
messageCount++;
|
|
431
|
+
activeBufferCount++; // Increment count of messages in current buffer file
|
|
424
432
|
|
|
425
433
|
// Status update (throttled internally to 1s)
|
|
426
|
-
updateStatus(`${messageCount}
|
|
434
|
+
updateStatus(`${messageCount} processed, ${activeBufferCount} active, ${cachedChunkCount} chunks`, messageCount === 1);
|
|
427
435
|
|
|
428
436
|
if (done) done();
|
|
429
437
|
});
|
|
@@ -106,6 +106,15 @@ module.exports = function(RED) {
|
|
|
106
106
|
// Set initial status
|
|
107
107
|
utils.setStatusOK(node, "configuration received");
|
|
108
108
|
|
|
109
|
+
// Emit event for history-service relay (InfluxDB batch format)
|
|
110
|
+
const eventName = `bldgblocks:history:${node.historyConfig.id}`;
|
|
111
|
+
RED.events.emit(eventName, {
|
|
112
|
+
measurement: escapedMeasurementName,
|
|
113
|
+
fields: { value: formattedValue },
|
|
114
|
+
tags: tagsObj,
|
|
115
|
+
timestamp: timestamp
|
|
116
|
+
});
|
|
117
|
+
|
|
109
118
|
// Handle storage type
|
|
110
119
|
if (node.storageType === 'memory') {
|
|
111
120
|
const contextKey = `history_data_${node.historyConfig.name}`;
|
|
@@ -125,7 +134,7 @@ module.exports = function(RED) {
|
|
|
125
134
|
msg.measurement = escapedMeasurementName;
|
|
126
135
|
msg.payload = line;
|
|
127
136
|
node.send(msg);
|
|
128
|
-
utils.setStatusChanged(node, `
|
|
137
|
+
utils.setStatusChanged(node, `stored: ${valueString}`);
|
|
129
138
|
} else if (node.storageType === 'object') {
|
|
130
139
|
msg.measurement = escapedMeasurementName;
|
|
131
140
|
msg.payload = {
|
|
@@ -135,7 +144,7 @@ module.exports = function(RED) {
|
|
|
135
144
|
timestamp: timestamp
|
|
136
145
|
};
|
|
137
146
|
node.send(msg);
|
|
138
|
-
utils.setStatusChanged(node, `
|
|
147
|
+
utils.setStatusChanged(node, `stored: ${valueString}`);
|
|
139
148
|
} else if (node.storageType === 'objectArray') {
|
|
140
149
|
msg.measurement = escapedMeasurementName;
|
|
141
150
|
msg.timestamp = timestamp;
|
|
@@ -146,7 +155,7 @@ module.exports = function(RED) {
|
|
|
146
155
|
tagsObj
|
|
147
156
|
]
|
|
148
157
|
node.send(msg);
|
|
149
|
-
utils.setStatusChanged(node, `
|
|
158
|
+
utils.setStatusChanged(node, `stored: ${valueString}`);
|
|
150
159
|
} else if (node.storageType === 'batchObject') {
|
|
151
160
|
msg.payload = {
|
|
152
161
|
measurement: escapedMeasurementName,
|
|
@@ -157,7 +166,7 @@ module.exports = function(RED) {
|
|
|
157
166
|
tags: tagsObj
|
|
158
167
|
}
|
|
159
168
|
node.send(msg);
|
|
160
|
-
utils.setStatusChanged(node, `
|
|
169
|
+
utils.setStatusChanged(node, `stored: ${valueString}`);
|
|
161
170
|
}
|
|
162
171
|
|
|
163
172
|
if (done) done();
|
package/nodes/history-service.js
CHANGED
|
@@ -15,6 +15,18 @@ module.exports = function(RED) {
|
|
|
15
15
|
// Generate matching event name based on history-config ID
|
|
16
16
|
const eventName = `bldgblocks:history:${node.historyConfig.id}`;
|
|
17
17
|
|
|
18
|
+
// Status throttling - prevent rapid status updates from fast-streaming histories
|
|
19
|
+
let lastRelayedName = null;
|
|
20
|
+
let statusDirty = false;
|
|
21
|
+
let statusInterval = null;
|
|
22
|
+
|
|
23
|
+
statusInterval = setInterval(() => {
|
|
24
|
+
if (statusDirty && lastRelayedName) {
|
|
25
|
+
utils.setStatusChanged(node, `relayed: ${lastRelayedName}`);
|
|
26
|
+
statusDirty = false;
|
|
27
|
+
}
|
|
28
|
+
}, 2000);
|
|
29
|
+
|
|
18
30
|
// Listen for events from history-collector nodes with this config
|
|
19
31
|
const eventListener = (eventData) => {
|
|
20
32
|
// Guard against invalid event data
|
|
@@ -24,17 +36,14 @@ module.exports = function(RED) {
|
|
|
24
36
|
return;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
|
-
//
|
|
28
|
-
// Preserve topic if it exists in the event data
|
|
39
|
+
// Send event data directly as payload (already in InfluxDB batch format)
|
|
29
40
|
const msg = {
|
|
30
|
-
payload: eventData
|
|
31
|
-
topic: eventData.topic
|
|
41
|
+
payload: eventData
|
|
32
42
|
};
|
|
33
43
|
|
|
34
44
|
node.send(msg);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
utils.setStatusChanged(node, `relayed: ${eventData.seriesName || 'data'}`);
|
|
45
|
+
lastRelayedName = eventData.measurement || 'data';
|
|
46
|
+
statusDirty = true;
|
|
38
47
|
};
|
|
39
48
|
|
|
40
49
|
// Subscribe to events
|
|
@@ -42,6 +51,11 @@ module.exports = function(RED) {
|
|
|
42
51
|
utils.setStatusOK(node, `listening on ${node.historyConfig.name}`);
|
|
43
52
|
|
|
44
53
|
node.on("close", function(done) {
|
|
54
|
+
// Clear status interval
|
|
55
|
+
if (statusInterval) {
|
|
56
|
+
clearInterval(statusInterval);
|
|
57
|
+
statusInterval = null;
|
|
58
|
+
}
|
|
45
59
|
// Unsubscribe from events on close
|
|
46
60
|
RED.events.off(eventName, eventListener);
|
|
47
61
|
done();
|
|
@@ -147,6 +147,11 @@ module.exports = function(RED) {
|
|
|
147
147
|
|
|
148
148
|
// Check for error response
|
|
149
149
|
if (data.error) {
|
|
150
|
+
// During startup phase, suppress error display (network still coming online)
|
|
151
|
+
if (data.isStartupPhase) {
|
|
152
|
+
// Keep stale value, don't show error - just stay in waiting state
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
150
155
|
const errorText = `Read failed for point #${node.pointId}: ${data.errorMessage || "Unknown error"}`;
|
|
151
156
|
utils.setStatusError(node, `Error: ${data.errorMessage || "Unknown error"}`);
|
|
152
157
|
node.error(errorText); // Show in debug panel
|