@bldgblocks/node-red-contrib-control 0.1.27 → 0.1.29
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/README.md +1 -1
- package/nodes/average-block.js +1 -1
- package/nodes/boolean-switch-block.html +2 -1
- package/nodes/boolean-switch-block.js +1 -2
- package/nodes/call-status-block.html +30 -45
- package/nodes/call-status-block.js +81 -195
- package/nodes/changeover-block.html +76 -12
- package/nodes/changeover-block.js +24 -8
- package/nodes/delay-block.html +1 -1
- package/nodes/frequency-block.html +3 -1
- package/nodes/frequency-block.js +64 -7
- package/nodes/global-getter.html +96 -0
- package/nodes/global-getter.js +42 -0
- package/nodes/global-setter.html +72 -0
- package/nodes/global-setter.js +43 -0
- package/nodes/hysteresis-block.js +1 -1
- package/nodes/latch-block.html +55 -0
- package/nodes/latch-block.js +77 -0
- package/nodes/on-change-block.html +0 -1
- package/nodes/on-change-block.js +2 -12
- package/nodes/pid-block.html +102 -80
- package/nodes/pid-block.js +120 -110
- package/nodes/rate-of-change-block.html +110 -0
- package/nodes/rate-of-change-block.js +233 -0
- package/nodes/string-builder-block.html +112 -0
- package/nodes/string-builder-block.js +89 -0
- package/nodes/tstat-block.html +85 -39
- package/nodes/tstat-block.js +66 -30
- package/package.json +6 -1
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
</div>
|
|
6
6
|
<div class="form-row">
|
|
7
7
|
<label for="node-input-algorithm" title="Control algorithm type"><i class="fa fa-cog"></i> Algorithm</label>
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
<option value="split">Split Setpoints</option>
|
|
11
|
-
</select>
|
|
8
|
+
<input type="text" id="node-input-algorithm" class="node-input-typed" placeholder="single">
|
|
9
|
+
<input type="hidden" id="node-input-algorithmType">
|
|
12
10
|
</div>
|
|
13
11
|
<div class="form-row single-only">
|
|
14
12
|
<label for="node-input-setpoint" title="Target temperature setpoint (number from num, msg, flow, or global)"><i class="fa fa-thermometer-half"></i> Setpoint</label>
|
|
@@ -50,6 +48,18 @@
|
|
|
50
48
|
Invalid
|
|
51
49
|
</div>
|
|
52
50
|
</div>
|
|
51
|
+
|
|
52
|
+
<div class="form-row specified-only" style="display: none;">
|
|
53
|
+
<label for="node-input-coolingOn" title="Temperature to turn cooling on (number from num, msg, flow, or global)"><i class="fa fa-snowflake-o"></i> Cooling On</label>
|
|
54
|
+
<input type="text" id="node-input-coolingOn" placeholder="74">
|
|
55
|
+
<input type="hidden" id="node-input-coolingOnType">
|
|
56
|
+
</div>
|
|
57
|
+
<div class="form-row specified-only" style="display: none;">
|
|
58
|
+
<label for="node-input-heatingOn" title="Temperature to turn heating on (number from num, msg, flow, or global)"><i class="fa fa-fire"></i> Heating On</label>
|
|
59
|
+
<input type="text" id="node-input-heatingOn" placeholder="66">
|
|
60
|
+
<input type="hidden" id="node-input-heatingOnType">
|
|
61
|
+
</div>
|
|
62
|
+
|
|
53
63
|
<div class="form-row">
|
|
54
64
|
<label for="node-input-swapTime" title="Minimum time before mode change (seconds, minimum 60, from num, msg, flow, or global)"><i class="fa fa-clock-o"></i> Swap Time</label>
|
|
55
65
|
<input type="text" id="node-input-swapTime" class="node-input-typed" placeholder="300">
|
|
@@ -76,11 +86,8 @@
|
|
|
76
86
|
</div>
|
|
77
87
|
<div class="form-row">
|
|
78
88
|
<label for="node-input-operationMode" title="Operation mode"><i class="fa fa-cogs"></i> Operation Mode</label>
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
<option value="heat">Heat</option>
|
|
82
|
-
<option value="cool">Cool</option>
|
|
83
|
-
</select>
|
|
89
|
+
<input type="text" id="node-input-operationMode" placeholder="auto">
|
|
90
|
+
<input type="hidden" id="node-input-operationModeType">
|
|
84
91
|
</div>
|
|
85
92
|
</script>
|
|
86
93
|
|
|
@@ -91,6 +98,7 @@
|
|
|
91
98
|
defaults: {
|
|
92
99
|
name: { value: "" },
|
|
93
100
|
algorithm: { value: "single" },
|
|
101
|
+
algorithmType: { value: "dropdown" },
|
|
94
102
|
setpoint: { value: "70" },
|
|
95
103
|
setpointType: { value: "num" },
|
|
96
104
|
deadband: { value: "2" },
|
|
@@ -98,6 +106,10 @@
|
|
|
98
106
|
heatingSetpointType: { value: "num" },
|
|
99
107
|
coolingSetpoint: { value: "74" },
|
|
100
108
|
coolingSetpointType: { value: "num" },
|
|
109
|
+
coolingOn: { value: "74" },
|
|
110
|
+
coolingOnType: { value: "num" },
|
|
111
|
+
heatingOn: { value: "66" },
|
|
112
|
+
heatingOnType: { value: "num" },
|
|
101
113
|
extent: { value: "1" },
|
|
102
114
|
swapTime: { value: "300" },
|
|
103
115
|
swapTimeType: { value: "num" },
|
|
@@ -106,7 +118,8 @@
|
|
|
106
118
|
maxTempSetpoint: { value: "90" },
|
|
107
119
|
maxTempSetpointType: { value: "num"},
|
|
108
120
|
initWindow: { value: "10" },
|
|
109
|
-
operationMode: { value: "auto" }
|
|
121
|
+
operationMode: { value: "auto" },
|
|
122
|
+
operationModeType: { value: "dropdown" },
|
|
110
123
|
},
|
|
111
124
|
inputs: 1,
|
|
112
125
|
outputs: 1,
|
|
@@ -122,9 +135,37 @@
|
|
|
122
135
|
const $algorithm = $("#node-input-algorithm");
|
|
123
136
|
const $singleFields = $(".single-only");
|
|
124
137
|
const $splitFields = $(".split-only");
|
|
138
|
+
const $specifiedFields = $(".specified-only");
|
|
125
139
|
|
|
126
140
|
try {
|
|
127
141
|
// Initialize typed inputs
|
|
142
|
+
|
|
143
|
+
$("#node-input-operationMode").typedInput({
|
|
144
|
+
default: "dropdown",
|
|
145
|
+
types: [{
|
|
146
|
+
value: "dropdown",
|
|
147
|
+
options: [
|
|
148
|
+
{ value: "auto", label: "Auto"},
|
|
149
|
+
{ value: "heat", label: "Heat"},
|
|
150
|
+
{ value: "cool", label: "Cool"},
|
|
151
|
+
]
|
|
152
|
+
}, "msg", "flow", "global"],
|
|
153
|
+
typeField: "#node-input-operationModeType"
|
|
154
|
+
}).typedInput("type", node.operationModeType).typedInput("value", node.operationMode);
|
|
155
|
+
|
|
156
|
+
$("#node-input-algorithm").typedInput({
|
|
157
|
+
default: "dropdown",
|
|
158
|
+
types: [{
|
|
159
|
+
value: "dropdown",
|
|
160
|
+
options: [
|
|
161
|
+
{ value: "single", label: "Single"},
|
|
162
|
+
{ value: "split", label: "Split"},
|
|
163
|
+
{ value: "specified", label: "Specified"},
|
|
164
|
+
]
|
|
165
|
+
}, "msg", "flow", "global"],
|
|
166
|
+
typeField: "#node-input-algorithmType"
|
|
167
|
+
}).typedInput("type", node.algorithmType).typedInput("value", node.algorithm);
|
|
168
|
+
|
|
128
169
|
$("#node-input-setpoint").typedInput({
|
|
129
170
|
default: "num",
|
|
130
171
|
types: ["num", "msg", "flow", "global"],
|
|
@@ -143,6 +184,18 @@
|
|
|
143
184
|
typeField: "#node-input-coolingSetpointType"
|
|
144
185
|
}).typedInput("type", node.coolingSetpointType || "num").typedInput("value", node.coolingSetpoint);
|
|
145
186
|
|
|
187
|
+
$("#node-input-heatingOn").typedInput({
|
|
188
|
+
default: "num",
|
|
189
|
+
types: ["num", "msg", "flow", "global"],
|
|
190
|
+
typeField: "#node-input-heatingOnType"
|
|
191
|
+
}).typedInput("type", node.heatingOnType || "num").typedInput("value", node.heatingOn);
|
|
192
|
+
|
|
193
|
+
$("#node-input-coolingOn").typedInput({
|
|
194
|
+
default: "num",
|
|
195
|
+
types: ["num", "msg", "flow", "global"],
|
|
196
|
+
typeField: "#node-input-coolingOnType"
|
|
197
|
+
}).typedInput("type", node.coolingOnType || "num").typedInput("value", node.coolingOn);
|
|
198
|
+
|
|
146
199
|
$("#node-input-swapTime").typedInput({
|
|
147
200
|
default: "num",
|
|
148
201
|
types: ["num", "msg", "flow", "global"],
|
|
@@ -178,9 +231,20 @@
|
|
|
178
231
|
if ($algorithm.val() === "single") {
|
|
179
232
|
$singleFields.show();
|
|
180
233
|
$splitFields.hide();
|
|
181
|
-
|
|
234
|
+
$specifiedFields.hide();
|
|
235
|
+
} else if ($algorithm.val() === "split") {
|
|
182
236
|
$singleFields.hide();
|
|
183
237
|
$splitFields.show();
|
|
238
|
+
$specifiedFields.hide();
|
|
239
|
+
} else if ($algorithm.val() === "specified") {
|
|
240
|
+
$singleFields.hide();
|
|
241
|
+
$splitFields.hide();
|
|
242
|
+
$specifiedFields.show();
|
|
243
|
+
} else {
|
|
244
|
+
$algorithm.val("single");
|
|
245
|
+
$singleFields.show();
|
|
246
|
+
$splitFields.hide();
|
|
247
|
+
$specifiedFields.hide();
|
|
184
248
|
}
|
|
185
249
|
}
|
|
186
250
|
|
|
@@ -188,7 +252,7 @@
|
|
|
188
252
|
toggleFields();
|
|
189
253
|
|
|
190
254
|
} catch (err) {
|
|
191
|
-
console.error("Error in
|
|
255
|
+
console.error("Error in oneditprepare:", err);
|
|
192
256
|
}
|
|
193
257
|
}
|
|
194
258
|
});
|
|
@@ -10,10 +10,7 @@ module.exports = function(RED) {
|
|
|
10
10
|
// Initialize runtime state
|
|
11
11
|
node.runtime = {
|
|
12
12
|
name: config.name,
|
|
13
|
-
algorithm: config.algorithm,
|
|
14
|
-
operationMode: config.operationMode,
|
|
15
13
|
initWindow: parseFloat(config.initWindow),
|
|
16
|
-
currentMode: (config.operationMode === "cool" ? "cooling" : "heating"),
|
|
17
14
|
lastTemperature: null,
|
|
18
15
|
lastModeChange: 0
|
|
19
16
|
};
|
|
@@ -27,7 +24,10 @@ module.exports = function(RED) {
|
|
|
27
24
|
node.runtime.deadband = parseFloat(RED.util.evaluateNodeProperty( config.deadband, config.deadbandType, node ));
|
|
28
25
|
node.runtime.extent = parseFloat(RED.util.evaluateNodeProperty( config.extent, config.extentType, node ));
|
|
29
26
|
node.runtime.minTempSetpoint = parseFloat(RED.util.evaluateNodeProperty( config.minTempSetpoint, config.minTempSetpointType, node ));
|
|
30
|
-
node.runtime.maxTempSetpoint = parseFloat(RED.util.evaluateNodeProperty( config.maxTempSetpoint, config.maxTempSetpointType, node ));
|
|
27
|
+
node.runtime.maxTempSetpoint = parseFloat(RED.util.evaluateNodeProperty( config.maxTempSetpoint, config.maxTempSetpointType, node ));
|
|
28
|
+
node.runtime.algorithm = RED.util.evaluateNodeProperty( config.algorithm, config.algorithmType, node );
|
|
29
|
+
node.runtime.operationMode = RED.util.evaluateNodeProperty( config.operationMode, config.operationModeType, node );
|
|
30
|
+
node.runtime.currentMode = node.runtime.operationMode === "cool" ? "cooling" : "heating";
|
|
31
31
|
} catch (err) {
|
|
32
32
|
node.error(`Error evaluating properties: ${err.message}`);
|
|
33
33
|
if (done) done();
|
|
@@ -74,7 +74,14 @@ module.exports = function(RED) {
|
|
|
74
74
|
}
|
|
75
75
|
if (utils.requiresEvaluation(config.maxTempSetpointType)) {
|
|
76
76
|
node.runtime.maxTempSetpoint = parseFloat(RED.util.evaluateNodeProperty( config.maxTempSetpoint, config.maxTempSetpointType, node, msg ));
|
|
77
|
-
}
|
|
77
|
+
}
|
|
78
|
+
if (utils.requiresEvaluation(config.algorithmType)) {
|
|
79
|
+
node.runtime.algorithm = RED.util.evaluateNodeProperty( config.algorithm, config.algorithmType, node, msg );
|
|
80
|
+
}
|
|
81
|
+
if (utils.requiresEvaluation(config.operationModeType)) {
|
|
82
|
+
node.runtime.operationMode = RED.util.evaluateNodeProperty( config.operationMode, config.operationModeType, node, msg );
|
|
83
|
+
node.runtime.currentMode = node.runtime.operationMode === "cool" ? "cooling" : "heating";
|
|
84
|
+
}
|
|
78
85
|
} catch (err) {
|
|
79
86
|
node.error(`Error evaluating properties: ${err.message}`);
|
|
80
87
|
if (done) done();
|
|
@@ -277,9 +284,12 @@ module.exports = function(RED) {
|
|
|
277
284
|
if (node.runtime.algorithm === "single") {
|
|
278
285
|
heatingThreshold = node.runtime.setpoint - node.runtime.deadband / 2;
|
|
279
286
|
coolingThreshold = node.runtime.setpoint + node.runtime.deadband / 2;
|
|
280
|
-
} else {
|
|
287
|
+
} else if (node.runtime.algorithm === "split") {
|
|
281
288
|
heatingThreshold = node.runtime.heatingSetpoint - node.runtime.extent;
|
|
282
289
|
coolingThreshold = node.runtime.coolingSetpoint + node.runtime.extent;
|
|
290
|
+
} else if (node.runtime.algorithm === "specified") {
|
|
291
|
+
heatingThreshold = node.runtime.heatingOn - node.runtime.extent;
|
|
292
|
+
coolingThreshold = node.runtime.coolingOn + node.runtime.extent;
|
|
283
293
|
}
|
|
284
294
|
|
|
285
295
|
if (temp < heatingThreshold) {
|
|
@@ -311,9 +321,12 @@ module.exports = function(RED) {
|
|
|
311
321
|
if (node.runtime.algorithm === "single") {
|
|
312
322
|
heatingThreshold = node.runtime.setpoint - node.runtime.deadband / 2;
|
|
313
323
|
coolingThreshold = node.runtime.setpoint + node.runtime.deadband / 2;
|
|
314
|
-
} else {
|
|
324
|
+
} else if (node.runtime.algorithm === "split") {
|
|
315
325
|
heatingThreshold = node.runtime.heatingSetpoint - node.runtime.extent;
|
|
316
326
|
coolingThreshold = node.runtime.coolingSetpoint + node.runtime.extent;
|
|
327
|
+
} else if (node.runtime.algorithm === "specified") {
|
|
328
|
+
heatingThreshold = node.runtime.heatingOn - node.runtime.extent;
|
|
329
|
+
coolingThreshold = node.runtime.coolingOn + node.runtime.extent;
|
|
317
330
|
}
|
|
318
331
|
|
|
319
332
|
let desiredMode = node.runtime.currentMode;
|
|
@@ -352,9 +365,12 @@ module.exports = function(RED) {
|
|
|
352
365
|
if (node.runtime.algorithm === "single") {
|
|
353
366
|
effectiveHeatingSetpoint = node.runtime.setpoint - node.runtime.deadband / 2;
|
|
354
367
|
effectiveCoolingSetpoint = node.runtime.setpoint + node.runtime.deadband / 2;
|
|
355
|
-
} else {
|
|
368
|
+
} else if (node.runtime.algorithm === "split") {
|
|
356
369
|
effectiveHeatingSetpoint = node.runtime.heatingSetpoint;
|
|
357
370
|
effectiveCoolingSetpoint = node.runtime.coolingSetpoint;
|
|
371
|
+
} else if (node.runtime.algorithm === "specified") {
|
|
372
|
+
effectiveHeatingSetpoint = node.runtime.heatingOn;
|
|
373
|
+
effectiveCoolingSetpoint = node.runtime.coolingOn;
|
|
358
374
|
}
|
|
359
375
|
|
|
360
376
|
return [
|
package/nodes/delay-block.html
CHANGED
|
@@ -39,9 +39,11 @@ Measures pulse frequency from boolean rising edges.
|
|
|
39
39
|
|
|
40
40
|
### Details
|
|
41
41
|
Measures pulse frequency from rising edges in `msg.payload` (boolean, `true` for pulse), outputting a message with
|
|
42
|
-
`msg.payload = { ppm, pph, ppd }` (pulses per minute, hour, day) on the second and subsequent rising edges
|
|
42
|
+
`msg.payload = { ppm, pph, ppd, duty }` (pulses per minute, hour, day, duty/hr) on the second and subsequent rising edges
|
|
43
43
|
(first edge sets baseline). Resets state via `msg.context = "reset"` with `msg.payload = true`.
|
|
44
44
|
|
|
45
|
+
Outputs a duty cycle in percentage of the last 60 minutes that the signal has been true.
|
|
46
|
+
|
|
45
47
|
### Status
|
|
46
48
|
- Green (dot): Configuration
|
|
47
49
|
- Blue (dot): Output, no alarm
|
package/nodes/frequency-block.js
CHANGED
|
@@ -11,7 +11,9 @@ module.exports = function(RED) {
|
|
|
11
11
|
completeCycle: false,
|
|
12
12
|
ppm: 0,
|
|
13
13
|
pph: 0,
|
|
14
|
-
ppd: 0
|
|
14
|
+
ppd: 0,
|
|
15
|
+
pulseHistory: [], // Array to store {start: timestamp, duration: ms}
|
|
16
|
+
currentPulseStart: 0
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
node.status({
|
|
@@ -20,7 +22,37 @@ module.exports = function(RED) {
|
|
|
20
22
|
text: "awaiting first pulse"
|
|
21
23
|
});
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
function calculateDutyCycle(now, currentInputValue) {
|
|
26
|
+
const oneHourAgo = now - 3600000;
|
|
27
|
+
|
|
28
|
+
// Clean up pulses older than 1 hour
|
|
29
|
+
node.runtime.pulseHistory = node.runtime.pulseHistory.filter(pulse => {
|
|
30
|
+
return (pulse.start + pulse.duration) > oneHourAgo;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let totalOnTime = 0;
|
|
34
|
+
|
|
35
|
+
// Sum all pulse durations within the last hour
|
|
36
|
+
node.runtime.pulseHistory.forEach(pulse => {
|
|
37
|
+
const pulseEnd = pulse.start + pulse.duration;
|
|
38
|
+
const effectiveStart = Math.max(pulse.start, oneHourAgo);
|
|
39
|
+
const effectiveEnd = Math.min(pulseEnd, now);
|
|
40
|
+
if (effectiveEnd > effectiveStart) {
|
|
41
|
+
totalOnTime += (effectiveEnd - effectiveStart);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Add current ongoing pulse if active
|
|
46
|
+
if (currentInputValue && node.runtime.currentPulseStart > 0) {
|
|
47
|
+
const currentPulseTime = Math.max(node.runtime.currentPulseStart, oneHourAgo);
|
|
48
|
+
totalOnTime += (now - currentPulseTime);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
dutyCycle: (totalOnTime / 3600000) * 100,
|
|
53
|
+
onTime: totalOnTime
|
|
54
|
+
};
|
|
55
|
+
}
|
|
24
56
|
|
|
25
57
|
node.on("input", function(msg, send, done) {
|
|
26
58
|
send = send || function() { node.send.apply(node, arguments); };
|
|
@@ -52,6 +84,8 @@ module.exports = function(RED) {
|
|
|
52
84
|
node.runtime.ppm = 0;
|
|
53
85
|
node.runtime.pph = 0;
|
|
54
86
|
node.runtime.ppd = 0;
|
|
87
|
+
node.runtime.pulseHistory = [];
|
|
88
|
+
node.runtime.currentPulseStart = 0;
|
|
55
89
|
node.status({ fill: "green", shape: "dot", text: "reset" });
|
|
56
90
|
}
|
|
57
91
|
if (done) done();
|
|
@@ -77,16 +111,39 @@ module.exports = function(RED) {
|
|
|
77
111
|
return;
|
|
78
112
|
}
|
|
79
113
|
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
|
|
116
|
+
// Track pulse edges for duty cycle
|
|
117
|
+
if (inputValue && !node.runtime.lastIn) {
|
|
118
|
+
// Rising edge - start new pulse
|
|
119
|
+
node.runtime.currentPulseStart = now;
|
|
120
|
+
} else if (!inputValue && node.runtime.lastIn) {
|
|
121
|
+
// Falling edge - record completed pulse
|
|
122
|
+
if (node.runtime.currentPulseStart > 0) {
|
|
123
|
+
const duration = now - node.runtime.currentPulseStart;
|
|
124
|
+
node.runtime.pulseHistory.push({
|
|
125
|
+
start: node.runtime.currentPulseStart,
|
|
126
|
+
duration: duration
|
|
127
|
+
});
|
|
128
|
+
node.runtime.currentPulseStart = 0;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Calculate duty cycle for the rolling hour
|
|
133
|
+
const dutyData = calculateDutyCycle(now, inputValue);
|
|
134
|
+
|
|
80
135
|
// Initialize output
|
|
81
136
|
let output = {
|
|
82
137
|
ppm: node.runtime.ppm,
|
|
83
138
|
pph: node.runtime.pph,
|
|
84
|
-
ppd: node.runtime.ppd
|
|
139
|
+
ppd: node.runtime.ppd,
|
|
140
|
+
dutyCycle: dutyData.dutyCycle.toFixed(2),
|
|
141
|
+
onTime: dutyData.onTime
|
|
85
142
|
};
|
|
86
143
|
|
|
87
144
|
// Detect rising edge
|
|
88
|
-
if (inputValue && !node.runtime.lastIn) {
|
|
89
|
-
|
|
145
|
+
if (inputValue && !node.runtime.lastIn) {
|
|
146
|
+
// Rising edge: true and lastIn was false
|
|
90
147
|
if (!node.runtime.completeCycle) {
|
|
91
148
|
node.runtime.completeCycle = true;
|
|
92
149
|
} else {
|
|
@@ -114,14 +171,14 @@ module.exports = function(RED) {
|
|
|
114
171
|
node.status({
|
|
115
172
|
fill: "blue",
|
|
116
173
|
shape: "dot",
|
|
117
|
-
text: `input: ${inputValue}, ppm: ${output.ppm.toFixed(2)}, pph: ${output.pph.toFixed(2)}, ppd: ${output.ppd.toFixed(2)}
|
|
174
|
+
text: `input: ${inputValue}, ppm: ${output.ppm.toFixed(2)}, pph: ${output.pph.toFixed(2)}, ppd: ${output.ppd.toFixed(2)}, duty: ${output.dutyCycle}%`
|
|
118
175
|
});
|
|
119
176
|
send({ payload: output });
|
|
120
177
|
} else {
|
|
121
178
|
node.status({
|
|
122
179
|
fill: "blue",
|
|
123
180
|
shape: "ring",
|
|
124
|
-
text: `input: ${inputValue}, ppm: ${node.runtime.ppm.toFixed(2)}, pph: ${node.runtime.pph.toFixed(2)}, ppd: ${node.runtime.ppd.toFixed(2)}
|
|
181
|
+
text: `input: ${inputValue}, ppm: ${node.runtime.ppm.toFixed(2)}, pph: ${node.runtime.pph.toFixed(2)}, ppd: ${node.runtime.ppd.toFixed(2)}, duty: ${output.dutyCycle}%`
|
|
125
182
|
});
|
|
126
183
|
}
|
|
127
184
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="global-getter">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
4
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-row">
|
|
7
|
+
<label for="node-input-targetNode"><i class="fa fa-crosshairs"></i> Source</label>
|
|
8
|
+
<input type="text" id="node-input-targetNode" style="width:70%;">
|
|
9
|
+
</div>
|
|
10
|
+
<div class="form-tips">
|
|
11
|
+
Targeting by Node ID allows the source path to change without breaking this link.
|
|
12
|
+
</div>
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<script type="text/javascript">
|
|
16
|
+
RED.nodes.registerType('global-getter', {
|
|
17
|
+
category: 'control',
|
|
18
|
+
color: '#301934',
|
|
19
|
+
defaults: {
|
|
20
|
+
name: { value: "" },
|
|
21
|
+
targetNode: { value: "", required: true }
|
|
22
|
+
},
|
|
23
|
+
inputs: 1,
|
|
24
|
+
outputs: 1,
|
|
25
|
+
icon: "font-awesome/fa-align-left",
|
|
26
|
+
label: function() {
|
|
27
|
+
if (this.targetNode) {
|
|
28
|
+
const target = RED.nodes.node(this.targetNode);
|
|
29
|
+
if (target) {
|
|
30
|
+
// Display the path, removing the #store: prefix for readability in the label if present
|
|
31
|
+
let lbl = target.path || target.name;
|
|
32
|
+
if(lbl && lbl.includes(":")) {
|
|
33
|
+
lbl = lbl.split(":").pop();
|
|
34
|
+
}
|
|
35
|
+
return "Get: " + lbl;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return this.name || "global get";
|
|
39
|
+
},
|
|
40
|
+
paletteLabel: "global get",
|
|
41
|
+
oneditprepare: function() {
|
|
42
|
+
const node = this;
|
|
43
|
+
|
|
44
|
+
let candidateNodes = [];
|
|
45
|
+
RED.nodes.eachNode(function(n) {
|
|
46
|
+
if (n.type === 'global-setter') {
|
|
47
|
+
let displayPath = n.path || "No Path";
|
|
48
|
+
|
|
49
|
+
candidateNodes.push({
|
|
50
|
+
value: n.id,
|
|
51
|
+
label: displayPath + (n.name ? ` (${n.name})` : "")
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
candidateNodes.sort((a, b) => a.label.localeCompare(b.label));
|
|
57
|
+
|
|
58
|
+
$("#node-input-targetNode").typedInput({
|
|
59
|
+
types: [{
|
|
60
|
+
value: "target",
|
|
61
|
+
options: candidateNodes
|
|
62
|
+
}]
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<!-- Help Section -->
|
|
69
|
+
<script type="text/markdown" data-help-name="global-getter">
|
|
70
|
+
Manage a global variable in a repeatable way.
|
|
71
|
+
|
|
72
|
+
### Inputs
|
|
73
|
+
|
|
74
|
+
### Outputs
|
|
75
|
+
: payload (any) : The global variable value
|
|
76
|
+
: topic (string) : The variable name/path used to store the value.
|
|
77
|
+
: globalMetadata (object) : Metadata about the global variable (store, path).
|
|
78
|
+
|
|
79
|
+
### Details
|
|
80
|
+
Global variables are meant to be retrieved in other places, this necessarily means managing the same string in multiple places.
|
|
81
|
+
|
|
82
|
+
This node allows you to get a global variable while supporting rename and deletion.
|
|
83
|
+
|
|
84
|
+
It links to a `global-setter` node by Node ID, so if that node's path is changed, this node will still work.
|
|
85
|
+
|
|
86
|
+
### Status
|
|
87
|
+
- Green (dot): Configuration update
|
|
88
|
+
- Blue (dot): State changed
|
|
89
|
+
- Blue (ring): State unchanged
|
|
90
|
+
- Red (ring): Error
|
|
91
|
+
- Yellow (ring): Warning
|
|
92
|
+
|
|
93
|
+
### References
|
|
94
|
+
- [Node-RED Documentation](https://nodered.org/docs/)
|
|
95
|
+
- [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
|
|
96
|
+
</script>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function GlobalGetterNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
node.targetNodeId = config.targetNode;
|
|
6
|
+
|
|
7
|
+
node.on('input', function(msg) {
|
|
8
|
+
const setterNode = RED.nodes.getNode(node.targetNodeId);
|
|
9
|
+
|
|
10
|
+
if (setterNode && setterNode.varName) {
|
|
11
|
+
const globalContext = node.context().global;
|
|
12
|
+
|
|
13
|
+
// Retrieve the wrapper object
|
|
14
|
+
const storedObject = globalContext.get(setterNode.varName, setterNode.storeName);
|
|
15
|
+
|
|
16
|
+
if (storedObject !== undefined) {
|
|
17
|
+
// CHECK: Is this our wrapper format?
|
|
18
|
+
if (storedObject && typeof storedObject === 'object' && storedObject.hasOwnProperty('value') && storedObject.hasOwnProperty('meta')) {
|
|
19
|
+
// Yes: Unwrap it
|
|
20
|
+
msg.payload = storedObject.value;
|
|
21
|
+
msg.globalMetadata = storedObject.meta; // Expose the ID/Metadata here
|
|
22
|
+
} else {
|
|
23
|
+
// No: It's legacy/raw data, just pass it through
|
|
24
|
+
msg.payload = storedObject;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
msg.topic = setterNode.varName;
|
|
28
|
+
|
|
29
|
+
node.status({ fill: "blue", shape: "dot", text: `Get: ${msg.payload}` });
|
|
30
|
+
node.send(msg);
|
|
31
|
+
} else {
|
|
32
|
+
// Variable exists in config but not in memory yet
|
|
33
|
+
// Optional: warn or just do nothing
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
node.warn("Source node not found or not configured.");
|
|
37
|
+
node.status({ fill: "red", shape: "ring", text: "Source node not found" });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
RED.nodes.registerType("global-getter", GlobalGetterNode);
|
|
42
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="global-setter">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
4
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-row">
|
|
7
|
+
<label for="node-input-path"><i class="fa fa-sitemap"></i> Global Path</label>
|
|
8
|
+
<input type="text" id="node-input-path" placeholder="furnace/outputs/heat">
|
|
9
|
+
</div>
|
|
10
|
+
<div class="form-tips">
|
|
11
|
+
<b>Note:</b> Use the dropdown inside the Path input to select a specific Context Store (File, Memory, etc).
|
|
12
|
+
When this node is redeployed or deleted, it will automatically remove (prune) the variable from that store.
|
|
13
|
+
</div>
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<script type="text/javascript">
|
|
17
|
+
RED.nodes.registerType('global-setter', {
|
|
18
|
+
category: 'control',
|
|
19
|
+
color: '#301934',
|
|
20
|
+
defaults: {
|
|
21
|
+
name: { value: "" },
|
|
22
|
+
path: { value: "", required: true }
|
|
23
|
+
},
|
|
24
|
+
inputs: 1,
|
|
25
|
+
outputs: 1,
|
|
26
|
+
icon: "font-awesome/fa-align-right",
|
|
27
|
+
label: function() {
|
|
28
|
+
// Remove #store: prefix for cleaner label
|
|
29
|
+
let lbl = this.path;
|
|
30
|
+
if (lbl && lbl.startsWith("#") && lbl.includes(":")) {
|
|
31
|
+
lbl = lbl.split(":")[3];
|
|
32
|
+
}
|
|
33
|
+
return this.name || lbl || "global set";
|
|
34
|
+
},
|
|
35
|
+
paletteLabel: "global set",
|
|
36
|
+
oneditprepare: function() {
|
|
37
|
+
// RESTRICT TO GLOBAL ONLY
|
|
38
|
+
$("#node-input-path").typedInput({
|
|
39
|
+
types: ['global']
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<!-- Help Section -->
|
|
46
|
+
<script type="text/markdown" data-help-name="global-setter">
|
|
47
|
+
Manage a global variable in a repeatable way.
|
|
48
|
+
|
|
49
|
+
### Inputs
|
|
50
|
+
: payload (any) : Input payload is passed through unchanged.
|
|
51
|
+
|
|
52
|
+
### Outputs
|
|
53
|
+
: payload (any) : Original payload.
|
|
54
|
+
|
|
55
|
+
### Details
|
|
56
|
+
Global variables are meant to be retrieved in other places, this necessarily means managing the same string in multiple places.
|
|
57
|
+
|
|
58
|
+
This node allows you to set a global variable in one place, and retrieve it elsewhere using the `global-getter` node while supporting rename and deletion.
|
|
59
|
+
|
|
60
|
+
When this node is deleted or the flow is redeployed, it will automatically remove (prune) the variable from the selected Context Store.
|
|
61
|
+
|
|
62
|
+
### Status
|
|
63
|
+
- Green (dot): Configuration update
|
|
64
|
+
- Blue (dot): State changed
|
|
65
|
+
- Blue (ring): State unchanged
|
|
66
|
+
- Red (ring): Error
|
|
67
|
+
- Yellow (ring): Warning
|
|
68
|
+
|
|
69
|
+
### References
|
|
70
|
+
- [Node-RED Documentation](https://nodered.org/docs/)
|
|
71
|
+
- [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
|
|
72
|
+
</script>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function GlobalSetterNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
|
|
6
|
+
const parsed = RED.util.parseContextStore(config.path);
|
|
7
|
+
|
|
8
|
+
node.varName = parsed.key;
|
|
9
|
+
node.storeName = parsed.store;
|
|
10
|
+
|
|
11
|
+
node.on('input', function(msg) {
|
|
12
|
+
if (node.varName) {
|
|
13
|
+
const globalContext = node.context().global;
|
|
14
|
+
|
|
15
|
+
// Create a clean wrapper object to store in global context
|
|
16
|
+
const storedObject = {
|
|
17
|
+
value: msg.payload,
|
|
18
|
+
meta: {
|
|
19
|
+
sourceId: node.id,
|
|
20
|
+
sourceName: node.name || config.path,
|
|
21
|
+
topic: msg.topic,
|
|
22
|
+
ts: Date.now()
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
globalContext.set(node.varName, storedObject, node.storeName);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
node.status({ fill: "blue", shape: "dot", text: `Set: ${msg.payload}` });
|
|
30
|
+
node.send(msg);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// CLEANUP
|
|
34
|
+
node.on('close', function(removed, done) {
|
|
35
|
+
if (node.varName) {
|
|
36
|
+
const globalContext = node.context().global;
|
|
37
|
+
globalContext.set(node.varName, undefined, node.storeName);
|
|
38
|
+
}
|
|
39
|
+
done();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
RED.nodes.registerType("global-setter", GlobalSetterNode);
|
|
43
|
+
}
|
|
@@ -84,7 +84,7 @@ module.exports = function(RED) {
|
|
|
84
84
|
|
|
85
85
|
// Add validation to ensure numbers
|
|
86
86
|
if (isNaN(upperTurnOn) || isNaN(upperTurnOff) || isNaN(lowerTurnOn) || isNaN(lowerTurnOff)) {
|
|
87
|
-
node.status({ fill: "red", shape: "ring", text: "invalid
|
|
87
|
+
node.status({ fill: "red", shape: "ring", text: "invalid limits calculation" });
|
|
88
88
|
if (done) done();
|
|
89
89
|
return;
|
|
90
90
|
}
|