@bldgblocks/node-red-contrib-control 0.1.37 → 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 +32 -27
- package/nodes/history-collector.js +4 -4
- 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
|
|
@@ -122,6 +123,26 @@ module.exports = function(RED) {
|
|
|
122
123
|
const now = new Date();
|
|
123
124
|
const currentTimestamp = Math.floor(now.getTime() / 1000);
|
|
124
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
|
+
|
|
125
146
|
// Check for this node's specific buffer
|
|
126
147
|
try {
|
|
127
148
|
const stats = await fs.promises.stat(BUFFER_FILE);
|
|
@@ -136,7 +157,7 @@ module.exports = function(RED) {
|
|
|
136
157
|
|
|
137
158
|
if (isStale) {
|
|
138
159
|
const fileTs = Math.floor(stats.mtimeMs / 1000);
|
|
139
|
-
const newName = path.join(TRENDS_DIR, `trend_${fileTs}_${node.id}.
|
|
160
|
+
const newName = path.join(TRENDS_DIR, `trend_${fileTs}_${node.id}.jsonl`);
|
|
140
161
|
await fs.promises.rename(BUFFER_FILE, newName);
|
|
141
162
|
}
|
|
142
163
|
|
|
@@ -147,7 +168,7 @@ module.exports = function(RED) {
|
|
|
147
168
|
// Check for legacy buffer (migration)
|
|
148
169
|
try {
|
|
149
170
|
await fs.promises.access(LEGACY_BUFFER_FILE);
|
|
150
|
-
const legacyName = path.join(TRENDS_DIR, `trend_${currentTimestamp}_legacy.
|
|
171
|
+
const legacyName = path.join(TRENDS_DIR, `trend_${currentTimestamp}_legacy.jsonl`);
|
|
151
172
|
await fs.promises.rename(LEGACY_BUFFER_FILE, legacyName);
|
|
152
173
|
} catch (err) {
|
|
153
174
|
// Ignore
|
|
@@ -157,9 +178,9 @@ module.exports = function(RED) {
|
|
|
157
178
|
async function getHistoricalFiles() {
|
|
158
179
|
try {
|
|
159
180
|
const files = await fs.promises.readdir(TRENDS_DIR);
|
|
160
|
-
// Filter for our files: trend_TIMESTAMP_*.json
|
|
181
|
+
// Filter for our files: trend_TIMESTAMP_*.json or .jsonl
|
|
161
182
|
return files
|
|
162
|
-
.filter(f => f.startsWith('trend_') && f.endsWith('.json'))
|
|
183
|
+
.filter(f => f.startsWith('trend_') && (f.endsWith('.json') || f.endsWith('.jsonl')))
|
|
163
184
|
.sort(); // String sort works for fixed-length timestamps usually, but numeric would be safer
|
|
164
185
|
} catch (err) {
|
|
165
186
|
return [];
|
|
@@ -250,20 +271,10 @@ module.exports = function(RED) {
|
|
|
250
271
|
// Reset live buffer immediately so new messages go into next batch
|
|
251
272
|
liveBuffer = [];
|
|
252
273
|
|
|
253
|
-
const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n');
|
|
274
|
+
const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n') + '\n';
|
|
254
275
|
|
|
255
276
|
try {
|
|
256
|
-
|
|
257
|
-
// We'll let the next append handle its own newline or just ensure we join efficiently.
|
|
258
|
-
// Actually, safest is to append WITH a preceding newline if file exists.
|
|
259
|
-
|
|
260
|
-
let prefix = '';
|
|
261
|
-
try {
|
|
262
|
-
const stats = await fs.promises.stat(BUFFER_FILE);
|
|
263
|
-
if (stats.size > 0) prefix = '\n';
|
|
264
|
-
} catch(e) { /* File doesn't exist, no prefix needed */ }
|
|
265
|
-
|
|
266
|
-
await fs.promises.appendFile(BUFFER_FILE, prefix + lines);
|
|
277
|
+
await fs.promises.appendFile(BUFFER_FILE, lines);
|
|
267
278
|
} catch (err) {
|
|
268
279
|
node.warn(`Buffer commit failed: ${err.message}`);
|
|
269
280
|
// Put points back at the start of buffer if write failed
|
|
@@ -336,15 +347,9 @@ module.exports = function(RED) {
|
|
|
336
347
|
if (liveBuffer.length > 0) {
|
|
337
348
|
const pointsToCommit = liveBuffer;
|
|
338
349
|
liveBuffer = []; // Clear memory
|
|
339
|
-
const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n');
|
|
350
|
+
const lines = pointsToCommit.map(p => JSON.stringify(p)).join('\n') + '\n';
|
|
340
351
|
try {
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
const stats = await fs.promises.stat(BUFFER_FILE);
|
|
344
|
-
if (stats.size > 0) prefix = '\n';
|
|
345
|
-
} catch(e) { /* File doesn't exist */ }
|
|
346
|
-
|
|
347
|
-
await fs.promises.appendFile(BUFFER_FILE, prefix + lines);
|
|
352
|
+
await fs.promises.appendFile(BUFFER_FILE, lines);
|
|
348
353
|
} catch (err) {
|
|
349
354
|
// If append fails, we might lose these points during rotation,
|
|
350
355
|
// put them back and abort rotation. Use concat for safety.
|
|
@@ -356,7 +361,7 @@ module.exports = function(RED) {
|
|
|
356
361
|
|
|
357
362
|
// Perform Rotation (Rename)
|
|
358
363
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
359
|
-
const newName = path.join(TRENDS_DIR, `trend_${timestamp}_${node.id}.
|
|
364
|
+
const newName = path.join(TRENDS_DIR, `trend_${timestamp}_${node.id}.jsonl`);
|
|
360
365
|
|
|
361
366
|
try {
|
|
362
367
|
await fs.promises.access(BUFFER_FILE);
|
|
@@ -134,7 +134,7 @@ module.exports = function(RED) {
|
|
|
134
134
|
msg.measurement = escapedMeasurementName;
|
|
135
135
|
msg.payload = line;
|
|
136
136
|
node.send(msg);
|
|
137
|
-
utils.setStatusChanged(node, `
|
|
137
|
+
utils.setStatusChanged(node, `stored: ${valueString}`);
|
|
138
138
|
} else if (node.storageType === 'object') {
|
|
139
139
|
msg.measurement = escapedMeasurementName;
|
|
140
140
|
msg.payload = {
|
|
@@ -144,7 +144,7 @@ module.exports = function(RED) {
|
|
|
144
144
|
timestamp: timestamp
|
|
145
145
|
};
|
|
146
146
|
node.send(msg);
|
|
147
|
-
utils.setStatusChanged(node, `
|
|
147
|
+
utils.setStatusChanged(node, `stored: ${valueString}`);
|
|
148
148
|
} else if (node.storageType === 'objectArray') {
|
|
149
149
|
msg.measurement = escapedMeasurementName;
|
|
150
150
|
msg.timestamp = timestamp;
|
|
@@ -155,7 +155,7 @@ module.exports = function(RED) {
|
|
|
155
155
|
tagsObj
|
|
156
156
|
]
|
|
157
157
|
node.send(msg);
|
|
158
|
-
utils.setStatusChanged(node, `
|
|
158
|
+
utils.setStatusChanged(node, `stored: ${valueString}`);
|
|
159
159
|
} else if (node.storageType === 'batchObject') {
|
|
160
160
|
msg.payload = {
|
|
161
161
|
measurement: escapedMeasurementName,
|
|
@@ -166,7 +166,7 @@ module.exports = function(RED) {
|
|
|
166
166
|
tags: tagsObj
|
|
167
167
|
}
|
|
168
168
|
node.send(msg);
|
|
169
|
-
utils.setStatusChanged(node, `
|
|
169
|
+
utils.setStatusChanged(node, `stored: ${valueString}`);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
if (done) 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
|
|
@@ -112,10 +112,18 @@ module.exports = function(RED) {
|
|
|
112
112
|
const pending = node.pendingRequests[data.requestId];
|
|
113
113
|
delete node.pendingRequests[data.requestId];
|
|
114
114
|
|
|
115
|
-
// Suppress error notification during startup phase
|
|
116
|
-
// (allows network to come online without nuisance errors)
|
|
117
115
|
if (pending.isStartupPhase) {
|
|
118
|
-
//
|
|
116
|
+
// During startup: still notify point-read to reset isPollPending,
|
|
117
|
+
// but suppress error logging (network may still be coming online)
|
|
118
|
+
RED.events.emit('pointReference:response', {
|
|
119
|
+
sourceNodeId: data.sourceNodeId,
|
|
120
|
+
pointId: data.pointId,
|
|
121
|
+
value: null,
|
|
122
|
+
error: true,
|
|
123
|
+
errorMessage: "Startup timeout",
|
|
124
|
+
requestId: data.requestId,
|
|
125
|
+
isStartupPhase: true
|
|
126
|
+
});
|
|
119
127
|
return;
|
|
120
128
|
}
|
|
121
129
|
|
|
@@ -195,10 +203,16 @@ module.exports = function(RED) {
|
|
|
195
203
|
const pending = node.pendingRequests[data.requestId];
|
|
196
204
|
delete node.pendingRequests[data.requestId];
|
|
197
205
|
|
|
198
|
-
// Suppress error notification during startup phase
|
|
199
|
-
// (allows network to come online without nuisance errors)
|
|
200
206
|
if (pending.isStartupPhase) {
|
|
201
|
-
//
|
|
207
|
+
// During startup: still notify point-write to reset pending state,
|
|
208
|
+
// but suppress error logging
|
|
209
|
+
RED.events.emit('pointWrite:response', {
|
|
210
|
+
sourceNodeId: pending.sourceNodeId,
|
|
211
|
+
pointId: data.pointId,
|
|
212
|
+
error: "Startup timeout",
|
|
213
|
+
requestId: data.requestId,
|
|
214
|
+
isStartupPhase: true
|
|
215
|
+
});
|
|
202
216
|
return;
|
|
203
217
|
}
|
|
204
218
|
|
|
@@ -240,14 +254,16 @@ module.exports = function(RED) {
|
|
|
240
254
|
// ================================================================
|
|
241
255
|
|
|
242
256
|
// Check if this looks like a point response (has network.pointId or status.pointId)
|
|
243
|
-
const
|
|
257
|
+
const rawPointId = msg.network?.pointId ?? msg.status?.pointId ?? msg.pointId;
|
|
258
|
+
// Normalize to number - point-read stores pointId as int, but WebSocket responses may return strings
|
|
259
|
+
const responsePointId = rawPointId !== undefined && rawPointId !== null ? parseInt(rawPointId, 10) : undefined;
|
|
244
260
|
const responseValue = msg.value ?? msg.payload;
|
|
245
261
|
const statusCode = msg.status?.code;
|
|
246
262
|
const statusMessage = msg.status?.message || "";
|
|
247
263
|
const isError = statusCode === "error";
|
|
248
264
|
|
|
249
|
-
// Valid response if we have a
|
|
250
|
-
const isValidResponse = responsePointId !== undefined;
|
|
265
|
+
// Valid response if we have a valid numeric pointId
|
|
266
|
+
const isValidResponse = responsePointId !== undefined && !isNaN(responsePointId);
|
|
251
267
|
|
|
252
268
|
if (isValidResponse) {
|
|
253
269
|
// Find ALL matching pending requests by pointId
|
|
@@ -313,8 +329,10 @@ module.exports = function(RED) {
|
|
|
313
329
|
updateStatus();
|
|
314
330
|
}
|
|
315
331
|
} else {
|
|
316
|
-
// Response without matching request -
|
|
317
|
-
|
|
332
|
+
// Response without matching request - duplicate, stale, or already timed-out
|
|
333
|
+
// This is normal when remote has multiple WebSocket nodes or response arrives after timeout cleanup
|
|
334
|
+
// Don't change node status - just log at trace level
|
|
335
|
+
node.trace(`Ignoring duplicate/stale response for point #${responsePointId}`);
|
|
318
336
|
}
|
|
319
337
|
|
|
320
338
|
if (done) done();
|
|
@@ -358,6 +376,11 @@ module.exports = function(RED) {
|
|
|
358
376
|
// Node lifecycle
|
|
359
377
|
// ====================================================================
|
|
360
378
|
node.on("close", function(done) {
|
|
379
|
+
// Clear startup timer
|
|
380
|
+
if (node.startupTimer) {
|
|
381
|
+
clearTimeout(node.startupTimer);
|
|
382
|
+
node.startupTimer = null;
|
|
383
|
+
}
|
|
361
384
|
// Clear pending requests on close
|
|
362
385
|
node.pendingRequests = {};
|
|
363
386
|
// Remove event listeners
|
|
@@ -369,6 +392,15 @@ module.exports = function(RED) {
|
|
|
369
392
|
// ====================================================================
|
|
370
393
|
// Initialize
|
|
371
394
|
// ====================================================================
|
|
395
|
+
// One-shot timer to guarantee startup delay completes even if no messages arrive
|
|
396
|
+
node.startupTimer = setTimeout(() => {
|
|
397
|
+
if (!node.startupComplete) {
|
|
398
|
+
node.startupComplete = true;
|
|
399
|
+
updateStatus();
|
|
400
|
+
}
|
|
401
|
+
node.startupTimer = null;
|
|
402
|
+
}, node.startupDelay * 1000);
|
|
403
|
+
|
|
372
404
|
updateStatus();
|
|
373
405
|
}
|
|
374
406
|
|
package/nodes/or-block.js
CHANGED
|
@@ -48,7 +48,7 @@ module.exports = function(RED) {
|
|
|
48
48
|
node.inputs[slotVal.index - 1] = Boolean(msg.payload);
|
|
49
49
|
const result = node.inputs.some(v => v === true);
|
|
50
50
|
const isUnchanged = result === lastResult && node.inputs.every((v, i) => v === lastInputs[i]);
|
|
51
|
-
const statusText = `
|
|
51
|
+
const statusText = `[${node.inputs.join(", ")}] -> ${result}`;
|
|
52
52
|
|
|
53
53
|
// ================================================================
|
|
54
54
|
// Debounce: Suppress consecutive same outputs within 500ms
|
package/nodes/priority-block.js
CHANGED
|
@@ -174,7 +174,7 @@ module.exports = function(RED) {
|
|
|
174
174
|
send(currentOutput);
|
|
175
175
|
const inDisplay = typeof msg.payload === "number" ? msg.payload.toFixed(2) : typeof msg.payload === "object" ? JSON.stringify(msg.payload).slice(0, 20) : msg.payload;
|
|
176
176
|
const outDisplay = currentOutput.payload === null ? "null" : typeof currentOutput.payload === "number" ? currentOutput.payload.toFixed(2) : currentOutput.payload;
|
|
177
|
-
const statusText = `
|
|
177
|
+
const statusText = `out: ${outDisplay}, slot: ${currentOutput.diagnostics.activePriority || "none"}`;
|
|
178
178
|
utils.setStatusChanged(node, statusText);
|
|
179
179
|
|
|
180
180
|
if (done) done();
|