@bldgblocks/node-red-contrib-control 0.1.30 → 0.1.32

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.
@@ -11,12 +11,28 @@
11
11
  <i class="fa fa-search"></i>
12
12
  </button>
13
13
  </div>
14
+
15
+ <div class="form-row">
16
+ <label for="node-input-updates"><i class="fa fa-bolt"></i> Trigger</label>
17
+ <select id="node-input-updates" style="width: 70%;">
18
+ <option value="input">Manual (On Input Only)</option>
19
+ <option value="always">Reactive (On Input & Update)</option>
20
+ </select>
21
+ </div>
14
22
 
15
23
  <div class="form-row">
16
24
  <label for="node-input-outputProperty"><i class="fa fa-arrow-right"></i> Output</label>
17
25
  <input type="text" id="node-input-outputProperty" placeholder="payload" style="width:70%;">
18
26
  </div>
19
27
 
28
+ <div class="form-row">
29
+ <label for="node-input-detail"><i class="fa fa-bolt"></i> Trigger</label>
30
+ <select id="node-input-detail" style="width: 70%;">
31
+ <option value="getValue">Get Simple Value</option>
32
+ <option value="getObject">Get Full Object</option>
33
+ </select>
34
+ </div>
35
+
20
36
  <div class="form-tips">
21
37
  <b>Note:</b> Targeting by Node ID allows the source path to change without breaking this link.
22
38
  </div>
@@ -29,7 +45,9 @@
29
45
  defaults: {
30
46
  name: { value: "" },
31
47
  targetNode: { value: "", required: true },
32
- outputProperty: { value: "payload", required: true }
48
+ outputProperty: { value: "payload", required: true },
49
+ updates: { value: "always", required: true },
50
+ detail: {value: "getObject", required: true }
33
51
  },
34
52
  inputs: 1,
35
53
  outputs: 1,
@@ -39,10 +57,10 @@
39
57
  const target = RED.nodes.node(this.targetNode);
40
58
  if (target) {
41
59
  let lbl = target.path || target.name;
42
- if(lbl && lbl.includes(":")) {
43
- lbl = lbl.split(":")[3];
60
+ if(lbl && lbl.startsWith("#") && lbl.includes(":")) {
61
+ lbl = lbl.substring(lbl.indexOf(":") + 1);
44
62
  }
45
- return "Get: " + lbl;
63
+ return "get: " + lbl;
46
64
  }
47
65
  }
48
66
  return this.name || "global get";
