@bldgblocks/node-red-contrib-control 0.1.4
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 +43 -0
- package/nodes/accumulate-block.html +71 -0
- package/nodes/accumulate-block.js +104 -0
- package/nodes/add-block.html +67 -0
- package/nodes/add-block.js +97 -0
- package/nodes/analog-switch-block.html +65 -0
- package/nodes/analog-switch-block.js +129 -0
- package/nodes/and-block.html +64 -0
- package/nodes/and-block.js +73 -0
- package/nodes/average-block.html +97 -0
- package/nodes/average-block.js +137 -0
- package/nodes/boolean-switch-block.html +59 -0
- package/nodes/boolean-switch-block.js +88 -0
- package/nodes/boolean-to-number-block.html +59 -0
- package/nodes/boolean-to-number-block.js +45 -0
- package/nodes/cache-block.html +69 -0
- package/nodes/cache-block.js +106 -0
- package/nodes/call-status-block.html +111 -0
- package/nodes/call-status-block.js +274 -0
- package/nodes/changeover-block.html +234 -0
- package/nodes/changeover-block.js +392 -0
- package/nodes/comment-block.html +83 -0
- package/nodes/comment-block.js +53 -0
- package/nodes/compare-block.html +64 -0
- package/nodes/compare-block.js +84 -0
- package/nodes/contextual-label-block.html +67 -0
- package/nodes/contextual-label-block.js +52 -0
- package/nodes/convert-block.html +179 -0
- package/nodes/convert-block.js +289 -0
- package/nodes/count-block.html +57 -0
- package/nodes/count-block.js +92 -0
- package/nodes/debounce-block.html +64 -0
- package/nodes/debounce-block.js +140 -0
- package/nodes/delay-block.html +104 -0
- package/nodes/delay-block.js +180 -0
- package/nodes/divide-block.html +65 -0
- package/nodes/divide-block.js +123 -0
- package/nodes/edge-block.html +71 -0
- package/nodes/edge-block.js +120 -0
- package/nodes/frequency-block.html +55 -0
- package/nodes/frequency-block.js +140 -0
- package/nodes/hysteresis-block.html +131 -0
- package/nodes/hysteresis-block.js +142 -0
- package/nodes/interpolate-block.html +74 -0
- package/nodes/interpolate-block.js +141 -0
- package/nodes/load-sequence-block.html +134 -0
- package/nodes/load-sequence-block.js +272 -0
- package/nodes/max-block.html +76 -0
- package/nodes/max-block.js +103 -0
- package/nodes/memory-block.html +90 -0
- package/nodes/memory-block.js +241 -0
- package/nodes/min-block.html +77 -0
- package/nodes/min-block.js +106 -0
- package/nodes/minmax-block.html +89 -0
- package/nodes/minmax-block.js +119 -0
- package/nodes/modulo-block.html +73 -0
- package/nodes/modulo-block.js +126 -0
- package/nodes/multiply-block.html +63 -0
- package/nodes/multiply-block.js +115 -0
- package/nodes/negate-block.html +55 -0
- package/nodes/negate-block.js +91 -0
- package/nodes/nullify-block.html +111 -0
- package/nodes/nullify-block.js +78 -0
- package/nodes/on-change-block.html +79 -0
- package/nodes/on-change-block.js +191 -0
- package/nodes/oneshot-block.html +96 -0
- package/nodes/oneshot-block.js +169 -0
- package/nodes/or-block.html +64 -0
- package/nodes/or-block.js +73 -0
- package/nodes/pid-block.html +205 -0
- package/nodes/pid-block.js +407 -0
- package/nodes/priority-block.html +66 -0
- package/nodes/priority-block.js +239 -0
- package/nodes/rate-limit-block.html +99 -0
- package/nodes/rate-limit-block.js +221 -0
- package/nodes/round-block.html +73 -0
- package/nodes/round-block.js +89 -0
- package/nodes/saw-tooth-wave-block.html +87 -0
- package/nodes/saw-tooth-wave-block.js +161 -0
- package/nodes/scale-range-block.html +90 -0
- package/nodes/scale-range-block.js +137 -0
- package/nodes/sine-wave-block.html +88 -0
- package/nodes/sine-wave-block.js +142 -0
- package/nodes/subtract-block.html +64 -0
- package/nodes/subtract-block.js +103 -0
- package/nodes/thermistor-block.html +81 -0
- package/nodes/thermistor-block.js +146 -0
- package/nodes/tick-tock-block.html +66 -0
- package/nodes/tick-tock-block.js +110 -0
- package/nodes/time-sequence-block.html +67 -0
- package/nodes/time-sequence-block.js +144 -0
- package/nodes/triangle-wave-block.html +86 -0
- package/nodes/triangle-wave-block.js +154 -0
- package/nodes/tstat-block.html +311 -0
- package/nodes/tstat-block.js +499 -0
- package/nodes/units-block.html +150 -0
- package/nodes/units-block.js +106 -0
- package/package.json +73 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function OneshotBlockNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
|
|
6
|
+
// Initialize runtime state
|
|
7
|
+
const durationMultiplier = config.durationUnits === "seconds" ? 1000 : config.durationUnits === "minutes" ? 60000 : 1;
|
|
8
|
+
node.runtime = {
|
|
9
|
+
name: config.name,
|
|
10
|
+
duration: (parseFloat(config.duration)) * durationMultiplier,
|
|
11
|
+
durationUnits: config.durationUnits,
|
|
12
|
+
resetRequireTrue: config.resetRequireTrue,
|
|
13
|
+
resetOnComplete: config.resetOnComplete,
|
|
14
|
+
triggerCount: 0,
|
|
15
|
+
locked: false,
|
|
16
|
+
output: false
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Validate initial config
|
|
20
|
+
if (isNaN(node.runtime.duration) || node.runtime.duration < 1) {
|
|
21
|
+
node.runtime.duration = 1000;
|
|
22
|
+
node.runtime.durationUnits = "milliseconds";
|
|
23
|
+
node.status({ fill: "red", shape: "ring", text: "invalid duration" });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Timer for pulse
|
|
27
|
+
let timer = null;
|
|
28
|
+
|
|
29
|
+
// Set initial status
|
|
30
|
+
node.status({
|
|
31
|
+
fill: "blue",
|
|
32
|
+
shape: "ring",
|
|
33
|
+
text: `triggers: ${node.runtime.triggerCount}, ${node.runtime.locked ? "locked" : "unlocked"}`
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
node.on("input", function(msg, send, done) {
|
|
37
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
38
|
+
|
|
39
|
+
// Guard against invalid message
|
|
40
|
+
if (!msg) {
|
|
41
|
+
node.status({ fill: "red", shape: "ring", text: "invalid message" });
|
|
42
|
+
if (done) done();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Handle context updates
|
|
47
|
+
if (msg.hasOwnProperty("context")) {
|
|
48
|
+
if (msg.context === "reset") {
|
|
49
|
+
if (node.runtime.resetRequireTrue && msg.payload !== true) {
|
|
50
|
+
node.status({ fill: "red", shape: "ring", text: "invalid reset payload" });
|
|
51
|
+
if (done) done();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (timer) {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
timer = null;
|
|
57
|
+
}
|
|
58
|
+
node.runtime.locked = false;
|
|
59
|
+
node.runtime.output = false;
|
|
60
|
+
node.status({
|
|
61
|
+
fill: "blue",
|
|
62
|
+
shape: "dot",
|
|
63
|
+
text: `triggers: ${node.runtime.triggerCount}, reset`
|
|
64
|
+
});
|
|
65
|
+
send({ payload: false });
|
|
66
|
+
if (done) done();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (msg.context === "duration") {
|
|
70
|
+
if (!msg.hasOwnProperty("payload")) {
|
|
71
|
+
node.status({ fill: "red", shape: "ring", text: "missing payload for duration" });
|
|
72
|
+
if (done) done();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
let newDuration = parseFloat(msg.payload);
|
|
76
|
+
const newDurationUnits = msg.units || "milliseconds";
|
|
77
|
+
const multiplier = newDurationUnits === "seconds" ? 1000 : newDurationUnits === "minutes" ? 60000 : 1;
|
|
78
|
+
newDuration *= multiplier;
|
|
79
|
+
if (isNaN(newDuration) || newDuration < 1) {
|
|
80
|
+
node.status({ fill: "red", shape: "ring", text: "invalid duration" });
|
|
81
|
+
if (done) done();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
node.runtime.duration = newDuration;
|
|
85
|
+
node.runtime.durationUnits = newDurationUnits;
|
|
86
|
+
node.status({
|
|
87
|
+
fill: "green",
|
|
88
|
+
shape: "dot",
|
|
89
|
+
text: `duration: ${node.runtime.duration.toFixed(0)} ms`
|
|
90
|
+
});
|
|
91
|
+
if (done) done();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
node.status({ fill: "yellow", shape: "ring", text: "unknown context" });
|
|
95
|
+
if (done) done("Unknown context");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Validate payload for trigger
|
|
100
|
+
if (msg.payload !== true) {
|
|
101
|
+
node.status({
|
|
102
|
+
fill: "yellow",
|
|
103
|
+
shape: "ring",
|
|
104
|
+
text: `ignored: non-true`
|
|
105
|
+
});
|
|
106
|
+
if (done) done();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check if locked
|
|
111
|
+
if (node.runtime.locked) {
|
|
112
|
+
node.status({
|
|
113
|
+
fill: "red",
|
|
114
|
+
shape: "ring",
|
|
115
|
+
text: `triggers: ${node.runtime.triggerCount}, locked`
|
|
116
|
+
});
|
|
117
|
+
send({ payload: node.runtime.output });
|
|
118
|
+
if (done) done();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Trigger pulse
|
|
123
|
+
node.runtime.triggerCount++;
|
|
124
|
+
node.runtime.locked = true;
|
|
125
|
+
node.runtime.output = true;
|
|
126
|
+
|
|
127
|
+
// Send true pulse
|
|
128
|
+
node.status({
|
|
129
|
+
fill: "green",
|
|
130
|
+
shape: "dot",
|
|
131
|
+
text: `triggers: ${node.runtime.triggerCount}, out: true`
|
|
132
|
+
});
|
|
133
|
+
send({ payload: true });
|
|
134
|
+
|
|
135
|
+
// Schedule false output
|
|
136
|
+
timer = setTimeout(() => {
|
|
137
|
+
node.runtime.output = false;
|
|
138
|
+
if (node.runtime.resetOnComplete) {
|
|
139
|
+
node.runtime.locked = false;
|
|
140
|
+
node.status({
|
|
141
|
+
fill: "blue",
|
|
142
|
+
shape: "ring",
|
|
143
|
+
text: `triggers: ${node.runtime.triggerCount}, unlocked`
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
node.status({
|
|
147
|
+
fill: "red",
|
|
148
|
+
shape: "ring",
|
|
149
|
+
text: `triggers: ${node.runtime.triggerCount}, locked`
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
send({ payload: false });
|
|
153
|
+
timer = null;
|
|
154
|
+
}, node.runtime.duration);
|
|
155
|
+
|
|
156
|
+
if (done) done();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
node.on("close", function(done) {
|
|
160
|
+
if (timer) {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
timer = null;
|
|
163
|
+
}
|
|
164
|
+
done();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
RED.nodes.registerType("oneshot-block", OneshotBlockNode);
|
|
169
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="or-block">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-name" title="Display name shown on the canvas"><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-slots" title="Number of boolean inputs (integer ≥ 2)"><i class="fa fa-list"></i> Slots</label>
|
|
8
|
+
<input type="number" id="node-input-slots" placeholder="2" min="2" step="1">
|
|
9
|
+
</div>
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script type="text/javascript">
|
|
13
|
+
RED.nodes.registerType("or-block", {
|
|
14
|
+
category: "control",
|
|
15
|
+
color: "#301934",
|
|
16
|
+
defaults: {
|
|
17
|
+
name: { value: "" },
|
|
18
|
+
slots: {
|
|
19
|
+
value: 2,
|
|
20
|
+
required: true,
|
|
21
|
+
validate: function(v) {
|
|
22
|
+
const num = parseFloat(v, 10);
|
|
23
|
+
return !isNaN(num) && num >= 2;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
inputs: 1,
|
|
28
|
+
outputs: 1,
|
|
29
|
+
inputLabels: ["input"],
|
|
30
|
+
outputLabels: ["output"],
|
|
31
|
+
icon: "join.svg",
|
|
32
|
+
paletteLabel: "or",
|
|
33
|
+
label: function() {
|
|
34
|
+
return this.name ? `${this.name} (${this.slots})` : `or (${this.slots})`;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<script type="text/markdown" data-help-name="or-block">
|
|
40
|
+
Computes the logical OR of multiple boolean inputs, outputting a new message with the result.
|
|
41
|
+
|
|
42
|
+
### Inputs
|
|
43
|
+
: context (string) : Identifies the input slot (e.g., `"in1"`, `"in2"`).
|
|
44
|
+
: payload (any) : Value for the slot, converted to boolean (`true`, `1` → `true`; `false`, `0`, `null` → `false`).
|
|
45
|
+
|
|
46
|
+
### Outputs
|
|
47
|
+
: payload (boolean) : `true` if any slot is `true`, `false` otherwise.
|
|
48
|
+
|
|
49
|
+
### Details
|
|
50
|
+
Evaluates the logical OR of a fixed number of boolean inputs (`slots` ≥ 2, set in editor).
|
|
51
|
+
|
|
52
|
+
Each slot is updated via `msg.context = "inX"` (e.g., `"in1"`, `"in2"`) with `msg.payload` converted to boolean.
|
|
53
|
+
|
|
54
|
+
### Status
|
|
55
|
+
- Green (dot): Configuration update
|
|
56
|
+
- Blue (dot): State changed
|
|
57
|
+
- Blue (ring): State unchanged
|
|
58
|
+
- Red (ring): Error
|
|
59
|
+
- Yellow (ring): Warning
|
|
60
|
+
|
|
61
|
+
### References
|
|
62
|
+
- [Node-RED Documentation](https://nodered.org/docs/)
|
|
63
|
+
- [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
|
|
64
|
+
</script>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function OrBlockNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
|
|
6
|
+
// Initialize state
|
|
7
|
+
node.inputs = Array(parseInt(config.slots) || 2).fill(false)
|
|
8
|
+
node.slots = parseInt(config.slots);
|
|
9
|
+
|
|
10
|
+
node.status({ fill: "green", shape: "dot", text: `slots: ${node.slots}` });
|
|
11
|
+
|
|
12
|
+
// Initialize logic fields
|
|
13
|
+
let lastResult = null;
|
|
14
|
+
let lastInputs = node.inputs.slice();
|
|
15
|
+
|
|
16
|
+
node.on("input", function(msg, send, done) {
|
|
17
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
18
|
+
|
|
19
|
+
// Guard against invalid msg
|
|
20
|
+
if (!msg) {
|
|
21
|
+
node.status({ fill: "red", shape: "ring", text: "invalid message" });
|
|
22
|
+
if (done) done();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check required properties
|
|
27
|
+
if (!msg.hasOwnProperty("context")) {
|
|
28
|
+
node.status({ fill: "red", shape: "ring", text: "missing context" });
|
|
29
|
+
if (done) done();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!msg.hasOwnProperty("payload")) {
|
|
34
|
+
node.status({ fill: "red", shape: "ring", text: "missing payload" });
|
|
35
|
+
if (done) done();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Process input slot
|
|
40
|
+
if (msg.context.startsWith("in")) {
|
|
41
|
+
let index = parseInt(msg.context.slice(2), 10);
|
|
42
|
+
if (!isNaN(index) && index >= 1 && index <= node.slots) {
|
|
43
|
+
node.inputs[index - 1] = Boolean(msg.payload);
|
|
44
|
+
const result = node.inputs.some(v => v === true);
|
|
45
|
+
const isUnchanged = result === lastResult && node.inputs.every((v, i) => v === lastInputs[i]);
|
|
46
|
+
node.status({
|
|
47
|
+
fill: "blue",
|
|
48
|
+
shape: isUnchanged ? "ring" : "dot",
|
|
49
|
+
text: `in: [${node.inputs.join(", ")}], out: ${result}`
|
|
50
|
+
});
|
|
51
|
+
lastResult = result;
|
|
52
|
+
lastInputs = node.inputs.slice();
|
|
53
|
+
send({ payload: result });
|
|
54
|
+
if (done) done();
|
|
55
|
+
return;
|
|
56
|
+
} else {
|
|
57
|
+
node.status({ fill: "red", shape: "ring", text: `invalid input index ${index || "NaN"}` });
|
|
58
|
+
if (done) done();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
node.status({ fill: "yellow", shape: "ring", text: "unknown context" });
|
|
64
|
+
if (done) done();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
node.on("close", function(done) {
|
|
68
|
+
done();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
RED.nodes.registerType("or-block", OrBlockNode);
|
|
73
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<!-- UI Template Section -->
|
|
2
|
+
<script type="text/html" data-template-name="pid-block">
|
|
3
|
+
<div class="form-row">
|
|
4
|
+
<label for="node-input-name" title="Display name shown on the canvas"><i class="fa fa-tag"></i> Name</label>
|
|
5
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
6
|
+
</div>
|
|
7
|
+
<div class="form-row">
|
|
8
|
+
<label for="node-input-kp" title="Proportional gain (number)"><i class="fa fa-sliders"></i> Kp</label>
|
|
9
|
+
<input type="number" id="node-input-kp" placeholder="0" step="any">
|
|
10
|
+
</div>
|
|
11
|
+
<div class="form-row">
|
|
12
|
+
<label for="node-input-ki" title="Integral gain (number)"><i class="fa fa-sliders"></i> Ki</label>
|
|
13
|
+
<input type="number" id="node-input-ki" placeholder="0" step="any">
|
|
14
|
+
</div>
|
|
15
|
+
<div class="form-row">
|
|
16
|
+
<label for="node-input-kd" title="Derivative gain (number)"><i class="fa fa-sliders"></i> Kd</label>
|
|
17
|
+
<input type="number" id="node-input-kd" placeholder="0" step="any">
|
|
18
|
+
</div>
|
|
19
|
+
<div class="form-row">
|
|
20
|
+
<label for="node-input-setpoint" title="Target setpoint (number)"><i class="fa fa-crosshairs"></i> Setpoint</label>
|
|
21
|
+
<input type="number" id="node-input-setpoint" placeholder="0" step="any">
|
|
22
|
+
</div>
|
|
23
|
+
<div class="form-row">
|
|
24
|
+
<label for="node-input-deadband" title="Deadband range around setpoint (non-negative number)"><i class="fa fa-arrows-h"></i> Deadband</label>
|
|
25
|
+
<input type="number" id="node-input-deadband" placeholder="0" step="any" min="0">
|
|
26
|
+
</div>
|
|
27
|
+
<div class="form-row">
|
|
28
|
+
<label for="node-input-dbBehavior" title="Deadband behavior: ReturnToZero or HoldLastResult"><i class="fa fa-cog"></i> Deadband Behavior</label>
|
|
29
|
+
<select id="node-input-dbBehavior">
|
|
30
|
+
<option value="ReturnToZero">ReturnToZero</option>
|
|
31
|
+
<option value="HoldLastResult">HoldLastResult</option>
|
|
32
|
+
</select>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="form-row">
|
|
35
|
+
<label for="node-input-outMin" title="Minimum output limit (number, less than outMax, leave empty for no limit)"><i class="fa fa-arrow-down"></i> Out Min</label>
|
|
36
|
+
<input type="number" id="node-input-outMin" placeholder="No min" step="any">
|
|
37
|
+
</div>
|
|
38
|
+
<div class="form-row">
|
|
39
|
+
<label for="node-input-outMax" title="Maximum output limit (number, greater than outMin, leave empty for no limit)"><i class="fa fa-arrow-up"></i> Out Max</label>
|
|
40
|
+
<input type="number" id="node-input-outMax" placeholder="No max" step="any">
|
|
41
|
+
</div>
|
|
42
|
+
<div class="form-row">
|
|
43
|
+
<label for="node-input-maxChange" title="Maximum output change per cycle (non-negative number)"><i class="fa fa-exchange"></i> Max Change</label>
|
|
44
|
+
<input type="number" id="node-input-maxChange" placeholder="0" step="any" min="0">
|
|
45
|
+
</div>
|
|
46
|
+
<div class="form-row">
|
|
47
|
+
<label for="node-input-directAction" title="Direct (true) or reverse (false) action"><i class="fa fa-exchange"></i> Direct Action</label>
|
|
48
|
+
<input type="checkbox" id="node-input-directAction" style="width: auto; vertical-align: middle;">
|
|
49
|
+
</div>
|
|
50
|
+
<div class="form-row">
|
|
51
|
+
<label for="node-input-run" title="Enable (true) or disable (false) PID calculation"><i class="fa fa-play"></i> Run</label>
|
|
52
|
+
<input type="checkbox" id="node-input-run" style="width: auto; vertical-align: middle;" checked>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="form-row">
|
|
55
|
+
<label><i class="fa fa-info-circle"></i> Changed Runtime Values</label>
|
|
56
|
+
<pre id="node-runtime-changes" style="color: #555; white-space: pre-wrap;">Changed Values: None</pre>
|
|
57
|
+
</div>
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<!-- JavaScript Section -->
|
|
61
|
+
<script type="text/javascript">
|
|
62
|
+
RED.nodes.registerType("pid-block", {
|
|
63
|
+
category: "control",
|
|
64
|
+
color: "#301934",
|
|
65
|
+
defaults: {
|
|
66
|
+
name: { value: "" },
|
|
67
|
+
kp: { value: 0, required: true, validate: function(v) { return !isNaN(parseFloat(v)) && isFinite(parseFloat(v)); } },
|
|
68
|
+
ki: { value: 0, required: true, validate: function(v) { return !isNaN(parseFloat(v)) && isFinite(parseFloat(v)); } },
|
|
69
|
+
kd: { value: 0, required: true, validate: function(v) { return !isNaN(parseFloat(v)) && isFinite(parseFloat(v)); } },
|
|
70
|
+
setpoint: { value: 0, required: true, validate: function(v) { return !isNaN(parseFloat(v)) && isFinite(parseFloat(v)); } },
|
|
71
|
+
deadband: { value: 0, required: true, validate: function(v) { return !isNaN(parseFloat(v)) && parseFloat(v) >= 0 && isFinite(parseFloat(v)); } },
|
|
72
|
+
dbBehavior: { value: "ReturnToZero" },
|
|
73
|
+
outMin: { value: null, validate: function(v) { return v === "" || (!isNaN(parseFloat(v)) && isFinite(parseFloat(v))); } },
|
|
74
|
+
outMax: { value: null, validate: function(v) { return v === "" || (!isNaN(parseFloat(v)) && isFinite(parseFloat(v))); } },
|
|
75
|
+
maxChange: { value: 0, required: true, validate: function(v) { return !isNaN(parseFloat(v)) && parseFloat(v) >= 0 && isFinite(parseFloat(v)); } },
|
|
76
|
+
directAction: { value: false },
|
|
77
|
+
run: { value: true }
|
|
78
|
+
},
|
|
79
|
+
inputs: 1,
|
|
80
|
+
outputs: 1,
|
|
81
|
+
inputLabels: ["input"],
|
|
82
|
+
outputLabels: ["output"],
|
|
83
|
+
icon: "font-awesome/fa-cogs",
|
|
84
|
+
paletteLabel: "pid",
|
|
85
|
+
label: function() {
|
|
86
|
+
return this.name || "pid";
|
|
87
|
+
},
|
|
88
|
+
oneditprepare: function() {
|
|
89
|
+
const node = this;
|
|
90
|
+
$("#node-input-name").val(node.name || "");
|
|
91
|
+
$("#node-input-kp").val(node.kp || 0);
|
|
92
|
+
$("#node-input-ki").val(node.ki || 0);
|
|
93
|
+
$("#node-input-kd").val(node.kd || 0);
|
|
94
|
+
$("#node-input-setpoint").val(node.setpoint || 0);
|
|
95
|
+
$("#node-input-deadband").val(node.deadband || 0);
|
|
96
|
+
$("#node-input-dbBehavior").val(node.dbBehavior || "ReturnToZero");
|
|
97
|
+
$("#node-input-outMin").val(node.outMin == null ? "" : node.outMin);
|
|
98
|
+
$("#node-input-outMax").val(node.outMax == null ? "" : node.outMax);
|
|
99
|
+
$("#node-input-maxChange").val(node.maxChange || 0);
|
|
100
|
+
$("#node-input-directAction").prop("checked", !!node.directAction);
|
|
101
|
+
$("#node-input-run").prop("checked", node.run !== false);
|
|
102
|
+
|
|
103
|
+
$.getJSON(`/pid-block-runtime/${this.id}?t=${Date.now()}`, function(data) {
|
|
104
|
+
const changes = [];
|
|
105
|
+
if (data.name) changes.push(`name: ${data.name}`);
|
|
106
|
+
if (data.kp !== parseFloat(node.kp || 0)) changes.push(`kp: ${data.kp.toFixed(2)}`);
|
|
107
|
+
if (data.ki !== parseFloat(node.ki || 0)) changes.push(`ki: ${data.ki.toFixed(2)}`);
|
|
108
|
+
if (data.kd !== parseFloat(node.kd || 0)) changes.push(`kd: ${data.kd.toFixed(2)}`);
|
|
109
|
+
if (data.setpoint !== parseFloat(node.setpoint || 0)) changes.push(`setpoint: ${data.setpoint.toFixed(2)}`);
|
|
110
|
+
if (data.deadband !== parseFloat(node.deadband || 0)) changes.push(`deadband: ${data.deadband.toFixed(2)}`);
|
|
111
|
+
if (data.dbBehavior !== (node.dbBehavior || "ReturnToZero")) changes.push(`dbBehavior: ${data.dbBehavior}`);
|
|
112
|
+
if (data.outMin != null && data.outMin !== parseFloat(node.outMin || null)) changes.push(`outMin: ${data.outMin.toFixed(2)}`);
|
|
113
|
+
if (data.outMax != null && data.outMax !== parseFloat(node.outMax || null)) changes.push(`outMax: ${data.outMax.toFixed(2)}`);
|
|
114
|
+
if (data.maxChange !== parseFloat(node.maxChange || 0)) changes.push(`maxChange: ${data.maxChange.toFixed(2)}`);
|
|
115
|
+
if (data.directAction !== !!node.directAction) changes.push(`directAction: ${data.directAction}`);
|
|
116
|
+
if (data.run !== (node.run !== false)) changes.push(`run: ${data.run}`);
|
|
117
|
+
$("#node-runtime-changes").text(changes.length > 0 ? `Changed Values:\n${changes.join("\n")}` : "Changed Values: None");
|
|
118
|
+
}).fail(function() {
|
|
119
|
+
$("#node-runtime-changes").text("Changed Values: Unknown");
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
oneditsave: function() {
|
|
123
|
+
// Standard config properties are automatically saved by Node-RED
|
|
124
|
+
},
|
|
125
|
+
oneditvalidate: function() {
|
|
126
|
+
const kp = parseFloat($("#node-input-kp").val());
|
|
127
|
+
const ki = parseFloat($("#node-input-ki").val());
|
|
128
|
+
const kd = parseFloat($("#node-input-kd").val());
|
|
129
|
+
const setpoint = parseFloat($("#node-input-setpoint").val());
|
|
130
|
+
const deadband = parseFloat($("#node-input-deadband").val());
|
|
131
|
+
const outMin = $("#node-input-outMin").val() === "" ? null : parseFloat($("#node-input-outMin").val());
|
|
132
|
+
const outMax = $("#node-input-outMax").val() === "" ? null : parseFloat($("#node-input-outMax").val());
|
|
133
|
+
const maxChange = parseFloat($("#node-input-maxChange").val());
|
|
134
|
+
return !isNaN(kp) && isFinite(kp) &&
|
|
135
|
+
!isNaN(ki) && isFinite(ki) &&
|
|
136
|
+
!isNaN(kd) && isFinite(kd) &&
|
|
137
|
+
!isNaN(setpoint) && isFinite(setpoint) &&
|
|
138
|
+
!isNaN(deadband) && isFinite(deadband) && deadband >= 0 &&
|
|
139
|
+
(outMin === null || (!isNaN(outMin) && isFinite(outMin))) &&
|
|
140
|
+
(outMax === null || (!isNaN(outMax) && isFinite(outMax))) &&
|
|
141
|
+
(outMin === null || outMax === null || outMax > outMin) &&
|
|
142
|
+
!isNaN(maxChange) && isFinite(maxChange) && maxChange >= 0;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<!-- Help Section -->
|
|
148
|
+
<script type="text/markdown" data-help-name="pid-block">
|
|
149
|
+
Implements a PID controller with deadband, output limits, and tuning.
|
|
150
|
+
|
|
151
|
+
### Inputs
|
|
152
|
+
: context (string) : Configures settings (`"setpoint"`, `"kp"`, `"ki"`, `"kd"`, `"deadband"`, `"dbBehavior"`, `"outMin"`, `"outMax"`, `"maxChange"`, `"directAction"`, `"run"`, `"reset"`, `"tune"`). Unmatched values trigger error.
|
|
153
|
+
: payload (number | boolean | string) : Number for input/setpoint/kp/ki/kd/deadband/outMin/outMax/maxChange/tune kp, boolean for run/directAction/reset, string for dbBehavior (`"ReturnToZero"`, `"HoldLastResult"`).
|
|
154
|
+
|
|
155
|
+
### Outputs
|
|
156
|
+
: payload (number) : PID control output.
|
|
157
|
+
: diagnostics (object) : `{ pGain, intGain, dGain, error, errorSum }`.
|
|
158
|
+
: tuneResult (object) : `{ Kp, Ki, Kd, Ku, Tu }` when tuning completes.
|
|
159
|
+
|
|
160
|
+
### Properties
|
|
161
|
+
: kp (number) : Proportional gain. Default: 0.
|
|
162
|
+
: ki (number) : Integral gain. Default: 0.
|
|
163
|
+
: kd (number) : Derivative gain. Default: 0.
|
|
164
|
+
: setpoint (number) : Target setpoint. Default: 0.
|
|
165
|
+
: deadband (number) : Deadband range around setpoint (non-negative). Default: 0.
|
|
166
|
+
: dbBehavior (string) : Deadband behavior (`"ReturnToZero"`, `"HoldLastResult"`). Default: `"ReturnToZero"`.
|
|
167
|
+
: outMin (number | null) : Minimum output limit (less than outMax). Default: null.
|
|
168
|
+
: outMax (number | null) : Maximum output limit (greater than outMin). Default: null.
|
|
169
|
+
: maxChange (number) : Maximum output change per cycle (non-negative). Default: 0.
|
|
170
|
+
: directAction (boolean) : Direct (true) or reverse (false) action. Default: false.
|
|
171
|
+
: run (boolean) : Enable (true) or disable (false) PID calculation. Default: true.
|
|
172
|
+
|
|
173
|
+
### Details
|
|
174
|
+
Calculates PID control output based on numeric `msg.payload`, setpoint, and gains (`kp`, `ki`, `kd`).
|
|
175
|
+
|
|
176
|
+
Supports deadband, output limits, rate of change limit, direct/reverse action, integral clamping, and Ziegler-Nichols tuning.
|
|
177
|
+
|
|
178
|
+
Outputs `{ payload: number, diagnostics: object }` when output changes, or `{ tuneResult: object }` on tuning completion.
|
|
179
|
+
|
|
180
|
+
Ziegler-Nichols tuning sets `kp = 0.6*Ku`, `ki = 2*kp/Tu`, `kd = kp*Tu/8` after detecting oscillations. Outputs only on change.
|
|
181
|
+
|
|
182
|
+
### Error Handling
|
|
183
|
+
- Missing `msg`: No output, red status (`invalid message`).
|
|
184
|
+
- Missing `msg.payload` for config: No output, red status (`missing payload for X`).
|
|
185
|
+
- Invalid context (non-string): No output, red status (`invalid context`).
|
|
186
|
+
- Invalid setpoint/kp/ki/kd/deadband/outMin/outMax/maxChange/tune kp (non-numeric, non-finite): No output, red status (`invalid X`).
|
|
187
|
+
- Invalid deadband/maxChange (negative): No output, red status (`invalid X`).
|
|
188
|
+
- Invalid run/directAction/reset (non-boolean, reset not true): No output, red status (`invalid X`).
|
|
189
|
+
- Invalid dbBehavior (not `"ReturnToZero"`, `"HoldLastResult"`): No output, red status (`invalid dbBehavior`).
|
|
190
|
+
- Invalid output range (outMax <= outMin): No output, red status (`invalid output range`).
|
|
191
|
+
- Invalid input (non-numeric, non-finite): No output, red status (`invalid input`).
|
|
192
|
+
- Unknown `msg.context`: No output, yellow status (`unknown context`), errors with message.
|
|
193
|
+
- Invalid config at startup: Red status (`invalid config` or specific), resets to defaults.
|
|
194
|
+
|
|
195
|
+
### Status
|
|
196
|
+
- Green (dot): Configuration, reset, or tuning (e.g., `setpoint: 50.00`, `reset`, `tune: completed, Kp=1.20, Ki=0.40, Kd=0.90`).
|
|
197
|
+
- Blue (dot): Output change (e.g., `in: 25.00, out: 50.00, setpoint: 50.00`).
|
|
198
|
+
- Blue (ring): Output unchanged (e.g., `in: 25.00, out: 50.00, setpoint: 50.00`).
|
|
199
|
+
- Red (ring): Errors (e.g., `invalid input`, `invalid setpoint`).
|
|
200
|
+
- Yellow (ring): Unknown context (e.g., `unknown context`).
|
|
201
|
+
|
|
202
|
+
### References
|
|
203
|
+
- [Node-RED Documentation](https://nodered.org/docs/)
|
|
204
|
+
- [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
|
|
205
|
+
</script>
|