@bldgblocks/node-red-contrib-control 0.1.37 → 0.2.0

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.
@@ -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,
@@ -91,16 +92,46 @@ module.exports = function(RED) {
91
92
  }
92
93
  node.isBusy = true;
93
94
 
94
- // Evaluate Dynamic Properties (Exact same logic as before)
95
+ // Resolve write priority three sources, in order of precedence:
96
+ // 1. msg.priority (number 1-16 or "default") — explicit per-message override
97
+ // 2. msg.context ("priority1"–"priority16" or "default") — tagged-input pattern (matches priority-block)
98
+ // 3. Configured writePriority (dropdown / msg / flow typed-input)
95
99
  try {
96
- const evaluations = [];
97
- evaluations.push(
98
- utils.requiresEvaluation(config.writePriorityType)
99
- ? utils.evaluateNodeProperty(config.writePriority, config.writePriorityType, node, msg)
100
- : Promise.resolve(node.writePriority)
101
- );
102
- const results = await Promise.all(evaluations);
103
- node.writePriority = results[0];
100
+ if (msg.hasOwnProperty("priority")) {
101
+ // Source 1: msg.priority (direct number or "default")
102
+ const mp = msg.priority;
103
+ if (mp === "default") {
104
+ node.writePriority = "default";
105
+ } else {
106
+ const p = parseInt(mp, 10);
107
+ if (isNaN(p) || p < 1 || p > 16) {
108
+ node.isBusy = false;
109
+ return utils.sendError(node, msg, done, `Invalid msg.priority: ${mp}`);
110
+ }
111
+ node.writePriority = String(p);
112
+ }
113
+ } else if (msg.hasOwnProperty("context") && typeof msg.context === "string") {
114
+ // Source 2: msg.context tagged-input ("priority8", "default", etc.)
115
+ // "reload" is handled separately below — skip it here
116
+ const ctx = msg.context;
117
+ const priorityMatch = /^priority([1-9]|1[0-6])$/.exec(ctx);
118
+ if (priorityMatch) {
119
+ node.writePriority = priorityMatch[1];
120
+ } else if (ctx === "default") {
121
+ node.writePriority = "default";
122
+ }
123
+ // Other contexts (e.g. "reload") fall through — config stays as-is
124
+ } else {
125
+ // Source 3: Configured typed-input (dropdown, msg path, flow variable)
126
+ const evaluations = [];
127
+ evaluations.push(
128
+ utils.requiresEvaluation(config.writePriorityType)
129
+ ? utils.evaluateNodeProperty(config.writePriority, config.writePriorityType, node, msg)
130
+ : Promise.resolve(node.writePriority)
131
+ );
132
+ const results = await Promise.all(evaluations);
133
+ node.writePriority = results[0];
134
+ }
104
135
  } catch (err) {
105
136
  throw new Error(`Property Eval Error: ${err.message}`);
106
137
  } finally {
@@ -120,7 +151,7 @@ module.exports = function(RED) {
120
151
  await utils.setGlobalState(node, node.varName, node.storeName, state);
121
152
 
122
153
  prefix = state.activePriority === 'default' ? '' : 'P';
123
- const statusText = `reload: ${prefix}${state.activePriority}:${state.value}${state.units}`;
154
+ const statusText = `reload: ${prefix}${state.activePriority}:${state.value}${state.units || ''}`;
124
155
 
125
156
  return utils.sendSuccess(node, { ...state }, done, statusText, null, "dot");
126
157
  }
@@ -158,8 +189,16 @@ module.exports = function(RED) {
158
189
 
159
190
  // Check for change
160
191
  if (value === state.value && priority === state.activePriority) {
192
+ // Ensure payload stays in sync with value
193
+ state.payload = state.value;
194
+ // Persist even when output unchanged — the priority array itself changed
195
+ await utils.setGlobalState(node, node.varName, node.storeName, state);
196
+ if (node.storeName !== 'default') {
197
+ await utils.setGlobalState(node, node.varName, 'default', state);
198
+ }
161
199
  prefix = `${node.writePriority === 'default' ? '' : 'P'}`;
162
- const noChangeText = `no change: ${prefix}${node.writePriority}:${state.value}${state.units}`;
200
+ const statePrefix = `${state.activePriority === 'default' ? '' : 'P'}`;
201
+ const noChangeText = `no change: ${prefix}${node.writePriority}:${inputValue} > active: ${statePrefix}${state.activePriority}:${state.value}${state.units || ''}`;
163
202
  utils.setStatusUnchanged(node, noChangeText);
164
203
  // Pass message through even if no context change
165
204
  send({ ...state });
@@ -199,7 +238,7 @@ module.exports = function(RED) {
199
238
 
200
239
  prefix = `${node.writePriority === 'default' ? '' : 'P'}`;
201
240
  const statePrefix = `${state.activePriority === 'default' ? '' : 'P'}`;
202
- const statusText = `write: ${prefix}${node.writePriority}:${inputValue}${state.units} > active: ${statePrefix}${state.activePriority}:${state.value}${state.units}`;
241
+ const statusText = `write: ${prefix}${node.writePriority}:${inputValue}${state.units || ''} > active: ${statePrefix}${state.activePriority}:${state.value}${state.units || ''}`;
203
242
 
204
243
  RED.events.emit("bldgblocks:global:value-changed", {
205
244
  key: node.varName,
@@ -216,8 +255,8 @@ module.exports = function(RED) {
216
255
  });
217
256
 
218
257
  node.on('close', function(removed, done) {
258
+ if (initTimer) { clearTimeout(initTimer); initTimer = null; }
219
259
  if (removed && node.varName) {
220
- RED.events.removeAllListeners("bldgblocks:global:value-changed");
221
260
  // Callback style safe for close
222
261
  node.context().global.set(node.varName, undefined, node.storeName, function() {
223
262
  done();
@@ -228,4 +267,47 @@ module.exports = function(RED) {
228
267
  });
229
268
  }
230
269
  RED.nodes.registerType("global-setter", GlobalSetterNode);
270
+
271
+ // --- Admin endpoint: Clear all priority slots for a given setter node ---
272
+ RED.httpAdmin.post('/global-setter/:id/clear-priorities', RED.auth.needsPermission('global-setter.write'), async function(req, res) {
273
+ const targetNode = RED.nodes.getNode(req.params.id);
274
+ if (!targetNode) {
275
+ return res.status(404).json({ error: "Node not found" });
276
+ }
277
+ try {
278
+ let state = await utils.getGlobalState(targetNode, targetNode.varName, targetNode.storeName);
279
+ if (!state || typeof state !== 'object' || !state.priority) {
280
+ return res.status(200).json({ message: "No state to clear" });
281
+ }
282
+ // Clear all 16 priority slots
283
+ for (let i = 1; i <= 16; i++) {
284
+ state.priority[i] = null;
285
+ }
286
+ // Recalculate winner (will fall back to default)
287
+ const { value, priority } = utils.getHighestPriority(state);
288
+ state.payload = value;
289
+ state.value = value;
290
+ state.activePriority = priority;
291
+ state.metadata.lastSet = new Date().toISOString();
292
+ state.metadata.sourceId = targetNode.id;
293
+
294
+ await utils.setGlobalState(targetNode, targetNode.varName, targetNode.storeName, state);
295
+ if (targetNode.storeName !== 'default') {
296
+ await utils.setGlobalState(targetNode, targetNode.varName, 'default', state);
297
+ }
298
+
299
+ RED.events.emit("bldgblocks:global:value-changed", {
300
+ key: targetNode.varName,
301
+ store: targetNode.storeName,
302
+ data: state
303
+ });
304
+ utils.setStatusOK(targetNode, `cleared: default:${state.value}`);
305
+ targetNode.send({ ...state });
306
+
307
+ res.status(200).json({ message: "Priorities cleared", value: state.value, activePriority: state.activePriority });
308
+ } catch (err) {
309
+ targetNode.error(`Clear priorities error: ${err.message}`);
310
+ res.status(500).json({ error: err.message });
311
+ }
312
+ });
231
313
  }
@@ -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);
@@ -95,7 +95,9 @@
95
95
 
96
96
  // 1. Update Series Dropdown
97
97
  const seriesOptions = configNode && configNode.series ?
98
- configNode.series.map(s => ({ value: s.seriesName, label: s.seriesName })) :
98
+ configNode.series
99
+ .map(s => ({ value: s.seriesName, label: s.seriesName }))
100
+ .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })) :
99
101
  [];
100
102
 
101
103
  seriesInput.typedInput('types', [{
@@ -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();
@@ -149,8 +149,11 @@
149
149
  );
150
150
  }
151
151
 
152
- (node.series || []).forEach(addRow);
153
- if (node.series.length === 0) {
152
+ const sortedSeries = (node.series || []).slice().sort((a, b) =>
153
+ (a.seriesName || '').localeCompare(b.seriesName || '', undefined, { sensitivity: 'base' })
154
+ );
155
+ sortedSeries.forEach(addRow);
156
+ if (sortedSeries.length === 0) {
154
157
  addRow({seriesName: "OutsideTemp", seriesUnits: "°F"});
155
158
  }
156
159
 
@@ -223,6 +226,9 @@
223
226
  RED.notify("At least one valid series is required", "error");
224
227
  throw new Error("No valid series");
225
228
  }
229
+ series.sort((a, b) =>
230
+ (a.seriesName || '').localeCompare(b.seriesName || '', undefined, { sensitivity: 'base' })
231
+ );
226
232
  this.series = series;
227
233
  this.name = cleanSaveStr($("#node-config-input-name").val()) || 'default';
228
234
  }
@@ -147,9 +147,14 @@ 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
- node.error(errorText); // Show in debug panel
157
+ //node.error(errorText); // Show in debug panel
153
158
  // Don't update cache on error, keep stale value
154
159
  return;
155
160
  }
@@ -126,7 +126,7 @@
126
126
  });
127
127
 
128
128
  // Also check deployed registry for any points not visible in editor
129
- $.getJSON(`/network-point-registry/list/${registry}`, function (data) {
129
+ $.getJSON(`network-point-registry/list/${registry}`, function (data) {
130
130
  const maxDeployedId = data.reduce((max, pt) => Math.max(max, pt.id), 0);
131
131
  const next = Math.max(maxEditorId, maxDeployedId) + 1;
132
132
  idInput.val(next);
@@ -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