@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.
@@ -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, `mode: ${node.removeLabel ? "remove" : "set"}, property: ${node.contextPropertyName}`);
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, `in: ${msg.payload}, out: removed context`);
26
+ utils.setStatusChanged(node, `${msg.payload} -> removed`);
27
27
  } else {
28
28
  msg.context = node.contextPropertyName;
29
- utils.setStatusChanged(node, `in: ${msg.payload}, out: ${node.contextPropertyName}`);
29
+ utils.setStatusChanged(node, `${msg.payload} -> ${node.contextPropertyName}`);
30
30
  }
31
31
 
32
32
  send(msg);
@@ -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) * (config.delayOnUnits === "seconds" ? 1000 : config.delayOnUnits === "minutes" ? 60000 : 1);
13
- node.delayOff = parseFloat(config.delayOff) * (config.delayOffUnits === "seconds" ? 1000 : config.delayOffUnits === "minutes" ? 60000 : 1);
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(node.delayOn),
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(node.delayOff),
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] * (config.delayOnUnits === "seconds" ? 1000 : config.delayOnUnits === "minutes" ? 60000 : 1);
63
- if (!isNaN(results[1])) node.delayOff = results[1] * (config.delayOffUnits === "seconds" ? 1000 : config.delayOffUnits === "minutes" ? 60000 : 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
- msg.payload = true;
174
- delete msg.context;
197
+ delayedMsg.payload = true;
198
+ delete delayedMsg.context;
175
199
  utils.setStatusChanged(node, "in: true, out: true");
176
- send(msg);
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
- msg.payload = false;
192
- delete msg.context;
235
+ delayedMsg.payload = false;
236
+ delete delayedMsg.context;
193
237
  utils.setStatusChanged(node, "in: false, out: false");
194
- send(msg);
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
  }
@@ -10,10 +10,11 @@ module.exports = function(RED) {
10
10
  node.detail = config.detail;
11
11
 
12
12
  let setterNode = null;
13
- let retryAction = null;
14
- let healthCheckAction = null;
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 (retryAction) {
89
- clearInterval(retryAction);
90
- retryAction = null;
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
- retryAction = () => {
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(retryAction, retryDelays[Math.min(retryCount, maxRetries - 1)]);
126
- };
127
- setTimeout(retryAction, retryDelays[0]);
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
- if (healthCheckAction) clearInterval(healthCheckAction);
164
- if (retryAction) clearInterval(retryAction);
165
- if (removed && updateListener) {
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
  });
@@ -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();
@@ -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}.json`);
24
- // Legacy file path for migration
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}.json`);
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.json`);
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
- // Only add prefix newline if file exists and has content (simplified check)
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
- let prefix = '';
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}.json`);
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, `sent: ${valueString}`);
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, `sent: ${valueString}`);
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, `sent: ${valueString}`);
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, `sent: ${valueString}`);
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
- // Silently drop timeout during startup
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
- // Silently drop timeout during startup
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 responsePointId = msg.network?.pointId ?? msg.status?.pointId ?? msg.pointId;
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 pointId (value can be null/undefined on error)
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 - could be stale or unsolicited
317
- utils.setStatusWarn(node, `Unmatched response for point #${responsePointId}`);
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 = `in: [${node.inputs.join(", ")}], out: ${result}`;
51
+ const statusText = `[${node.inputs.join(", ")}] -> ${result}`;
52
52
 
53
53
  // ================================================================
54
54
  // Debounce: Suppress consecutive same outputs within 500ms
@@ -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 = `in: ${inDisplay}, out: ${outDisplay}, slot: ${currentOutput.diagnostics.activePriority || "none"}`;
177
+ const statusText = `out: ${outDisplay}, slot: ${currentOutput.diagnostics.activePriority || "none"}`;
178
178
  utils.setStatusChanged(node, statusText);
179
179
 
180
180
  if (done) done();