@bldgblocks/node-red-contrib-control 0.1.38 → 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.
- package/LICENSE.md +38 -5
- package/nodes/accumulate-block.html +3 -3
- package/nodes/alarm-collector.js +18 -5
- package/nodes/alarm-config.html +11 -6
- package/nodes/alarm-config.js +34 -35
- package/nodes/boolean-switch-block.html +27 -14
- package/nodes/boolean-switch-block.js +22 -12
- package/nodes/global-getter.html +173 -12
- package/nodes/global-setter.html +37 -0
- package/nodes/global-setter.js +89 -10
- package/nodes/history-collector.html +3 -1
- package/nodes/history-config.html +8 -2
- package/nodes/network-point-read.js +1 -1
- package/nodes/network-point-register.html +1 -1
- package/nodes/network-service-registry.html +236 -27
- package/nodes/network-service-registry.js +1 -1
- package/package.json +2 -2
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
|
|
219
|
+
Copyright 2024-2026 buildingblocks
|
|
191
220
|
|
|
192
|
-
Licensed under the Apache License, Version 2.0
|
|
193
|
-
you may not use this file
|
|
194
|
-
You may obtain a copy of the
|
|
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.
|
|
@@ -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
|
|
65
|
-
- **Accumulate False
|
|
66
|
-
- **Accumulate Flows
|
|
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
|
|
package/nodes/alarm-collector.js
CHANGED
|
@@ -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
|
-
|
|
184
|
-
|
|
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.
|
|
304
|
+
node.alarmConfig.unregister(node.id);
|
|
292
305
|
}
|
|
293
306
|
|
|
294
307
|
// Remove global value-changed listener
|
package/nodes/alarm-config.html
CHANGED
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
const $list = $("#node-input-alarm-list");
|
|
37
37
|
|
|
38
38
|
function loadAlarms() {
|
|
39
|
-
$.getJSON(
|
|
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,
|
|
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}]
|
|
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
|
-
|
|
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
|
|
package/nodes/alarm-config.js
CHANGED
|
@@ -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
|
|
12
|
-
// Format: { "
|
|
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(
|
|
17
|
-
if (!
|
|
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(
|
|
22
|
-
const existing = node.alarms.get(
|
|
23
|
-
//
|
|
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(
|
|
26
|
+
node.alarms.set(nodeId, meta);
|
|
31
27
|
return true;
|
|
32
28
|
};
|
|
33
29
|
|
|
34
|
-
// Unregister an alarm
|
|
35
|
-
node.unregister = function(
|
|
36
|
-
|
|
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
|
|
42
|
-
node.lookup = function(
|
|
43
|
-
return node.alarms.get(
|
|
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(
|
|
48
|
-
if (node.alarms.has(
|
|
49
|
-
const alarm = node.alarms.get(
|
|
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 [
|
|
61
|
-
arr.push({
|
|
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
|
|
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(
|
|
102
|
+
// Check for the alarm — map is keyed by nodeId
|
|
103
|
+
entry = configNode.lookup(checkNodeId);
|
|
104
|
+
|
|
109
105
|
if (entry) {
|
|
110
|
-
|
|
111
|
-
|
|
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 (!
|
|
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
|
-
|
|
36
|
+
A boolean gate that routes messages based on a `true`/`false` switch state.
|
|
33
37
|
|
|
34
38
|
### Inputs
|
|
35
|
-
: context (string) :
|
|
36
|
-
: payload (any) : Flow data for `"inTrue"`
|
|
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`
|
|
40
|
-
: outFalse (msg) : Receives `msg`
|
|
41
|
-
: outControl (msg) : `
|
|
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
|
-
|
|
55
|
+
This node acts as a boolean gate/router.
|
|
45
56
|
|
|
46
|
-
|
|
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
|
|
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)
|
|
52
|
-
- Blue (dot)
|
|
53
|
-
- Blue (ring)
|
|
54
|
-
-
|
|
55
|
-
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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, `
|
|
46
|
-
send([msg, null,
|
|
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, `
|
|
52
|
-
send([null, msg,
|
|
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,
|
|
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();
|
package/nodes/global-getter.html
CHANGED
|
@@ -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
|
|
9
|
-
<input type="
|
|
10
|
-
<
|
|
11
|
-
<i class="fa fa-
|
|
12
|
-
</
|
|
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
|
-
|
|
97
|
-
|
|
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" &&
|
|
116
|
-
const selectedSourceId =
|
|
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
|
-
|
|
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 =
|
|
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>
|
package/nodes/global-setter.html
CHANGED
|
@@ -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> </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
|
package/nodes/global-setter.js
CHANGED
|
@@ -92,16 +92,46 @@ module.exports = function(RED) {
|
|
|
92
92
|
}
|
|
93
93
|
node.isBusy = true;
|
|
94
94
|
|
|
95
|
-
//
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
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 || []).
|
|
153
|
-
|
|
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(
|
|
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:
|
|
17
|
-
overflow-y:auto;
|
|
23
|
+
height:300px;
|
|
24
|
+
overflow-y:auto;
|
|
18
25
|
padding:5px;
|
|
19
26
|
box-sizing:border-box;">
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
37
|
-
const $
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
101
|
+
// ============================================================
|
|
102
|
+
// Render the editable table
|
|
103
|
+
// ============================================================
|
|
104
|
+
function renderTable(points) {
|
|
105
|
+
$tbody.empty();
|
|
106
|
+
$selectAll.prop('checked', false);
|
|
49
107
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
$
|
|
160
|
+
$tr.append($tdCb, $tdId, $tdPath, $tdBtn);
|
|
161
|
+
$tbody.append($tr);
|
|
67
162
|
});
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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"
|