@@ -51,12 +69,13 @@
51
69
  oneditprepare: function() {
52
70
  const node = this;
53
71
 
54
- // 1. Populate Dropdown
55
72
  let candidateNodes = [];
56
73
  RED.nodes.eachNode(function(n) {
57
74
  if (n.type === 'global-setter') {
58
75
  let displayPath = n.path || "No Path";
59
-
76
+ if (displayPath.startsWith("#") && displayPath.includes(":")) {
77
+ displayPath = displayPath.substring(displayPath.indexOf(":") + 1);
78
+ }
60
79
  candidateNodes.push({
61
80
  value: n.id,
62
81
  label: displayPath + (n.name ? ` (${n.name})` : "")
@@ -67,62 +86,16 @@
67
86
  candidateNodes.sort((a, b) => a.label.localeCompare(b.label));
68
87
 
69
88
  $("#node-input-targetNode").typedInput({
70
- types: [{
71
- value: "target",
72
- options: candidateNodes
73
- }]
89
+ types: [{ value: "target", options: candidateNodes }]
74
90
  });
75
91
 
76
- // 2. Output Property Selector
77
- $("#node-input-outputProperty").typedInput({
78
- types: ['msg']
79
- });
92
+ $("#node-input-outputProperty").typedInput({ types: ['msg'] });
80
93
 
81
- // 3. Find Button Logic
82
94
  $("#node-config-find-source").on("click", function() {
83
95
  const selectedId = $("#node-input-targetNode").val();
84
- if (selectedId) {
85
- // Node-RED API to jump to a node
86
- RED.view.reveal(selectedId);
87
- } else {
88
- RED.notify("Please select a source node first.", "warning");
89
- }
96
+ if (selectedId) { RED.view.reveal(selectedId); }
97
+ else { RED.notify("Please select a source node first.", "warning"); }
90
98
  });
91
99
  }
92
100
  });
93
101
  </script>
94
-
95
- <!-- Help Section -->
96
- <script type="text/markdown" data-help-name="global-getter">
97
- Manage a global variable in a repeatable way.
98
-
99
- ### Inputs
100
- : source (any) : Select the source 'global-setter' node to get the variable from.
101
- : output (string) : The output property to store the variable value in.
102
-
103
- ### Outputs
104
- : payload (any) : The global variable value
105
- : topic (string) : The variable name/path used to store the value.
106
- : units (string) : The units associated with the value, if any. Also supports nested units at `msg.<inputProperty>.units`.
107
- : metadata (object) : Metadata about the global variable (store, path).
108
-
109
- ### Details
110
- Global variables are meant to be retrieved in other places, this necessarily means managing the same string in multiple places.
111
-
112
- This node allows you to get a global variable while supporting rename and deletion.
113
-
114
- It links to a `global-setter` node by Node ID, so if that node's path is changed, this node will still work.
115
-
116
- Configurable output property allows chaining multiple getters in series if only interested in the value.
117
-
118
- ### Status
119
- - Green (dot): Configuration update
120
- - Blue (dot): State changed
121
- - Blue (ring): State unchanged
122
- - Red (ring): Error
123
- - Yellow (ring): Warning
124
-
125
- ### References
126
- - [Node-RED Documentation](https://nodered.org/docs/)
127
- - [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
128
- </script>
@@ -4,47 +4,135 @@ module.exports = function(RED) {
4
4
  const node = this;
5
5
  node.targetNodeId = config.targetNode;
6
6
  node.outputProperty = config.outputProperty || "payload";
7
+ node.updates = config.updates;
8
+ node.detail = config.detail;
7
9
 
8
- node.on('input', function(msg) {
9
- const setterNode = RED.nodes.getNode(node.targetNodeId);
10
+ let setterNode = null;
11
+ let retryInterval = null;
12
+ let updateListener = null;
13
+ let retryCount = 0;
14
+ const retryDelays = [0, 100, 500, 1000, 2000, 4000, 8000, 16000];
15
+ const maxRetries = retryDelays.length - 1;
16
+
17
+ // --- HELPER: Process Wrapper and Send Msg ---
18
+ function sendValue(storedObject, msgToReuse) {
19
+ const msg = msgToReuse || {};
10
20
 
11
- if (setterNode && setterNode.varName) {
12
- const globalContext = node.context().global;
13
- const storedObject = globalContext.get(setterNode.varName, setterNode.storeName);
21
+ if (storedObject !== undefined && storedObject !== null) {
14
22
 
15
- if (storedObject !== undefined) {
16
- let val = storedObject;
17
- let units = null;
18
- let meta = {};
19
-
20
- // CHECK: Is this wrapper format?
21
- if (storedObject && typeof storedObject === 'object' && storedObject.hasOwnProperty('value') && storedObject.hasOwnProperty('meta')) {
22
- // Yes: Unwrap it
23
- val = storedObject.value;
24
- units = storedObject.units;
25
- meta = storedObject.meta;
26
- } else {
27
- // Legacy/Raw: Metadata is limited
28
- meta = { path: setterNode.varName, legacy: true };
29
- }
30
-
31
- RED.util.setMessageProperty(msg, node.outputProperty, val);
23
+ // CHECK: Is this our Wrapper Format? (Created by Global Setter)
24
+ if (storedObject && typeof storedObject === 'object' && storedObject.hasOwnProperty('value')) {
32
25
 
33
- msg.topic = setterNode.varName;
34
-
35
- msg.units = units;
26
+ // 1. Separate the Value from everything else (Rest operator)
27
+ // 'attributes' will contain: priority, units, metadata, topic, etc.
28
+ const { value, ...attributes } = storedObject;
29
+
30
+ // 2. Set the Main Output (e.g. msg.payload = 75)
31
+ RED.util.setMessageProperty(msg, node.outputProperty, value);
32
+
33
+ // 3. Merge all attributes onto the msg root
34
+ // This automatically handles priority, units, metadata, and any future fields
35
+ if (node.detail === "getObject") {
36
+ Object.assign(msg, attributes);
37
+ }
36
38
 
37
- msg.metadata = meta;
38
-
39
- node.status({ fill: "blue", shape: "dot", text: `Get: ${val}` });
40
- node.send(msg);
41
39
  } else {
42
- node.status({ fill: "red", shape: "ring", text: "Global variable undefined" });
40
+ // Handle Legacy/Raw values (not created by your Setter)
41
+ RED.util.setMessageProperty(msg, node.outputProperty, storedObject);
42
+ msg.metadata = { path: setterNode ? setterNode.varName : "unknown", legacy: true };
43
+ }
44
+
45
+ // Visual Status
46
+ const valDisplay = RED.util.getMessageProperty(msg, node.outputProperty);
47
+ node.status({ fill: "blue", shape: "dot", text: `get: ${valDisplay}` });
48
+
49
+ node.send(msg);
50
+
51
+ } else {
52
+ node.status({ fill: "red", shape: "ring", text: "global variable undefined" });
53
+ }
54
+ }
55
+
56
+ // --- HELPER: Manage Event Subscription ---
57
+ function establishListener() {
58
+ setterNode = RED.nodes.getNode(node.targetNodeId);
59
+
60
+ if (setterNode && setterNode.varName && node.updates === 'always') {
61
+ if (updateListener) {
62
+ // Remove existing listener if we're retrying
63
+ RED.events.removeListener("bldgblocks-global-update", updateListener);
64
+ }
65
+
66
+ updateListener = function(evt) {
67
+ if (evt.key === setterNode.varName && evt.store === setterNode.storeName) {
68
+ sendValue(evt.data, {});
69
+ }
70
+ };
71
+
72
+ RED.events.on("bldgblocks-global-update", updateListener);
73
+
74
+ // Clear retry interval once successful
75
+ if (retryInterval) {
76
+ clearInterval(retryInterval);
77
+ retryInterval = null;
43
78
  }
79
+
80
+ node.status({ fill: "green", shape: "dot", text: "Connected" });
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ // --- HANDLE MANUAL INPUT ---
87
+ node.on('input', function(msg, send, done) {
88
+ send = send || function() { node.send.apply(node, arguments); };
89
+
90
+ if (setterNode && setterNode.varName) {
91
+ const globalContext = node.context().global;
92
+ const storedObject = globalContext.get(setterNode.varName, setterNode.storeName);
93
+ sendValue(storedObject, msg);
44
94
  } else {
45
95
  node.warn("Source node not found or not configured.");
46
96
  node.status({ fill: "red", shape: "ring", text: "Source node not found" });
47
97
  }
98
+
99
+ if (done) done();
100
+ });
101
+
102
+ // --- HANDLE REACTIVE UPDATES ---
103
+ if (node.updates === 'always') {
104
+ if (!establishListener()) {
105
+ // Recursive retry
106
+ const retry = () => {
107
+ if (retryCount >= maxRetries) {
108
+ node.error("Failed to connect to setter node after multiple attempts");
109
+ node.status({ fill: "red", shape: "ring", text: "Connection failed" });
110
+ return;
111
+ }
112
+
113
+ if (establishListener()) {
114
+ retryCount = 0;
115
+ return; // Success
116
+ }
117
+
118
+ retryCount++;
119
+ setTimeout(retry, retryDelays[Math.min(retryCount, maxRetries - 1)]);
120
+ };
121
+
122
+ // Try immediately
123
+ setTimeout(retry, retryDelays[0]);
124
+ }
125
+ }
126
+
127
+ // --- CLEANUP ---
128
+ node.on('close', function(removed, done) {
129
+ if (retryInterval) {
130
+ clearInterval(retryInterval);
131
+ }
132
+ if (removed && updateListener) {
133
+ RED.events.removeListener("bldgblocks-global-update", updateListener);
134
+ }
135
+ done();
48
136
  });
49
137
  }
50
138
  RED.nodes.registerType("global-getter", GlobalGetterNode);
@@ -8,12 +8,24 @@
8
8
  <input type="text" id="node-input-path" style="width: 70%;" placeholder="furnace/outputs/heat">
9
9
  </div>
10
10
  <div class="form-row">
11
- <label for="node-input-property"><i class="fa fa-ellipsis-h"></i> Input Property</label>
11
+ <label for="node-input-property"><i class="fa fa-ellipsis-h"></i> Input</label>
12
12
  <input type="text" id="node-input-property" style="width:70%;">
13
13
  </div>
14
+ <div class="form-row">
15
+ <label for="node-input-writePriority" title="Priority is evaluated for final value when using global-setter/getter nodes."><i class="fa fa-sort-numeric-asc"></i> Priority</label>
16
+ <input type="text" id="node-input-writePriority" placeholder="single">
17
+ <input type="hidden" id="node-input-writePriorityType">
18
+ </div>
19
+
20
+ <hr>
21
+
22
+ <div class="form-row">
23
+ <label for="node-input-defaultValue" title="Static default value to ensure one exists. You may write to default through message configuration."><i class="fa fa-undo"></i> Default</label>
24
+ <input type="text" id="node-input-defaultValue" placeholder="e.g. 0 or false">
25
+ </div>
26
+
14
27
  <div class="form-tips">
15
- <b>Note:</b> Use the dropdown inside the Path input to select a specific Context Store.
16
- When this node is redeployed or deleted, it will automatically remove the variable from memory.
28
+ <b>Note:</b> This node writes to the selected <b>Priority Level</b>. The actual Global Variable value will be the highest active priority.
17
29
  </div>
18
30
  </script>
19
31
 
@@ -24,7 +36,10 @@
24
36
  defaults: {
25
37
  name: { value: "" },
26
38
  path: { value: "", required: true },
27
- property: { value: "payload", required: true }
39
+ property: { value: "payload", required: true },
40
+ defaultValue: { value: "", required: true },
41
+ writePriority: { value: "16", required: true },
42
+ writePriorityType: { value: "dropdown" }
28
43
  },
29
44
  inputs: 1,
30
45
  outputs: 1,
@@ -32,21 +47,45 @@
32
47
  label: function() {
33
48
  let lbl = this.path;
34
49
  if (lbl && lbl.startsWith("#") && lbl.includes(":")) {
35
- lbl = lbl.split(":")[3];
50
+ lbl = lbl.substring(lbl.indexOf(":") + 1);
36
51
  }
37
- return this.name || "Set: " + lbl || "global set";
52
+ return this.name || "set: " + lbl || "global set";
38
53
  },
39
54
  paletteLabel: "global set",
40
55
  oneditprepare: function() {
41
- // RESTRICT TO GLOBAL ONLY
42
- $("#node-input-path").typedInput({
43
- types: ['global']
44
- });
56
+ // These are read as strings not evaluated typed-inputs.
57
+ $("#node-input-path").typedInput({ types: ['global'] });
58
+ // getMessageProperty handles msg access on the incoming message at the specified path.
59
+ $("#node-input-property").typedInput({ types: ['msg'] });
60
+ // Default for the default value, meaning it should only be manually set. You can still pass default values through messages.
61
+ $("#node-input-defaultValue").typedInput({ types: ['str','num','bool'] });
45
62
 
46
- // Input Property Selector
47
- $("#node-input-property").typedInput({
48
- types: ['msg']
49
- });
63
+ $("#node-input-writePriority").typedInput({
64
+ default: "dropdown",
65
+ types: [{
66
+ value: "dropdown",
67
+ options: [
68
+ { value: "1", label: "1 (Life Safety)"},
69
+ { value: "2", label: "2"},
70
+ { value: "3", label: "3"},
71
+ { value: "4", label: "4"},
72
+ { value: "5", label: "5"},
73
+ { value: "6", label: "6"},
74
+ { value: "7", label: "7"},
75
+ { value: "8", label: "8 (Manual Operator)"},
76
+ { value: "9", label: "9"},
77
+ { value: "10", label: "10"},
78
+ { value: "11", label: "11"},
79
+ { value: "12", label: "12"},
80
+ { value: "13", label: "13"},
81
+ { value: "14", label: "14"},
82
+ { value: "15", label: "15"},
83
+ { value: "16", label: "16 (Schedule/Logic)"},
84
+ { value: "default", label: "default (fallback)"},
85
+ ]
86
+ }, "msg", "flow"],
87
+ typeField: "#node-input-writePriorityType"
88
+ }).typedInput("type", node.writePriorityType).typedInput("value", node.writePriority);
50
89
  }
51
90
  });
52
91
  </script>
@@ -1,56 +1,171 @@
1
+
1
2
  module.exports = function(RED) {
3
+ const utils = require('./utils')(RED);
4
+
2
5
  function GlobalSetterNode(config) {
3
6
  RED.nodes.createNode(this, config);
4
7
  const node = this;
5
8
 
6
9
  const parsed = RED.util.parseContextStore(config.path);
7
-
8
10
  node.varName = parsed.key;
9
11
  node.storeName = parsed.store;
10
- node.inputProperty = config.property;
12
+ node.inputProperty = config.property;
13
+ node.defaultValue = config.defaultValue;
14
+ node.writePriority = config.writePriority;
15
+
16
+ // Cast default value logic
17
+ if(!isNaN(node.defaultValue) && node.defaultValue !== "") node.defaultValue = Number(node.defaultValue);
18
+ if(node.defaultValue === "true") node.defaultValue = true;
19
+ if(node.defaultValue === "false") node.defaultValue = false;
20
+
21
+ // --- HELPER: Calculate Winner ---
22
+ function calculateWinner(state) {
23
+ for (let i = 1; i <= 16; i++) {
24
+ if (state.priority[i] !== undefined && state.priority[i] !== null) {
25
+ return state.priority[i];
26
+ }
27
+ }
28
+ return state.defaultValue;
29
+ }
30
+
31
+ node.isBusy = false;
32
+
33
+ node.on('input', async function(msg, send, done) {
34
+ send = send || function() { node.send.apply(node, arguments); };
35
+
36
+ // Guard against invalid msg
37
+ if (!msg) {
38
+ node.status({ fill: "red", shape: "ring", text: "invalid message" });
39
+ if (done) done();
40
+ return;
41
+ }
42
+
43
+ // Evaluate dynamic properties
44
+ try {
45
+
46
+ // Check busy lock
47
+ if (node.isBusy) {
48
+ // Update status to let user know they are pushing too fast
49
+ node.status({ fill: "yellow", shape: "ring", text: "busy - dropped msg" });
50
+ if (done) done();
51
+ return;
52
+ }
53
+
54
+ // Lock node during evaluation
55
+ node.isBusy = true;
56
+
57
+ // Begin evaluations
58
+ const evaluations = [];
59
+
60
+ evaluations.push(
61
+ utils.requiresEvaluation(config.writePriorityType)
62
+ ? utils.evaluateNodeProperty( config.writePriority, config.writePriorityType, node, msg )
63
+ : Promise.resolve(node.writePriority),
64
+ );
65
+
66
+ const results = await Promise.all(evaluations);
67
+
68
+ // Update runtime with evaluated values
69
+ node.writePriority = results[0];
70
+ } catch (err) {
71
+ node.error(`Error evaluating properties: ${err.message}`);
72
+ if (done) done();
73
+ return;
74
+ } finally {
75
+ // Release, all synchronous from here on
76
+ node.isBusy = false;
77
+ }
11
78
 
12
- node.on('input', function(msg) {
13
79
  if (node.varName) {
14
- // READ from the configured property
15
- const valueToStore = RED.util.getMessageProperty(msg, node.inputProperty);
80
+ const inputValue = RED.util.getMessageProperty(msg, node.inputProperty);
81
+ const globalContext = node.context().global;
16
82
 
17
- if (valueToStore !== undefined) {
18
- const globalContext = node.context().global;
83
+ // Get existing state or initialize new
84
+ let state = globalContext.get(node.varName, node.storeName);
85
+ if (!state || typeof state !== 'object' || !state.priority) {
86
+ state = {
87
+ value: null,
88
+ defaultValue: node.defaultValue,
89
+ priority: {
90
+ 1: null,
91
+ 2: null,
92
+ 3: null,
93
+ 4: null,
94
+ 5: null,
95
+ 6: null,
96
+ 7: null,
97
+ 8: null,
98
+ 9: null,
99
+ 10: null,
100
+ 11: null,
101
+ 12: null,
102
+ 13: null,
103
+ 14: null,
104
+ 15: null,
105
+ 16: null,
106
+ },
107
+ metadata: {}
108
+ };
109
+ }
19
110
 
20
- // 1. Try to find units in the standard location (msg.units)
21
- // 2. If not found, check if the input property itself is an object containing .units
22
- let capturedUnits = msg.units;
23
-
24
- // Optional: Deep check if msg.payload was an object that contained units
25
- if (!capturedUnits && typeof valueToStore === 'object' && valueToStore !== null && valueToStore.units) {
26
- capturedUnits = valueToStore.units;
111
+ // Update Default, can not be set null
112
+ if (node.writePriority === 'default') {
113
+ state.defaultValue = inputValue !== null ? inputValue : node.defaultValue;
114
+ } else {
115
+ const priority = parseInt(node.writePriority, 10);
116
+ if (isNaN(priority) || priority < 1 || priority > 16) {
117
+ node.status({ fill: "red", shape: "ring", text: `Invalid priority: ${node.writePriority}` });
118
+ if (done) done();
119
+ return;
27
120
  }
28
121
 
29
- // Create wrapper with simplified metadata
30
- const storedObject = {
31
- value: valueToStore,
32
- topic: node.varName,
33
- units: capturedUnits,
34
- meta: {
35
- sourceId: node.id,
36
- sourceName: node.name || config.path,
37
- sourcePath: node.varName,
38
- lastSet: new Date().toISOString()
39
- }
40
- };
41
-
42
- node.status({ fill: "blue", shape: "dot", text: `Set: ${storedObject.value}` });
43
- globalContext.set(node.varName, storedObject, node.storeName);
122
+ if (inputValue !== undefined) {
123
+ state.priority[node.writePriority] = inputValue;
124
+ }
44
125
  }
45
- }
126
+
127
+ // Ensure defaultValue always has a value
128
+ if (state.defaultValue === null || state.defaultValue === undefined) {
129
+ state.defaultValue = node.defaultValue;
130
+ }
131
+
132
+ // Calculate Winner
133
+ state.value = calculateWinner(state);
134
+
135
+ // Update Metadata
136
+ state.metadata.sourceId = node.id;
137
+ state.metadata.lastSet = new Date().toISOString();
138
+ state.metadata.sourceName = node.name || config.path;
139
+ state.metadata.sourcePath = node.varName;
140
+ state.metadata.store = node.storeName;
46
141
 
142
+ // Units logic
143
+ let capturedUnits = msg.units;
144
+ if (!capturedUnits && typeof inputValue === 'object' && inputValue !== null && inputValue.units) {
145
+ capturedUnits = inputValue.units;
146
+ }
147
+ if(capturedUnits) state.units = capturedUnits;
148
+
149
+ // Save & Emit
150
+ globalContext.set(node.varName, state, node.storeName);
151
+
152
+ node.status({ fill: "blue", shape: "dot", text: `P${node.writePriority}:${inputValue} > Val:${state.value}` });
153
+
154
+ // Fire Event
155
+ RED.events.emit("bldgblocks-global-update", {
156
+ key: node.varName,
157
+ store: node.storeName,
158
+ data: state
159
+ });
160
+ }
161
+
47
162
  node.send(msg);
163
+ if (done) done();
48
164
  });
49
165
 
50
- // CLEANUP
51
166
  node.on('close', function(removed, done) {
52
- // Do NOT prune if Node-RED is simply restarting or deploying.
53
167
  if (removed && node.varName) {
168
+ //RED.events.removeAllListeners("bldgblocks-global-update");
54
169
  const globalContext = node.context().global;
55
170
  globalContext.set(node.varName, undefined, node.storeName);
56
171
  }