@bldgblocks/node-red-contrib-control 0.1.38 → 0.2.1

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/LICENSE.md CHANGED
@@ -1,4 +1,33 @@
1
1
 
2
+ # Commons Clause License Condition v1.0
3
+
4
+ The Software is provided to you by the Licensor under the License, as
5
+ defined below, subject to the following condition.
6
+
7
+ Without limiting other conditions in the License, the grant of rights
8
+ under the License will not include, and the License does not grant to
9
+ you, the right to Sell the Software.
10
+
11
+ For purposes of the foregoing, "Sell" means practicing any or all of
12
+ the rights granted to you under the License to provide to third parties,
13
+ for a fee or other consideration (including without limitation fees for
14
+ hosting or consulting/support services related to the Software), a
15
+ product or service whose value derives, entirely or substantially, from
16
+ the functionality of the Software. Any license notice or attribution
17
+ required by the License must also include this Commons Clause License
18
+ Condition notice.
19
+
20
+ **Software:** node-red-contrib-control
21
+
22
+ **License:** Apache 2.0
23
+
24
+ **Licensor:** buildingblocks
25
+
26
+ For commercial licensing inquiries, contact the Licensor via the
27
+ repository at https://github.com/BldgBlocks/node-red-contrib-control
28
+
29
+ ---
30
+
2
31
  Apache License
3
32
  Version 2.0, January 2004
4
33
  http://www.apache.org/licenses/
@@ -187,11 +216,12 @@
187
216
  same "printed page" as the copyright notice for easier
188
217
  identification within third-party archives.
189
218
 
190
- Copyright [yyyy] [name of copyright owner]
219
+ Copyright 2024-2026 buildingblocks
191
220
 
192
- Licensed under the Apache License, Version 2.0 (the "License");
193
- you may not use this file except in compliance with the License.
194
- You may obtain a copy of the License at
221
+ Licensed under the Apache License, Version 2.0, with Commons Clause
222
+ License Condition v1.0 (the "License"); you may not use this file
223
+ except in compliance with the License. You may obtain a copy of the
224
+ License at
195
225
 
196
226
  http://www.apache.org/licenses/LICENSE-2.0
197
227
 
@@ -199,4 +229,7 @@
199
229
  distributed under the License is distributed on an "AS IS" BASIS,
200
230
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
231
  See the License for the specific language governing permissions and
202
- limitations under the License.
232
+ limitations under the License.
233
+
234
+ This software is subject to the Commons Clause License Condition v1.0
235
+ as described at the top of this file.
package/README.md CHANGED
@@ -40,7 +40,7 @@ Search for the package name and add to your project.
40
40
  ```
41
41
  # Navigate to Node-RED user directory (varies by installation)
42
42
  - $ cd ~/.node-red
43
- - $ npm install node-red-contrib-buildingblocks-control
43
+ - $ npm install node-red-contrib-control
44
44
  # then restart node-red
45
45
  ```
46
46
 
@@ -61,9 +61,9 @@ Counts consecutive inputs from a configured property based on the selected mode,
61
61
 
62
62
  ### Details
63
63
  Counts inputs according to the selected mode, reading from the configured **Input Property** (default: `msg.payload`):
64
- - **Accumulate True**: counts consecutive `true` values (resets on `false` or explicit reset)
65
- - **Accumulate False**: counts consecutive `false` values (resets on `true` or explicit reset)
66
- - **Accumulate Flows**: counts all valid input messages (resets only on explicit reset)
64
+ - **Accumulate True** counts consecutive `true` values (resets on `false` or explicit reset)
65
+ - **Accumulate False** counts consecutive `false` values (resets on `true` or explicit reset)
66
+ - **Accumulate Flows** counts all valid input messages (resets only on explicit reset)
67
67
 
68
68
  Reset via `msg.context = "reset"` with `msg.payload = true`.
69
69
 
