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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -43,3 +43,13 @@ Search for the package name and add to your project.
43
43
  - $ npm install node-red-contrib-buildingblocks-control
44
44
  # then restart node-red
45
45
  ```
46
+
47
+ ## Testing
48
+ Tests use [Mocha](https://mochajs.org/) and [node-red-node-test-helper](https://github.com/node-red/node-red-node-test-helper) to run nodes in an isolated, in-memory Node-RED runtime. Your live Node-RED instance is never touched.
49
+
50
+ ```bash
51
+ npm install # install dev dependencies (mocha, test helper)
52
+ npm test # run all tests
53
+ ```
54
+
55
+ Test files live in `test/` and follow the naming convention `*_spec.js`. Shared utilities are in `test/test-helpers.js`.
@@ -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
 
@@ -96,6 +96,7 @@
96
96
  <div class="form-row">
97
97
  <label for="node-input-message"><i class="fa fa-comment"></i> Message</label>
98
98
  <input type="text" id="node-input-message" placeholder="Zone exceeds setpoint">
99
+ <input type="hidden" id="node-input-messageType">
99
100
  </div>
100
101
 
101
102
  <div class="form-row">
@@ -131,6 +132,7 @@
131
132
  topic: { value: "Alarms_Default" },
132
133
  title: { value: "Alarm" },
133
134
  message: { value: "Condition triggered" },
135
+ messageType: { value: "str" },
134
136
  tags: { value: "" },
135
137
  units: { value: "°F" }
136
138
  },
@@ -197,6 +199,15 @@
197
199
  typeField: "#node-input-inputFieldType"
198
200
  }).typedInput("type", node.inputFieldType || "msg").typedInput("value", node.inputField || "payload");
199
201
 
202
+ // Setup message typedInput: static string or from msg property
203
+ $("#node-input-message").typedInput({
204
+ types: [
205
+ "str",
206
+ "msg"
207
+ ],
208
+ typeField: "#node-input-messageType"
209
+ }).typedInput("type", node.messageType || "str").typedInput("value", node.message || "Condition triggered");
210
+
200
211
  // Show/hide sections based on inputMode
201
212
  const updateDisplay = () => {
202
213
  let mode = $("#node-input-inputMode").val();
@@ -20,6 +20,7 @@ module.exports = function(RED) {
20
20
  node.topic = config.topic || "Alarms_Default";
21
21
  node.title = config.title || "Alarm";
22
22
  node.message = config.message || "Condition triggered";
23
+ node.messageType = config.messageType || "str";
23
24
  node.tags = config.tags || "";
24
25
  node.units = config.units || "";
25
26
 
@@ -45,6 +46,20 @@ module.exports = function(RED) {
45
46
  node.conditionMet = false;
46
47
  node.valueChangedListener = null;
47
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
+
48
63
  utils.setStatusOK(node, `idle`);
49
64
 
50
65
  // ====================================================================
@@ -179,15 +194,14 @@ module.exports = function(RED) {
179
194
 
180
195
  // Register/update alarm in registry
181
196
  if (node.alarmConfig) {
182
- const alarmName = node.name;
183
- node.alarmConfig.register(alarmName, {
184
- nodeId: node.id,
185
- pointId: node.currentValue,
197
+ node.alarmConfig.register(node.id, {
198
+ name: node.name,
186
199
  severity: node.priority,
187
200
  status: node.alarmState ? 'active' : 'cleared',
188
201
  title: node.title,
189
202
  message: node.message,
190
203
  topic: node.topic,
204
+ value: node.currentValue,
191
205
  timestamp: new Date().toISOString()
192
206
  });
193
207
  }
@@ -257,6 +271,18 @@ module.exports = function(RED) {
257
271
  }
258
272
  }
259
273
 
274
+ // Resolve message dynamically if configured as msg property
275
+ if (node.messageType === "msg") {
276
+ try {
277
+ const resolved = await utils.evaluateNodeProperty(config.message, "msg", node, msg);
278
+ if (resolved !== undefined && resolved !== null) {
279
+ node.message = String(resolved);
280
+ }
281
+ } catch (e) {
282
+ // Keep existing message on error
283
+ }
284
+ }
285
+
260
286
  evaluateAndEmit(inputValue);
261
287
  } catch (err) {
262
288
  utils.setStatusError(node, `Error reading input: ${err.message}`);
@@ -275,7 +301,7 @@ module.exports = function(RED) {
275
301
 
276
302
  // Unregister alarm from registry
277
303
  if (node.alarmConfig) {
278
- node.alarmConfig.unregister(node.name, node.id);
304
+ node.alarmConfig.unregister(node.id);
279
305
  }
280
306
 
281
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
 
@@ -48,7 +48,7 @@ module.exports = function(RED) {
48
48
 
49
49
  // Send alarm message with status
50
50
  const msg = {
51
- payload: eventData,
51
+ alarm: eventData,
52
52
  status: { state: "triggered", transition: eventData.transition },
53
53
  activeAlarmCount: activeCount,
54
54
  alarmKey: key
@@ -71,7 +71,7 @@ module.exports = function(RED) {
71
71
 
72
72
  // Send clear message with status
73
73
  const msg = {
74
- payload: eventData,
74
+ alarm: eventData,
75
75
  status: { state: "cleared", transition: eventData.transition },
76
76
  activeAlarmCount: activeCount,
77
77
  alarmKey: key
@@ -48,7 +48,7 @@ module.exports = function(RED) {
48
48
  node.inputs[slotVal.index - 1] = Boolean(msg.payload);
49
49
  const result = node.inputs.every(v => v === true);
50
50
  const isUnchanged = result === lastResult && node.inputs.every((v, i) => v === lastInputs[i]);
51
- const statusText = `in: [${node.inputs.join(", ")}], out: ${result}`;
51
+ const statusText = `[${node.inputs.join(", ")}] -> ${result}`;
52
52
 
53
53
  // ================================================================
54
54
  // Debounce: Suppress consecutive same outputs within 500ms
@@ -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();