@@ -46,6 +46,20 @@ module.exports = function(RED) {
46
46
  node.conditionMet = false;
47
47
  node.valueChangedListener = null;
48
48
 
49
+ // Register with alarm-config at startup so the registry knows about this collector
50
+ if (node.alarmConfig) {
51
+ node.alarmConfig.register(node.id, {
52
+ name: node.name,
53
+ severity: node.priority,
54
+ status: 'cleared',
55
+ title: node.title,
56
+ message: node.message,
57
+ topic: node.topic,
58
+ value: null,
59
+ timestamp: new Date().toISOString()
60
+ });
61
+ }
62
+
49
63
  utils.setStatusOK(node, `idle`);
50
64
 
51
65
  // ====================================================================
@@ -180,15 +194,14 @@ module.exports = function(RED) {
180
194
 
181
195
  // Register/update alarm in registry
182
196
  if (node.alarmConfig) {
183
- const alarmName = node.name;
184
- node.alarmConfig.register(alarmName, {
185
- nodeId: node.id,
186
- pointId: node.currentValue,
197
+ node.alarmConfig.register(node.id, {
198
+ name: node.name,
187
199
  severity: node.priority,
188
200
  status: node.alarmState ? 'active' : 'cleared',
189
201
  title: node.title,
190
202
  message: node.message,
191
203
  topic: node.topic,
204
+ value: node.currentValue,
192
205
  timestamp: new Date().toISOString()
193
206
  });
194
207
  }
@@ -288,7 +301,7 @@ module.exports = function(RED) {
288
301
 
289
302
  // Unregister alarm from registry
290
303
  if (node.alarmConfig) {
291
- node.alarmConfig.unregister(node.name, node.id);
304
+ node.alarmConfig.unregister(node.id);
292
305
  }
293
306
 
294
307
  // Remove global value-changed listener
@@ -36,10 +36,10 @@
36
36
  const $list = $("#node-input-alarm-list");
37
37
 
38
38
  function loadAlarms() {
39
- $.getJSON(`/alarm-config/list/${node.id}`, function(data) {
39
+ $.getJSON(`alarm-config/list/${node.id}`, function(data) {
40
40
  $list.empty();
41
41
  if (!data.length) {
42
- return $list.append('<li style="color:#999;">No alarms registered</li>');
42
+ return $list.append('<li style="color:#999;">No alarms registered (deploy first if newly added)</li>');
43
43
  }
44
44
 
45
45
  // Data already sorted by server
@@ -63,9 +63,10 @@
63
63
  width: '16px'
64
64
  });
65
65
 
66
- // Alarm info (name, severity, point)
66
+ // Alarm info (name, severity, topic)
67
+ const valuePart = alarm.value != null ? ` val:${alarm.value}` : '';
67
68
  const infoText = $('<span>')
68
- .html(`<strong>${alarm.name}</strong> <span style="color:#666;font-size:0.9em;">[${alarm.severity}] #${alarm.pointId}</span>`)
69
+ .html(`<strong>${alarm.name}</strong> <span style="color:#666;font-size:0.9em;">[${alarm.severity}] ${alarm.topic}${valuePart}</span>`)
69
70
  .css({ flexGrow: 1 });
70
71
 
71
72
  // Reveal button (find node on canvas)
@@ -80,8 +81,12 @@
80
81
  li.append(statusDot, infoText, revealBtn);
81
82
  $list.append(li);
82
83
  });
83
- }).fail(function() {
84
- $list.html('<li style="color:red;">Could not load alarms</li>');
84
+ }).fail(function(jqXHR) {
85
+ if (jqXHR.status === 401 || jqXHR.status === 403) {
86
+ $list.html('<li style="color:red;">Permission denied (alarm-config.read)</li>');
87
+ } else {
88
+ $list.html('<li style="color:red;">Could not load alarms (HTTP ' + jqXHR.status + ')</li>');
89
+ }
85
90
  });
86
91
  }
87
92
 
@@ -8,45 +8,39 @@ module.exports = function(RED) {
8
8
  const utils = require('./utils')(RED);
9
9
  utils.registerRegistryNode(node);
10
10
 
11
- // The Map: stores alarm metadata by name
12
- // Format: { "alarmName": { nodeId: "abc.123", pointId: 101, severity: "high", status: "active", ... } }
11
+ // The Map: stores alarm metadata keyed by collector node ID (always unique)
12
+ // Format: { "nodeId": { name: "Alarm Name", severity: "high", status: "active", ... } }
13
13
  node.alarms = new Map();
14
14
 
15
- // Register an alarm in the registry
16
- node.register = function(alarmName, meta) {
17
- if (!alarmName || typeof alarmName !== 'string') {
15
+ // Register an alarm in the registry (keyed by nodeId for uniqueness)
16
+ node.register = function(nodeId, meta) {
17
+ if (!nodeId || typeof nodeId !== 'string') {
18
18
  return false;
19
19
  }
20
20
 
21
- if (node.alarms.has(alarmName)) {
22
- const existing = node.alarms.get(alarmName);
23
- // Allow update if it's the same node
24
- if (existing.nodeId !== meta.nodeId) {
25
- return false;
26
- }
27
- // Merge updates (preserving status if provided)
21
+ if (node.alarms.has(nodeId)) {
22
+ const existing = node.alarms.get(nodeId);
23
+ // Merge updates (preserving existing fields not in new meta)
28
24
  meta = Object.assign({}, existing, meta);
29
25
  }
30
- node.alarms.set(alarmName, meta);
26
+ node.alarms.set(nodeId, meta);
31
27
  return true;
32
28
  };
33
29
 
34
- // Unregister an alarm
35
- node.unregister = function(alarmName, nodeId) {
36
- if (node.alarms.has(alarmName) && node.alarms.get(alarmName).nodeId === nodeId) {
37
- node.alarms.delete(alarmName);
38
- }
30
+ // Unregister an alarm by node ID
31
+ node.unregister = function(nodeId) {
32
+ node.alarms.delete(nodeId);
39
33
  };
40
34
 
41
- // Lookup an alarm by name
42
- node.lookup = function(alarmName) {
43
- return node.alarms.get(alarmName);
35
+ // Lookup an alarm by node ID
36
+ node.lookup = function(nodeId) {
37
+ return node.alarms.get(nodeId);
44
38
  };
45
39
 
46
- // Update alarm status
47
- node.updateStatus = function(alarmName, status) {
48
- if (node.alarms.has(alarmName)) {
49
- const alarm = node.alarms.get(alarmName);
40
+ // Update alarm status by node ID
41
+ node.updateStatus = function(nodeId, status) {
42
+ if (node.alarms.has(nodeId)) {
43
+ const alarm = node.alarms.get(nodeId);
50
44
  alarm.status = status; // 'active' or 'cleared'
51
45
  alarm.lastUpdate = new Date().toISOString();
52
46
  return true;
@@ -57,8 +51,8 @@ module.exports = function(RED) {
57
51
  // Get all alarms
58
52
  node.getAll = function() {
59
53
  const arr = [];
60
- for (const [name, meta] of node.alarms.entries()) {
61
- arr.push({ name, ...meta });
54
+ for (const [nodeId, meta] of node.alarms.entries()) {
55
+ arr.push({ nodeId, ...meta });
62
56
  }
63
57
  return arr;
64
58
  };
@@ -73,7 +67,8 @@ module.exports = function(RED) {
73
67
  // Find the alarm-config node
74
68
  const configNode = RED.nodes.getNode(configId);
75
69
  if (!configNode) {
76
- return res.status(404).json({ error: 'Configuration node not found' });
70
+ // Not deployed yet — return empty list so the editor can show a friendly message
71
+ return res.json([]);
77
72
  }
78
73
 
79
74
  // Get all alarms from this config
@@ -104,20 +99,24 @@ module.exports = function(RED) {
104
99
  return res.json({ status: result, warning: "Configuration not deployed" });
105
100
  }
106
101
 
107
- // Check for the alarm
108
- entry = configNode.lookup(alarmName);
102
+ // Check for the alarm — map is keyed by nodeId
103
+ entry = configNode.lookup(checkNodeId);
104
+
109
105
  if (entry) {
110
- // Collision if alarm exists AND belongs to a different node
111
- if (entry.nodeId !== checkNodeId) {
106
+ result = "assigned";
107
+ } else {
108
+ // Check if any other node has the same alarm name (name collision check)
109
+ const allAlarms = configNode.getAll();
110
+ const nameMatch = allAlarms.find(a => a.name === alarmName && a.nodeId !== checkNodeId);
111
+ if (nameMatch) {
112
112
  collision = true;
113
+ entry = nameMatch;
113
114
  }
114
115
  }
115
116
 
116
117
  if (collision) {
117
118
  result = "collision";
118
- } else if (!collision && entry) {
119
- result = "assigned";
120
- } else {
119
+ } else if (!entry) {
121
120
  result = "available";
122
121
  }
123
122
 
@@ -4,6 +4,10 @@
4
4
  <label for="node-input-name" title="Display name shown on the canvas"><i class="fa fa-tag"></i> Name</label>
5
5
  <input type="text" id="node-input-name" placeholder="Name">
6
6
  </div>
7
+ <div class="form-row">
8
+ <label for="node-input-state" title="Initial switch state"><i class="fa fa-toggle-on"></i> State</label>
9
+ <input type="checkbox" id="node-input-state" style="width: auto;">
10
+ </div>
7
11
  </script>
8
12
 
9
13
  <!-- JavaScript Section -->
@@ -29,30 +33,39 @@
29
33
 
30
34
  <!-- Help Section -->
31
35
  <script type="text/markdown" data-help-name="boolean-switch-block">
32
- Routes input flows to one of three outputs based on a boolean switch state.
36
+ A boolean gate that routes messages based on a `true`/`false` switch state.
33
37
 
34
38
  ### Inputs
35
- : context (string) : Configuration commands (`toggle`, `switch`, `inTrue`, `inFalse`).
36
- : payload (any) : Flow data for `"inTrue"` or `"inFalse"`.
39
+ : context (string) : Message tag: `"in"`, `"inTrue"`, `"inFalse"`, `"toggle"`, or `"switch"`.
40
+ : payload (any) : Flow data for `"in"` / `"inTrue"` / `"inFalse"`; boolean for `"switch"`.
41
+
42
+ #### Contexts
43
+ - **`"in"`** — Routes `msg` to `outTrue` when state is `true`, or `outFalse` when state is `false`. Always flows through.
44
+ - **`"inTrue"`** — Passes `msg` to `outTrue` only when state is `true`. Blocked with warning when `false`.
45
+ - **`"inFalse"`** — Passes `msg` to `outFalse` only when state is `false`. Blocked with warning when `true`.
46
+ - **`"toggle"`** — Flips the switch state and emits new state on `outControl`.
47
+ - **`"switch"`** — Sets the switch state to `!!msg.payload` and emits on `outControl`.
37
48
 
38
49
  ### Outputs
39
- : outTrue (msg) : Receives `msg` with `context "inTrue"` when switch is `true`.
40
- : outFalse (msg) : Receives `msg` with `context "inFalse"` when switch is `false`.
41
- : outControl (msg) : `msg.payload` set to switch state on toggle.
50
+ : outTrue (msg) : Receives `msg` when state is `true` (via `"in"` or `"inTrue"`).
51
+ : outFalse (msg) : Receives `msg` when state is `false` (via `"in"` or `"inFalse"`).
52
+ : outControl (msg) : Emits `{ payload: <boolean> }` on `"toggle"` or `"switch"` only.
42
53
 
43
54
  ### Details
44
- Routes input messages to one of three outputs based on a boolean switch state (`true` or `false`).
55
+ This node acts as a boolean gate/router.
45
56
 
46
- Messages with `msg.context = "inTrue"` are sent to `outTrue` when `state = true`, and messages with `msg.context = "inFalse"` are sent to `outFalse` when `state = false`.
57
+ Use `"in"` to auto-route messages to the correct output based on state messages always flow through.
58
+ Use `"inTrue"`/`"inFalse"` for conditional gating — messages are blocked (with yellow warning) when state doesn't match.
47
59
 
48
- State can be controlled directly with the `"switch"` property given a `msg.payload` boolean value.
60
+ State can be controlled with `"toggle"` (flip) or `"switch"` (set explicitly via payload).
61
+ The `outControl` port fires only on state commands, not on routed data messages.
49
62
 
50
63
  ### Status
51
- - Green (dot): Configuration update
52
- - Blue (dot): State changed
53
- - Blue (ring): State unchanged
54
- - Red (ring): Error
55
- - Yellow (ring): Warning
64
+ - **Green (dot)**: Message routed successfully
65
+ - **Blue (dot)**: State changed (`toggle` / `switch`)
66
+ - **Blue (ring)**: State unchanged (`switch` with same value)
67
+ - **Yellow (ring)**: Message blocked (`inTrue` when false, `inFalse` when true) or unknown context
68
+ - **Red (ring)**: Error (missing context, invalid message)
56
69
 
57
70
  ### References
58
71
  - [Node-RED Documentation](https://nodered.org/docs/)
@@ -5,8 +5,8 @@ module.exports = function(RED) {
5
5
  RED.nodes.createNode(this, config);
6
6
  const node = this;
7
7
 
8
- // Initialize state from config
9
- node.state = config.state;
8
+ // Initialize state from config (coerce to boolean)
9
+ node.state = !!config.state;
10
10
 
11
11
  // Set initial status
12
12
  utils.setStatusOK(node, `state: ${node.state}`);
@@ -30,31 +30,41 @@ module.exports = function(RED) {
30
30
 
31
31
  // Handle context commands
32
32
  switch (msg.context) {
33
- case "toggle":
33
+ case "toggle": {
34
34
  node.state = !node.state;
35
35
  utils.setStatusChanged(node, `state: ${node.state}`);
36
36
  send([null, null, { payload: node.state }]);
37
37
  break;
38
- case "switch":
39
- node.state = !!msg.payload;
40
- utils.setStatusChanged(node, `state: ${node.state}`);
38
+ }
39
+
40
+ case "switch": {
41
+ const newState = !!msg.payload;
42
+ if (newState === node.state) {
43
+ utils.setStatusUnchanged(node, `state: ${node.state}`);
44
+ } else {
45
+ node.state = newState;
46
+ utils.setStatusChanged(node, `state: ${node.state}`);
47
+ }
41
48
  send([null, null, { payload: node.state }]);
42
49
  break;
50
+ }
43
51
  case "inTrue":
44
52
  if (node.state) {
45
- utils.setStatusOK(node, `out: ${msg.payload}`);
46
- send([msg, null, { payload: node.state }]);
53
+ utils.setStatusOK(node, `outTrue: ${msg.payload}`);
54
+ send([msg, null, null]);
47
55
  }
48
56
  break;
57
+
49
58
  case "inFalse":
50
59
  if (!node.state) {
51
- utils.setStatusOK(node, `out: ${msg.payload}`);
52
- send([null, msg, { payload: node.state }]);
60
+ utils.setStatusOK(node, `outFalse: ${msg.payload}`);
61
+ send([null, msg, null]);
53
62
  }
54
63
  break;
64
+
55
65
  default:
56
- utils.setStatusWarn(node, "unknown context");
57
- if (done) done("Unknown context");
66
+ utils.setStatusWarn(node, `unknown context: ${msg.context}`);
67
+ if (done) done("Unknown context: " + msg.context);
58
68
  return;
59
69
  }
60
70
  if (done) done();
@@ -4,12 +4,22 @@
4
4
  <input type="text" id="node-input-name" placeholder="Name">
5
5
  </div>
6
6
 
7
- <div class="form-row">
8
- <label for="node-input-targetNode"><i class="fa fa-crosshairs"></i> Source</label>
9
- <input type="text" id="node-input-targetNode" style="width: calc(70% - 45px);">
10
- <button id="node-config-find-source" class="editor-button" style="margin-left: 5px; width: 40px;" title="Find Source Node">
11
- <i class="fa fa-search"></i>
12
- </button>
7
+ <div class="form-row" style="margin-bottom: 0px;">
8
+ <label><i class="fa fa-crosshairs"></i> Source</label>
9
+ <input type="hidden" id="node-input-targetNode">
10
+ <span id="node-input-source-toggle" style="float: right; cursor: pointer; padding: 2px 6px; font-size: 14px;" title="Collapse/Expand source list">
11
+ <i class="fa fa-caret-down"></i>
12
+ </span>
13
+ </div>
14
+ <div id="node-input-source-summary" style="display: none; padding: 4px 8px; margin-bottom: 5px; border: 1px solid #ddd; border-radius: 4px; background: transparent; color: #888; font-style: italic; cursor: pointer;" title="Click to expand">No source selected</div>
15
+ <div id="node-input-source-expanded">
16
+ <div style="position: relative; height: 30px; text-align: right;">
17
+ <div style="display: inline-block; vertical-align: middle;"><input type="text" id="node-input-source-filter"></div>
18
+ <button id="node-config-find-source" class="editor-button" style="width: 28px; height: 28px; vertical-align: middle; margin-left: 4px; display: inline-flex; align-items: center; justify-content: center;" title="Reveal Source Node">
19
+ <i class="fa fa-search"></i>
20
+ </button>
21
+ </div>
22
+ <div class="form-row node-input-source-row"></div>
13
23
  </div>
14
24
 
15
25
  <div class="form-row">
@@ -74,6 +84,7 @@
74
84
  paletteLabel: "global get",
75
85
  oneditprepare: function() {
76
86
  const node = this;
87
+ let initComplete = false;
77
88
 
78
89
  let candidateNodes = [];
79
90
  RED.nodes.eachNode(function(n) {
@@ -93,10 +104,137 @@
93
104
 
94
105
  candidateNodes.sort((a, b) => a.label.localeCompare(b.label));
95
106
 
96
- $("#node-input-targetNode").typedInput({
97
- types: [{ value: "target", options: candidateNodes }]
107
+ // Build treeList grouped by flow tab (like link-in node)
108
+ const sourceList = $("<div>").css({width: "100%", height: "100%"}).appendTo(".node-input-source-row")
109
+ .treeList({autoSelect: false});
110
+
111
+ const flowMap = {};
112
+ const flows = [];
113
+ RED.nodes.eachWorkspace(function(ws) {
114
+ flowMap[ws.id] = {
115
+ id: ws.id,
116
+ class: 'red-ui-palette-header',
117
+ label: (ws.label || ws.id) + (node.z === ws.id ? " *" : ""),
118
+ expanded: true,
119
+ children: []
120
+ };
121
+ flows.push(flowMap[ws.id]);
122
+ });
123
+
124
+ let candidateNodesCount = 0;
125
+ candidateNodes.forEach(function(opt) {
126
+ if (flowMap[RED.nodes.node(opt.value)?.z]) {
127
+ const targetZ = RED.nodes.node(opt.value).z;
128
+ flowMap[targetZ].children.push({
129
+ id: opt.value,
130
+ node: RED.nodes.node(opt.value),
131
+ label: opt.label,
132
+ radio: true,
133
+ radioGroup: "source-select",
134
+ selected: opt.value === node.targetNode
135
+ });
136
+ candidateNodesCount++;
137
+ }
138
+ });
139
+
140
+ const flowsFiltered = flows.filter(function(f) { return f.children.length > 0; });
141
+ sourceList.treeList('data', flowsFiltered);
142
+
143
+ // Show current node's flow tab
144
+ setTimeout(function() { sourceList.treeList('show', node.z); }, 100);
145
+
146
+ // searchBox filter (same pattern as link-in)
147
+ const search = $("#node-input-source-filter").searchBox({
148
+ style: "compact",
149
+ delay: 300,
150
+ change: function() {
151
+ const val = $(this).val().trim().toLowerCase();
152
+ if (val === "") {
153
+ sourceList.treeList("filter", null);
154
+ search.searchBox("count", "");
155
+ } else {
156
+ const count = sourceList.treeList("filter", function(item) {
157
+ return item.label && item.label.toLowerCase().indexOf(val) > -1;
158
+ });
159
+ search.searchBox("count", count + " / " + candidateNodesCount);
160
+ }
161
+ }
98
162
  });
99
163
 
164
+ // Helper to get the currently selected source from the treeList
165
+ function getSelectedSourceId() {
166
+ const items = sourceList.treeList('data');
167
+ for (let f = 0; f < items.length; f++) {
168
+ const children = items[f].children || [];
169
+ for (let c = 0; c < children.length; c++) {
170
+ if (children[c].selected) return children[c].id;
171
+ }
172
+ }
173
+ return "";
174
+ }
175
+
176
+ // --- Collapse / Expand toggle ---
177
+ const toggleBtn = $("#node-input-source-toggle");
178
+ const expandedSection = $("#node-input-source-expanded");
179
+ const summaryBar = $("#node-input-source-summary");
180
+ let sourceExpanded = true;
181
+
182
+ function getSelectedLabel() {
183
+ const id = getSelectedSourceId();
184
+ if (!id) return "No source selected";
185
+ const opt = candidateNodes.find(o => o.value === id);
186
+ return opt ? opt.label : "Unknown";
187
+ }
188
+
189
+ function resizeSourceList() {
190
+ if (!sourceExpanded) return;
191
+ var dialogForm = $("#dialog-form");
192
+ var height = dialogForm.height();
193
+ dialogForm.children().each(function() {
194
+ var $el = $(this);
195
+ if ($el.attr("id") === "node-input-source-expanded") {
196
+ // Subtract the search/button bar inside expanded, but not the source-row itself
197
+ $el.children().each(function() {
198
+ if (!$(this).hasClass("node-input-source-row")) {
199
+ height -= $(this).outerHeight(true);
200
+ }
201
+ });
202
+ } else {
203
+ height -= $el.outerHeight(true);
204
+ }
205
+ });
206
+ $(".node-input-source-row").css("height", Math.max(200, height) + "px");
207
+ }
208
+
209
+ function setSourceExpanded(expanded) {
210
+ sourceExpanded = expanded;
211
+ if (expanded) {
212
+ summaryBar.hide();
213
+ expandedSection.show();
214
+ toggleBtn.find("i").removeClass("fa-caret-right").addClass("fa-caret-down");
215
+ setTimeout(resizeSourceList, 50);
216
+ } else {
217
+ expandedSection.hide();
218
+ summaryBar.text(getSelectedLabel()).show();
219
+ toggleBtn.find("i").removeClass("fa-caret-down").addClass("fa-caret-right");
220
+ }
221
+ }
222
+
223
+ toggleBtn.on("click", function() { setSourceExpanded(!sourceExpanded); });
224
+ summaryBar.on("click", function() { setSourceExpanded(true); });
225
+
226
+ // Update summary text and hidden input when selection changes
227
+ sourceList.on('treelistselect', function() {
228
+ if (!initComplete) return;
229
+ $("#node-input-targetNode").val(getSelectedSourceId());
230
+ if (!sourceExpanded) summaryBar.text(getSelectedLabel());
231
+ });
232
+
233
+ // Start collapsed if a source is already selected
234
+ if (node.targetNode) {
235
+ setSourceExpanded(false);
236
+ }
237
+
100
238
  $("#node-input-outputProperty").typedInput({
101
239
  default: "msg",
102
240
  types: ["msg", "flow",
@@ -110,10 +248,12 @@
110
248
  }).typedInput("type", node.outputPropertyType || "msg").typedInput("value", node.outputProperty);
111
249
 
112
250
  function updateOutputValue() {
251
+ if (!initComplete) return;
113
252
  const currentType = $("#node-input-outputProperty").typedInput("type");
253
+ const currentValue = $("#node-input-outputProperty").typedInput("value");
114
254
 
115
- if (currentType === "dropdown" && node.outputProperty === "sourceToFlow") {
116
- const selectedSourceId = $("#node-input-targetNode").val();
255
+ if (currentType === "dropdown" && currentValue === "sourceToFlow") {
256
+ const selectedSourceId = getSelectedSourceId();
117
257
  const selectedOption = candidateNodes.find(opt => opt.value === selectedSourceId);
118
258
 
119
259
  if (selectedOption && selectedOption.path) {
@@ -122,14 +262,35 @@
122
262
  }
123
263
  }
124
264
 
125
- $("#node-input-targetNode").on("change", updateOutputValue);
265
+ sourceList.on('treelistselect', updateOutputValue);
126
266
  $("#node-input-outputProperty").on("change", updateOutputValue);
127
267
 
128
268
  $("#node-config-find-source").on("click", function() {
129
- const selectedId = $("#node-input-targetNode").val();
269
+ const selectedId = getSelectedSourceId();
130
270
  if (selectedId) { RED.view.reveal(selectedId); }
131
271
  else { RED.notify("Please select a source node first.", "warning"); }
132
272
  });
273
+
274
+ // Mark init complete - all event handlers now active
275
+ setTimeout(function() { initComplete = true; }, 200);
276
+ },
277
+ oneditresize: function(size) {
278
+ if ($("#node-input-source-expanded").is(":hidden")) return;
279
+ var dialogForm = $("#dialog-form");
280
+ var height = dialogForm.height();
281
+ dialogForm.children().each(function() {
282
+ var $el = $(this);
283
+ if ($el.attr("id") === "node-input-source-expanded") {
284
+ $el.children().each(function() {
285
+ if (!$(this).hasClass("node-input-source-row")) {
286
+ height -= $(this).outerHeight(true);
287
+ }
288
+ });
289
+ } else {
290
+ height -= $el.outerHeight(true);
291
+ }
292
+ });
293
+ $(".node-input-source-row").css("height", Math.max(200, height) + "px");
133
294
  }
134
295
  });
135
296
  </script>
@@ -25,6 +25,15 @@
25
25
  <input type="hidden" id="node-input-defaultValueType">
26
26
  </div>
27
27
 
28
+ <hr>
29
+
30
+ <div class="form-row">
31
+ <label>&nbsp;</label>
32
+ <button type="button" id="node-btn-clear-priorities" class="editor-button" style="width: calc(70% - 3px);" title="Clear all 16 priority slots on the live node (does not affect the default value)">
33
+ <i class="fa fa-eraser"></i> Clear All Priorities
34
+ </button>
35
+ </div>
36
+
28
37
  <div class="form-tips">
29
38
  <b>Note:</b> This node writes to the selected <b>Priority</b>, manually, by msg or flow. The actual Global Variable value will be the highest active priority.
30
39
  </div>
@@ -97,6 +106,30 @@
97
106
  }, "msg", "flow"],
98
107
  typeField: "#node-input-writePriorityType"
99
108
  }).typedInput("type", node.writePriorityType).typedInput("value", node.writePriority);
109
+
110
+ // Clear Priorities button — calls the admin endpoint on the live runtime node
111
+ $("#node-btn-clear-priorities").on("click", function() {
112
+ const btn = $(this);
113
+ if (!node.id) {
114
+ RED.notify("Node must be deployed before clearing priorities.", "warning");
115
+ return;
116
+ }
117
+ btn.prop("disabled", true).find("i").removeClass("fa-eraser").addClass("fa-spinner fa-spin");
118
+ $.ajax({
119
+ url: "global-setter/" + node.id + "/clear-priorities",
120
+ type: "POST",
121
+ success: function(data) {
122
+ RED.notify("Priorities cleared — active: " + (data.activePriority || "default") + ":" + data.value, "success");
123
+ },
124
+ error: function(jqXHR) {
125
+ const errMsg = jqXHR.responseJSON ? jqXHR.responseJSON.error : "Unknown error";
126
+ RED.notify("Failed to clear priorities: " + errMsg, "error");
127
+ },
128
+ complete: function() {
129
+ btn.prop("disabled", false).find("i").removeClass("fa-spinner fa-spin").addClass("fa-eraser");
130
+ }
131
+ });
132
+ });
100
133
  }
101
134
  });
102
135
  </script>
@@ -108,6 +141,8 @@ Manage a global variable in a repeatable way.
108
141
  ### Inputs
109
142
  : payload (any) : Input payload is passed through unchanged.
110
143
  : property (string) : The input property where the value is taken from.
144
+ : priority (number|string) : _Optional_. Overrides the configured Priority at runtime. Accepts `1`–`16` or `"default"`. For example, `msg.priority = 8` writes to priority slot 8.
145
+ : context (string) : _Optional_. Tagged-input priority routing (matches priority-block conventions). Accepts `"priority1"`–`"priority16"`, `"default"`, or `"reload"`. When both `msg.priority` and `msg.context` are present, `msg.priority` takes precedence.
111
146
  : units (string) : The units associated with the value, if any. Also supports nested units at `msg.<inputProperty>.units`.
112
147
 
113
148
  ### Outputs
@@ -120,6 +155,8 @@ This node allows you to set a global variable in one place, and retrieve it else
120
155
 
121
156
  When this node is deleted or the flow is redeployed, it will automatically remove (prune) the variable from the selected Context Store.
122
157
 
158
+ **Clear All Priorities** button (in editor): Resets all 16 priority slots to `null` on the live running node. The default value is preserved. The active value recalculates to the highest remaining priority (or falls back to default). The node must be deployed first.
159
+
123
160
  ### Status
124
161
  - Green (dot): Configuration update
125
162
  - Blue (dot): State changed
@@ -92,16 +92,46 @@ module.exports = function(RED) {
92
92
  }
93
93
  node.isBusy = true;
94
94
 
95
- // 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)
96
99
  try {
97
- const evaluations = [];
98
- evaluations.push(
99
- utils.requiresEvaluation(config.writePriorityType)
100
- ? utils.evaluateNodeProperty(config.writePriority, config.writePriorityType, node, msg)
101
- : Promise.resolve(node.writePriority)
102
- );
103
- const results = await Promise.all(evaluations);
104
- 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
+ }
105
135
  } catch (err) {
106
136
  throw new Error(`Property Eval Error: ${err.message}`);
107
137
  } finally {
@@ -161,8 +191,14 @@ module.exports = function(RED) {
161
191
  if (value === state.value && priority === state.activePriority) {
162
192
  // Ensure payload stays in sync with value
163
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
+ }
164
199
  prefix = `${node.writePriority === 'default' ? '' : 'P'}`;
165
- 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 || ''}`;
166
202
  utils.setStatusUnchanged(node, noChangeText);
167
203
  // Pass message through even if no context change
168
204
  send({ ...state });
@@ -231,4 +267,47 @@ module.exports = function(RED) {
231
267
  });
232
268
  }
233
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
+ });
234
313
  }
@@ -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', [{
@@ -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
  }
@@ -154,7 +154,7 @@ module.exports = function(RED) {
154
154
  }
155
155
  const errorText = `Read failed for point #${node.pointId}: ${data.errorMessage || "Unknown error"}`;
156
156
  utils.setStatusError(node, `Error: ${data.errorMessage || "Unknown error"}`);
157
- node.error(errorText); // Show in debug panel
157
+ //node.error(errorText); // Show in debug panel
158
158
  // Don't update cache on error, keep stale value
159
159
  return;
160
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);
@@ -8,19 +8,38 @@
8
8
  <p>This node maintains the mapping between Network Point IDs (integers) and Global Variables.</p>
9
9
  </div>
10
10
 
11
- <div class="form-row">
11
+ <div class="form-row" style="margin-bottom:4px;">
12
12
  <label><i class="fa fa-list-ul"></i> Points</label>
13
+ <div style="display:inline-block; vertical-align:top;">
14
+ <button type="button" id="node-input-renumber-btn" class="editor-button editor-button-small" title="Renumber selected points sequentially">
15
+ <i class="fa fa-sort-numeric-asc"></i> Renumber...
16
+ </button>
17
+ </div>
18
+ </div>
19
+ <div class="form-row">
13
20
  <div id="node-input-point-list-div"
14
21
  style="
15
22
  border:1px solid #ccc;
16
- height:200px; /* set a fixed height */
17
- overflow-y:auto; /* enable vertical scrolling */
23
+ height:300px;
24
+ overflow-y:auto;
18
25
  padding:5px;
19
26
  box-sizing:border-box;">
20
- <!-- The list will be injected by the JavaScript below -->
21
- <ul id="node-input-point-list" style="margin:0;padding-left:1.2em;"></ul>
27
+ <table id="node-input-point-table" style="width:100%;border-collapse:collapse;font-size:0.9em;">
28
+ <thead>
29
+ <tr style="border-bottom:2px solid #ccc;text-align:left;">
30
+ <th style="padding:4px;width:28px;"><input type="checkbox" id="node-input-select-all" title="Select all"></th>
31
+ <th style="padding:4px;width:70px;">ID</th>
32
+ <th style="padding:4px;">Path</th>
33
+ <th style="padding:4px;width:30px;"></th>
34
+ </tr>
35
+ </thead>
36
+ <tbody id="node-input-point-list"></tbody>
37
+ </table>
22
38
  </div>
23
39
  </div>
40
+ <div class="form-row">
41
+ <span id="node-input-point-status" style="font-size:0.85em;color:#666;"></span>
42
+ </div>
24
43
  </script>
25
44
 
26
45
  <script type="text/javascript">
@@ -33,44 +52,227 @@
33
52
  return this.name || "Point Registry";
34
53
  },
35
54
  oneditprepare: function() {
36
- const node = this;
37
- const $list = $("#node-input-point-list");
55
+ const configNode = this;
56
+ const $tbody = $("#node-input-point-list");
57
+ const $status = $("#node-input-point-status");
58
+ const $selectAll = $("#node-input-select-all");
38
59
 
60
+ // Track pending changes: { editorNodeId: newPointId }
61
+ const pendingChanges = {};
62
+
63
+ // ============================================================
64
+ // Load deployed points + merge in any editor-only register nodes
65
+ // ============================================================
39
66
  function loadPoints() {
40
- $.getJSON(`/network-point-registry/list/${node.id}`, function(data) {
41
- $list.empty();
42
- if (!data.length) return $list.append('<li>No points defined</li>');
67
+ // Gather all register nodes in the editor that reference this registry
68
+ const editorNodes = {};
69
+ RED.nodes.eachNode(function(n) {
70
+ if ((n.type === "network-point-register" || n.type === "network-register") && n.registry === configNode.id) {
71
+ editorNodes[n.id] = {
72
+ nodeId: n.id,
73
+ id: parseInt(n.pointId),
74
+ path: null, // will be filled from deployed data if available
75
+ store: null,
76
+ writable: !!n.writable,
77
+ editorName: n.name || ""
78
+ };
79
+ }
80
+ });
81
+
82
+ $.getJSON(`network-point-registry/list/${configNode.id}`, function(deployedData) {
83
+ // Merge deployed data into editor nodes (deployed has runtime path/store)
84
+ deployedData.forEach(function(pt) {
85
+ if (editorNodes[pt.nodeId]) {
86
+ editorNodes[pt.nodeId].path = pt.path;
87
+ editorNodes[pt.nodeId].store = pt.store;
88
+ } else {
89
+ // Deployed but not in editor (unusual — maybe just deleted)
90
+ editorNodes[pt.nodeId] = pt;
91
+ }
92
+ });
43
93
 
44
-
45
- data.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
94
+ renderTable(Object.values(editorNodes));
95
+ }).fail(function() {
96
+ // Fallback: render from editor nodes only
97
+ renderTable(Object.values(editorNodes));
98
+ });
99
+ }
46
100
 
47
- data.forEach(pt => {
48
- const li = $('<li>').css({ display:'flex', alignItems:'center' });
101
+ // ============================================================
102
+ // Render the editable table
103
+ // ============================================================
104
+ function renderTable(points) {
105
+ $tbody.empty();
106
+ $selectAll.prop('checked', false);
49
107
 
50
- // Text part: ID + path
51
- const txt = $('<span>')
52
- .text(`ID ${pt.id}: ${pt.path ?? 'not set'}`)
53
- .css({ flexGrow:1 }); // push button to the right
108
+ if (!points.length) {
109
+ $tbody.append('<tr><td colspan="4" style="padding:8px;color:#999;">No points defined (deploy first if newly added)</td></tr>');
110
+ $status.text("");
111
+ return;
112
+ }
54
113
 
55
- // Button part
56
- const btn = $('<button type="button" class="editor-button">')
57
- .attr('title','Find node')
114
+ points.sort((a, b) => (a.id || 0) - (b.id || 0));
115
+
116
+ points.forEach(function(pt) {
117
+ const $tr = $('<tr>').css({ borderBottom: '1px solid #eee' }).attr('data-node-id', pt.nodeId);
118
+
119
+ // Checkbox
120
+ const $cb = $('<input type="checkbox" class="point-select">');
121
+ const $tdCb = $('<td>').css({ padding: '3px 4px' }).append($cb);
122
+
123
+ // Editable ID input
124
+ const $idInput = $('<input type="number" class="point-id-input">')
125
+ .val(pt.id)
126
+ .attr('data-original-id', pt.id)
127
+ .css({ width: '55px', padding: '2px 4px', textAlign: 'right' });
128
+
129
+ $idInput.on('change input', function() {
130
+ const nid = pt.nodeId;
131
+ const newVal = parseInt($(this).val());
132
+ const origVal = parseInt($(this).attr('data-original-id'));
133
+
134
+ if (!isNaN(newVal) && newVal !== origVal) {
135
+ pendingChanges[nid] = newVal;
136
+ } else {
137
+ delete pendingChanges[nid];
138
+ }
139
+ validateIds();
140
+ });
141
+
142
+ const $tdId = $('<td>').css({ padding: '3px 4px' }).append($idInput);
143
+
144
+ // Path display
145
+ const displayPath = pt.path && pt.path !== "not ready" ? pt.path : (pt.editorName || 'not deployed');
146
+ const $tdPath = $('<td>').css({ padding: '3px 4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '200px' })
147
+ .attr('title', displayPath)
148
+ .text(displayPath);
149
+
150
+ // Reveal button
151
+ const $btn = $('<button type="button" class="editor-button editor-button-small">')
152
+ .attr('title', 'Find node on canvas')
58
153
  .html('<i class="fa fa-search"></i>')
59
- .on('click', function (e) {
154
+ .on('click', function(e) {
60
155
  e.stopPropagation();
61
-
62
156
  RED.view.reveal(pt.nodeId);
63
157
  });
158
+ const $tdBtn = $('<td>').css({ padding: '3px 4px' }).append($btn);
64
159
 
65
- li.append(txt, btn);
66
- $list.append(li);
160
+ $tr.append($tdCb, $tdId, $tdPath, $tdBtn);
161
+ $tbody.append($tr);
67
162
  });
68
- }).fail(function() {
69
- $list.html('<li style="color:red;">Could not load points</li>');
163
+
164
+ $status.text(points.length + " point" + (points.length !== 1 ? "s" : ""));
165
+ validateIds();
166
+ }
167
+
168
+ // ============================================================
169
+ // Validate all IDs — highlight duplicates
170
+ // ============================================================
171
+ function validateIds() {
172
+ const idCounts = {};
173
+ $tbody.find('.point-id-input').each(function() {
174
+ const val = parseInt($(this).val());
175
+ if (!isNaN(val)) {
176
+ idCounts[val] = (idCounts[val] || 0) + 1;
177
+ }
178
+ });
179
+
180
+ $tbody.find('.point-id-input').each(function() {
181
+ const val = parseInt($(this).val());
182
+ const orig = parseInt($(this).attr('data-original-id'));
183
+ const isDup = !isNaN(val) && idCounts[val] > 1;
184
+ const isChanged = !isNaN(val) && val !== orig;
185
+
186
+ $(this).css({
187
+ border: isDup ? '2px solid #d32f2f' : (isChanged ? '2px solid #1976d2' : ''),
188
+ backgroundColor: isDup ? '#f8a0a0' : (isChanged ? '#a0c8f0' : ''),
189
+ color: isDup ? '#7a0000' : (isChanged ? '#003060' : '')
190
+ });
70
191
  });
71
192
  }
72
193
 
194
+ // ============================================================
195
+ // Select All checkbox
196
+ // ============================================================
197
+ $selectAll.on('change', function() {
198
+ const checked = $(this).is(':checked');
199
+ $tbody.find('.point-select').prop('checked', checked);
200
+ });
201
+
202
+ // ============================================================
203
+ // Renumber button
204
+ // ============================================================
205
+ $("#node-input-renumber-btn").on('click', function() {
206
+ const $checked = $tbody.find('.point-select:checked');
207
+ if ($checked.length === 0) {
208
+ RED.notify("Select points to renumber using the checkboxes.", "warning");
209
+ return;
210
+ }
211
+
212
+ // Prompt for starting number
213
+ const startStr = prompt("Enter starting ID number for " + $checked.length + " selected point(s):", "1");
214
+ if (startStr === null) return;
215
+
216
+ const startId = parseInt(startStr);
217
+ if (isNaN(startId) || startId < 0) {
218
+ RED.notify("Invalid starting ID.", "error");
219
+ return;
220
+ }
221
+
222
+ // Renumber selected rows in current table order (top to bottom)
223
+ let nextId = startId;
224
+ $checked.each(function() {
225
+ const $row = $(this).closest('tr');
226
+ const $input = $row.find('.point-id-input');
227
+ const nid = $row.attr('data-node-id');
228
+ const origVal = parseInt($input.attr('data-original-id'));
229
+
230
+ $input.val(nextId);
231
+
232
+ if (nextId !== origVal) {
233
+ pendingChanges[nid] = nextId;
234
+ } else {
235
+ delete pendingChanges[nid];
236
+ }
237
+ nextId++;
238
+ });
239
+
240
+ validateIds();
241
+ });
242
+
243
+ // ============================================================
244
+ // Load on open
245
+ // ============================================================
73
246
  loadPoints();
247
+ },
248
+
249
+ oneditsave: function() {
250
+ // Apply pending ID changes to editor nodes
251
+ const $tbody = $("#node-input-point-list");
252
+ let changeCount = 0;
253
+
254
+ $tbody.find('tr[data-node-id]').each(function() {
255
+ const nid = $(this).attr('data-node-id');
256
+ const $input = $(this).find('.point-id-input');
257
+ const newVal = parseInt($input.val());
258
+ const origVal = parseInt($input.attr('data-original-id'));
259
+
260
+ if (!isNaN(newVal) && newVal !== origVal) {
261
+ // Update the editor node config
262
+ const editorNode = RED.nodes.node(nid);
263
+ if (editorNode) {
264
+ editorNode.pointId = newVal;
265
+ editorNode.changed = true;
266
+ editorNode.dirty = true;
267
+ changeCount++;
268
+ }
269
+ }
270
+ });
271
+
272
+ if (changeCount > 0) {
273
+ RED.nodes.dirty(true);
274
+ RED.notify(changeCount + " point ID" + (changeCount !== 1 ? "s" : "") + " updated. Deploy to apply.", "success");
275
+ }
74
276
  }
75
277
  });
76
278
  </script>
@@ -83,6 +285,13 @@ Maintains the mapping between integer Point IDs and global variable paths. This
83
285
 
84
286
  Create a single registry for your network of nodes and reference it from each network-register node.
85
287
 
288
+ ### Bulk ID Editing
289
+ The config panel lets you edit Point IDs directly in the table:
290
+
291
+ 1. **Inline editing**: Change any ID by typing in its input field. Changed IDs highlight blue; conflicts highlight red.
292
+ 2. **Renumber**: Select points with checkboxes, click **Renumber...**, enter a starting ID — selected points get sequential IDs in table order.
293
+ 3. **Deploy**: Click Done, then Deploy to apply changes. The register nodes update automatically.
294
+
86
295
  **API for Developers:**
87
296
  * `register(id, meta)`: Claim an ID.
88
297
  * `lookup(id)`: Find the path/store for an ID.
@@ -83,7 +83,7 @@ module.exports = function(RED) {
83
83
 
84
84
  RED.httpAdmin.get('/network-point-registry/list/:registryId', RED.auth.needsPermission('network-point-registry.read'), function(req, res) {
85
85
  const reg = RED.nodes.getNode(req.params.registryId);
86
- if (!reg) return res.status(404).json({error:'not found'});
86
+ if (!reg) return res.json([]); // Not deployed yet — return empty list
87
87
 
88
88
  // Convert Map to array
89
89
  const arr = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bldgblocks/node-red-contrib-control",
3
- "version": "0.1.38",
3
+ "version": "0.2.1",
4
4
  "description": "Sedona-inspired control nodes for Node-RED",
5
5
  "keywords": [
6
6
  "node-red",
@@ -93,7 +93,7 @@
93
93
  }
94
94
  },
95
95
  "author": "buildingblocks",
96
- "license": "Apache-2.0",
96
+ "license": "Apache-2.0 WITH Commons-Clause",
97
97
  "repository": {
98
98
  "type": "git",
99
99
  "url": "git+https://github.com/BldgBlocks/node-red-contrib-control.git"