@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.
Files changed (98) hide show
  1. package/README.md +43 -0
  2. package/nodes/accumulate-block.html +71 -0
  3. package/nodes/accumulate-block.js +104 -0
  4. package/nodes/add-block.html +67 -0
  5. package/nodes/add-block.js +97 -0
  6. package/nodes/analog-switch-block.html +65 -0
  7. package/nodes/analog-switch-block.js +129 -0
  8. package/nodes/and-block.html +64 -0
  9. package/nodes/and-block.js +73 -0
  10. package/nodes/average-block.html +97 -0
  11. package/nodes/average-block.js +137 -0
  12. package/nodes/boolean-switch-block.html +59 -0
  13. package/nodes/boolean-switch-block.js +88 -0
  14. package/nodes/boolean-to-number-block.html +59 -0
  15. package/nodes/boolean-to-number-block.js +45 -0
  16. package/nodes/cache-block.html +69 -0
  17. package/nodes/cache-block.js +106 -0
  18. package/nodes/call-status-block.html +111 -0
  19. package/nodes/call-status-block.js +274 -0
  20. package/nodes/changeover-block.html +234 -0
  21. package/nodes/changeover-block.js +392 -0
  22. package/nodes/comment-block.html +83 -0
  23. package/nodes/comment-block.js +53 -0
  24. package/nodes/compare-block.html +64 -0
  25. package/nodes/compare-block.js +84 -0
  26. package/nodes/contextual-label-block.html +67 -0
  27. package/nodes/contextual-label-block.js +52 -0
  28. package/nodes/convert-block.html +179 -0
  29. package/nodes/convert-block.js +289 -0
  30. package/nodes/count-block.html +57 -0
  31. package/nodes/count-block.js +92 -0
  32. package/nodes/debounce-block.html +64 -0
  33. package/nodes/debounce-block.js +140 -0
  34. package/nodes/delay-block.html +104 -0
  35. package/nodes/delay-block.js +180 -0
  36. package/nodes/divide-block.html +65 -0
  37. package/nodes/divide-block.js +123 -0
  38. package/nodes/edge-block.html +71 -0
  39. package/nodes/edge-block.js +120 -0
  40. package/nodes/frequency-block.html +55 -0
  41. package/nodes/frequency-block.js +140 -0
  42. package/nodes/hysteresis-block.html +131 -0
  43. package/nodes/hysteresis-block.js +142 -0
  44. package/nodes/interpolate-block.html +74 -0
  45. package/nodes/interpolate-block.js +141 -0
  46. package/nodes/load-sequence-block.html +134 -0
  47. package/nodes/load-sequence-block.js +272 -0
  48. package/nodes/max-block.html +76 -0
  49. package/nodes/max-block.js +103 -0
  50. package/nodes/memory-block.html +90 -0
  51. package/nodes/memory-block.js +241 -0
  52. package/nodes/min-block.html +77 -0
  53. package/nodes/min-block.js +106 -0
  54. package/nodes/minmax-block.html +89 -0
  55. package/nodes/minmax-block.js +119 -0
  56. package/nodes/modulo-block.html +73 -0
  57. package/nodes/modulo-block.js +126 -0
  58. package/nodes/multiply-block.html +63 -0
  59. package/nodes/multiply-block.js +115 -0
  60. package/nodes/negate-block.html +55 -0
  61. package/nodes/negate-block.js +91 -0
  62. package/nodes/nullify-block.html +111 -0
  63. package/nodes/nullify-block.js +78 -0
  64. package/nodes/on-change-block.html +79 -0
  65. package/nodes/on-change-block.js +191 -0
  66. package/nodes/oneshot-block.html +96 -0
  67. package/nodes/oneshot-block.js +169 -0
  68. package/nodes/or-block.html +64 -0
  69. package/nodes/or-block.js +73 -0
  70. package/nodes/pid-block.html +205 -0
  71. package/nodes/pid-block.js +407 -0
  72. package/nodes/priority-block.html +66 -0
  73. package/nodes/priority-block.js +239 -0
  74. package/nodes/rate-limit-block.html +99 -0
  75. package/nodes/rate-limit-block.js +221 -0
  76. package/nodes/round-block.html +73 -0
  77. package/nodes/round-block.js +89 -0
  78. package/nodes/saw-tooth-wave-block.html +87 -0
  79. package/nodes/saw-tooth-wave-block.js +161 -0
  80. package/nodes/scale-range-block.html +90 -0
  81. package/nodes/scale-range-block.js +137 -0
  82. package/nodes/sine-wave-block.html +88 -0
  83. package/nodes/sine-wave-block.js +142 -0
  84. package/nodes/subtract-block.html +64 -0
  85. package/nodes/subtract-block.js +103 -0
  86. package/nodes/thermistor-block.html +81 -0
  87. package/nodes/thermistor-block.js +146 -0
  88. package/nodes/tick-tock-block.html +66 -0
  89. package/nodes/tick-tock-block.js +110 -0
  90. package/nodes/time-sequence-block.html +67 -0
  91. package/nodes/time-sequence-block.js +144 -0
  92. package/nodes/triangle-wave-block.html +86 -0
  93. package/nodes/triangle-wave-block.js +154 -0
  94. package/nodes/tstat-block.html +311 -0
  95. package/nodes/tstat-block.js +499 -0
  96. package/nodes/units-block.html +150 -0
  97. package/nodes/units-block.js +106 -0
  98. package/package.json +73 -0
@@ -0,0 +1,407 @@
1
+ module.exports = function(RED) {
2
+ function PIDBlockNode(config) {
3
+ RED.nodes.createNode(this, config);
4
+ const node = this;
5
+
6
+ // Initialize runtime state
7
+ node.runtime = {
8
+ name: config.name || "",
9
+ kp: parseFloat(config.kp) || 0,
10
+ ki: parseFloat(config.ki) || 0,
11
+ kd: parseFloat(config.kd) || 0,
12
+ setpoint: parseFloat(config.setpoint) || 0,
13
+ deadband: parseFloat(config.deadband) || 0,
14
+ dbBehavior: config.dbBehavior || "ReturnToZero",
15
+ outMin: config.outMin ? parseFloat(config.outMin) : null,
16
+ outMax: config.outMax ? parseFloat(config.outMax) : null,
17
+ maxChange: parseFloat(config.maxChange) || 0,
18
+ directAction: !!config.directAction,
19
+ run: config.run !== false,
20
+ errorSum: 0,
21
+ lastError: 0,
22
+ lastDError: 0,
23
+ result: 0,
24
+ lastTime: Date.now(),
25
+ tuneMode: false,
26
+ tuneData: { oscillations: [], lastPeak: null, lastTrough: null, Ku: 0, Tu: 0 }
27
+ };
28
+
29
+ // Validate initial config
30
+ if (isNaN(node.runtime.kp) || isNaN(node.runtime.ki) || isNaN(node.runtime.kd) ||
31
+ isNaN(node.runtime.setpoint) || isNaN(node.runtime.deadband) || isNaN(node.runtime.maxChange) ||
32
+ !isFinite(node.runtime.kp) || !isFinite(node.runtime.ki) || !isFinite(node.runtime.kd) ||
33
+ !isFinite(node.runtime.setpoint) || !isFinite(node.runtime.deadband) || !isFinite(node.runtime.maxChange)) {
34
+ node.status({ fill: "red", shape: "ring", text: "invalid config" });
35
+ node.runtime.kp = node.runtime.ki = node.runtime.kd = node.runtime.setpoint = node.runtime.deadband = node.runtime.maxChange = 0;
36
+ }
37
+ if (node.runtime.deadband < 0 || node.runtime.maxChange < 0) {
38
+ node.status({ fill: "red", shape: "ring", text: "invalid deadband or maxChange" });
39
+ node.runtime.deadband = node.runtime.maxChange = 0;
40
+ }
41
+ if (node.runtime.outMin != null && node.runtime.outMax != null && node.runtime.outMax <= node.runtime.outMin) {
42
+ node.status({ fill: "red", shape: "ring", text: "invalid output range" });
43
+ node.runtime.outMin = node.runtime.outMax = null;
44
+ }
45
+ if (!["ReturnToZero", "HoldLastResult"].includes(node.runtime.dbBehavior)) {
46
+ node.status({ fill: "red", shape: "ring", text: "invalid dbBehavior" });
47
+ node.runtime.dbBehavior = "ReturnToZero";
48
+ }
49
+
50
+ // Initialize internal variables
51
+ let storekp = node.runtime.kp;
52
+ let storeki = node.runtime.ki;
53
+ let storemin = node.runtime.outMin;
54
+ let storemax = node.runtime.outMax;
55
+ let kpkiConst = node.runtime.kp * node.runtime.ki;
56
+ let minInt = kpkiConst === 0 ? 0 : (node.runtime.outMin || -Infinity) * kpkiConst;
57
+ let maxInt = kpkiConst === 0 ? 0 : (node.runtime.outMax || Infinity) * kpkiConst;
58
+ let lastOutput = null;
59
+
60
+ node.on("input", function(msg, send, done) {
61
+ send = send || function() { node.send.apply(node, arguments); };
62
+
63
+ // Guard against invalid message
64
+ if (!msg) {
65
+ node.status({ fill: "red", shape: "ring", text: "invalid message" });
66
+ if (done) done();
67
+ return;
68
+ }
69
+
70
+ // Handle context updates
71
+ if (msg.hasOwnProperty("context")) {
72
+ if (!msg.hasOwnProperty("payload")) {
73
+ node.status({ fill: "red", shape: "ring", text: `missing payload for ${msg.context}` });
74
+ if (done) done();
75
+ return;
76
+ }
77
+ if (typeof msg.context !== "string") {
78
+ node.status({ fill: "red", shape: "ring", text: "invalid context" });
79
+ if (done) done();
80
+ return;
81
+ }
82
+ if (["setpoint", "kp", "ki", "kd", "deadband", "outMin", "outMax", "maxChange"].includes(msg.context)) {
83
+ let value = parseFloat(msg.payload);
84
+ if (isNaN(value) || !isFinite(value)) {
85
+ node.status({ fill: "red", shape: "ring", text: `invalid ${msg.context}` });
86
+ if (done) done();
87
+ return;
88
+ }
89
+ if ((msg.context === "deadband" || msg.context === "maxChange") && value < 0) {
90
+ node.status({ fill: "red", shape: "ring", text: `invalid ${msg.context}` });
91
+ if (done) done();
92
+ return;
93
+ }
94
+ node.runtime[msg.context] = value;
95
+ if (msg.context === "outMin" || msg.context === "outMax") {
96
+ if (node.runtime.outMin != null && node.runtime.outMax != null && node.runtime.outMax <= node.runtime.outMin) {
97
+ node.status({ fill: "red", shape: "ring", text: "invalid output range" });
98
+ if (done) done();
99
+ return;
100
+ }
101
+ }
102
+ node.status({ fill: "green", shape: "dot", text: `${msg.context}: ${value.toFixed(2)}` });
103
+ } else if (["run", "directAction"].includes(msg.context)) {
104
+ if (typeof msg.payload !== "boolean") {
105
+ node.status({ fill: "red", shape: "ring", text: `invalid ${msg.context}` });
106
+ if (done) done();
107
+ return;
108
+ }
109
+ node.runtime[msg.context] = msg.payload;
110
+ node.status({ fill: "green", shape: "dot", text: `${msg.context}: ${msg.payload}` });
111
+ } else if (msg.context === "dbBehavior") {
112
+ if (!["ReturnToZero", "HoldLastResult"].includes(msg.payload)) {
113
+ node.status({ fill: "red", shape: "ring", text: "invalid dbBehavior" });
114
+ if (done) done();
115
+ return;
116
+ }
117
+ node.runtime.dbBehavior = msg.payload;
118
+ node.status({ fill: "green", shape: "dot", text: `dbBehavior: ${msg.payload}` });
119
+ } else if (msg.context === "reset") {
120
+ if (typeof msg.payload !== "boolean" || !msg.payload) {
121
+ node.status({ fill: "red", shape: "ring", text: "invalid reset" });
122
+ if (done) done();
123
+ return;
124
+ }
125
+ node.runtime.errorSum = 0;
126
+ node.runtime.lastError = 0;
127
+ node.runtime.lastDError = 0;
128
+ node.runtime.result = 0;
129
+ node.runtime.tuneMode = false;
130
+ node.runtime.tuneData = { oscillations: [], lastPeak: null, lastTrough: null, Ku: 0, Tu: 0 };
131
+ node.status({ fill: "green", shape: "dot", text: "reset" });
132
+ if (done) done();
133
+ return;
134
+ } else if (msg.context === "tune") {
135
+ let tuneKp = parseFloat(msg.payload);
136
+ if (isNaN(tuneKp) || !isFinite(tuneKp) || tuneKp <= 0) {
137
+ node.status({ fill: "red", shape: "ring", text: "invalid tune kp" });
138
+ if (done) done();
139
+ return;
140
+ }
141
+ node.runtime.tuneMode = true;
142
+ node.runtime.kp = tuneKp;
143
+ node.runtime.ki = 0;
144
+ node.runtime.kd = 0;
145
+ node.runtime.tuneData = { oscillations: [], lastPeak: null, lastTrough: null, Ku: 0, Tu: 0 };
146
+ node.status({ fill: "green", shape: "dot", text: `tune: started, kp=${tuneKp.toFixed(2)}` });
147
+ if (done) done();
148
+ return;
149
+ } else {
150
+ node.status({ fill: "yellow", shape: "ring", text: "unknown context" });
151
+ if (done) done("Unknown context");
152
+ return;
153
+ }
154
+ if (done) done();
155
+ return;
156
+ }
157
+
158
+ if (!msg.hasOwnProperty("payload")) {
159
+ node.status({ fill: "red", shape: "ring", text: "missing input" });
160
+ if (done) done();
161
+ return;
162
+ }
163
+
164
+ const input = parseFloat(msg.payload);
165
+ if (isNaN(input) || !isFinite(input)) {
166
+ node.status({ fill: "red", shape: "ring", text: "invalid input" });
167
+ if (done) done();
168
+ return;
169
+ }
170
+
171
+ // PID Calculation
172
+ let currentTime = Date.now();
173
+ let interval = (currentTime - node.runtime.lastTime) / 1000; // Seconds
174
+ node.runtime.lastTime = currentTime;
175
+
176
+ let outputMsg = { payload: 0, diagnostics: {} };
177
+ if (!node.runtime.run || interval <= 0 || node.runtime.kp === 0) {
178
+ if (lastOutput !== 0) {
179
+ lastOutput = 0;
180
+ node.status({
181
+ fill: "blue",
182
+ shape: "dot",
183
+ text: `in: ${input.toFixed(2)}, out: 0.00, setpoint: ${node.runtime.setpoint.toFixed(2)}`
184
+ });
185
+ send(outputMsg);
186
+ } else {
187
+ node.status({
188
+ fill: "blue",
189
+ shape: "ring",
190
+ text: `in: ${input.toFixed(2)}, out: 0.00, setpoint: ${node.runtime.setpoint.toFixed(2)}`
191
+ });
192
+ }
193
+ if (done) done();
194
+ return;
195
+ }
196
+
197
+ // Deadband check
198
+ if (node.runtime.deadband !== 0 && input <= node.runtime.setpoint + node.runtime.deadband && input >= node.runtime.setpoint - node.runtime.deadband) {
199
+ outputMsg.payload = node.runtime.dbBehavior === "ReturnToZero" ? 0 : node.runtime.result;
200
+ const outputChanged = !lastOutput || lastOutput !== outputMsg.payload;
201
+ if (outputChanged) {
202
+ lastOutput = outputMsg.payload;
203
+ node.status({
204
+ fill: "blue",
205
+ shape: "dot",
206
+ text: `in: ${input.toFixed(2)}, out: ${outputMsg.payload.toFixed(2)}, setpoint: ${node.runtime.setpoint.toFixed(2)}`
207
+ });
208
+ send(outputMsg);
209
+ } else {
210
+ node.status({
211
+ fill: "blue",
212
+ shape: "ring",
213
+ text: `in: ${input.toFixed(2)}, out: ${outputMsg.payload.toFixed(2)}, setpoint: ${node.runtime.setpoint.toFixed(2)}`
214
+ });
215
+ }
216
+ if (done) done();
217
+ return;
218
+ }
219
+
220
+ // Update internal constraints
221
+ if (node.runtime.kp !== storekp || node.runtime.ki !== storeki || node.runtime.outMin !== storemin || node.runtime.outMax !== storemax) {
222
+ if (node.runtime.kp !== storekp && node.runtime.kp !== 0 && storekp !== 0) {
223
+ node.runtime.errorSum = node.runtime.errorSum * storekp / node.runtime.kp;
224
+ }
225
+ if (node.runtime.ki !== storeki && node.runtime.ki !== 0 && storeki !== 0) {
226
+ node.runtime.errorSum = node.runtime.errorSum * storeki / node.runtime.ki;
227
+ }
228
+ kpkiConst = node.runtime.kp * node.runtime.ki;
229
+ minInt = kpkiConst === 0 ? 0 : (node.runtime.outMin || -Infinity) * kpkiConst;
230
+ maxInt = kpkiConst === 0 ? 0 : (node.runtime.outMax || Infinity) * kpkiConst;
231
+ storekp = node.runtime.kp;
232
+ storeki = node.runtime.ki;
233
+ storemin = node.runtime.outMin;
234
+ storemax = node.runtime.outMax;
235
+ }
236
+
237
+ // Calculate error
238
+ let error = node.runtime.setpoint - input;
239
+
240
+ // Tuning assistant (Ziegler-Nichols)
241
+ if (node.runtime.tuneMode) {
242
+ if (node.runtime.lastError > 0 && error <= 0) { // Peak detected
243
+ if (node.runtime.tuneData.lastPeak !== null) {
244
+ node.runtime.tuneData.oscillations.push({ time: currentTime, amplitude: node.runtime.tuneData.lastPeak });
245
+ }
246
+ node.runtime.tuneData.lastPeak = node.runtime.lastError;
247
+ } else if (node.runtime.lastError < 0 && error >= 0) { // Trough detected
248
+ node.runtime.tuneData.lastTrough = node.runtime.lastError;
249
+ }
250
+ if (node.runtime.tuneData.oscillations.length >= 3) { // Enough data to tune
251
+ let periodSum = 0;
252
+ for (let i = 1; i < node.runtime.tuneData.oscillations.length; i++) {
253
+ periodSum += (node.runtime.tuneData.oscillations[i].time - node.runtime.tuneData.oscillations[i-1].time) / 1000;
254
+ }
255
+ node.runtime.tuneData.Tu = periodSum / (node.runtime.tuneData.oscillations.length - 1); // Average period in seconds
256
+ node.runtime.tuneData.Ku = node.runtime.kp; // Ultimate gain
257
+ node.runtime.kp = 0.6 * node.runtime.tuneData.Ku;
258
+ node.runtime.ki = 2 * node.runtime.kp / node.runtime.tuneData.Tu;
259
+ node.runtime.kd = node.runtime.kp * node.runtime.tuneData.Tu / 8;
260
+ node.runtime.tuneMode = false;
261
+ outputMsg.payload = node.runtime.result;
262
+ outputMsg.tuneResult = {
263
+ Kp: node.runtime.kp,
264
+ Ki: node.runtime.ki,
265
+ Kd: node.runtime.kd,
266
+ Ku: node.runtime.tuneData.Ku,
267
+ Tu: node.runtime.tuneData.Tu
268
+ };
269
+ lastOutput = outputMsg.payload;
270
+ node.status({
271
+ fill: "green",
272
+ shape: "dot",
273
+ text: `tune: completed, Kp=${node.runtime.kp.toFixed(2)}, Ki=${node.runtime.ki.toFixed(2)}, Kd=${node.runtime.kd.toFixed(2)}`
274
+ });
275
+ send(outputMsg);
276
+ if (done) done();
277
+ return;
278
+ }
279
+ }
280
+
281
+ // Integral term
282
+ if (node.runtime.ki !== 0) {
283
+ node.runtime.errorSum += interval * error;
284
+ if (node.runtime.directAction) {
285
+ if (-node.runtime.errorSum > maxInt) node.runtime.errorSum = -maxInt;
286
+ else if (-node.runtime.errorSum < minInt) node.runtime.errorSum = -minInt;
287
+ } else {
288
+ node.runtime.errorSum = Math.min(Math.max(node.runtime.errorSum, minInt), maxInt);
289
+ }
290
+ }
291
+
292
+ // Gain calculations
293
+ let pGain = node.runtime.kp * error;
294
+ let intGain = node.runtime.ki !== 0 ? node.runtime.kp * node.runtime.ki * node.runtime.errorSum * interval : 0;
295
+ let dRaw = (error - node.runtime.lastError) / interval;
296
+ let dFiltered = node.runtime.kd !== 0 ? 0.1 * dRaw + 0.9 * node.runtime.lastDError : 0;
297
+ let dGain = node.runtime.kd !== 0 ? node.runtime.kp * node.runtime.kd * dFiltered : 0;
298
+
299
+ node.runtime.lastError = error;
300
+ node.runtime.lastDError = dFiltered;
301
+
302
+ // Output calculation
303
+ let pv = pGain + intGain + dGain;
304
+ if (node.runtime.directAction) pv = -pv;
305
+ pv = Math.min(Math.max(pv, node.runtime.outMin || -Infinity), node.runtime.outMax || Infinity);
306
+
307
+ // Rate of change limit
308
+ if (node.runtime.maxChange !== 0) {
309
+ if (node.runtime.result > pv) {
310
+ node.runtime.result = (node.runtime.result - pv > node.runtime.maxChange) ? node.runtime.result - node.runtime.maxChange : pv;
311
+ } else {
312
+ node.runtime.result = (pv - node.runtime.result > node.runtime.maxChange) ? node.runtime.result + node.runtime.maxChange : pv;
313
+ }
314
+ } else {
315
+ node.runtime.result = pv;
316
+ }
317
+
318
+ outputMsg.payload = node.runtime.result;
319
+ outputMsg.diagnostics = { pGain, intGain, dGain, error, errorSum: node.runtime.errorSum };
320
+
321
+ const outputChanged = !lastOutput || lastOutput !== outputMsg.payload;
322
+ if (outputChanged) {
323
+ lastOutput = outputMsg.payload;
324
+ node.status({
325
+ fill: "blue",
326
+ shape: "dot",
327
+ text: `in: ${input.toFixed(2)}, out: ${node.runtime.result.toFixed(2)}, setpoint: ${node.runtime.setpoint.toFixed(2)}`
328
+ });
329
+ send(outputMsg);
330
+ } else {
331
+ node.status({
332
+ fill: "blue",
333
+ shape: "ring",
334
+ text: `in: ${input.toFixed(2)}, out: ${node.runtime.result.toFixed(2)}, setpoint: ${node.runtime.setpoint.toFixed(2)}`
335
+ });
336
+ }
337
+
338
+ if (done) done();
339
+ });
340
+
341
+ node.on("close", function(done) {
342
+ node.runtime = {
343
+ name: config.name || "",
344
+ kp: parseFloat(config.kp) || 0,
345
+ ki: parseFloat(config.ki) || 0,
346
+ kd: parseFloat(config.kd) || 0,
347
+ setpoint: parseFloat(config.setpoint) || 0,
348
+ deadband: parseFloat(config.deadband) || 0,
349
+ dbBehavior: config.dbBehavior || "ReturnToZero",
350
+ outMin: config.outMin ? parseFloat(config.outMin) : null,
351
+ outMax: config.outMax ? parseFloat(config.outMax) : null,
352
+ maxChange: parseFloat(config.maxChange) || 0,
353
+ directAction: !!config.directAction,
354
+ run: config.run !== false,
355
+ errorSum: 0,
356
+ lastError: 0,
357
+ lastDError: 0,
358
+ result: 0,
359
+ lastTime: Date.now(),
360
+ tuneMode: false,
361
+ tuneData: { oscillations: [], lastPeak: null, lastTrough: null, Ku: 0, Tu: 0 }
362
+ };
363
+ if (isNaN(node.runtime.kp) || isNaN(node.runtime.ki) || isNaN(node.runtime.kd) ||
364
+ isNaN(node.runtime.setpoint) || isNaN(node.runtime.deadband) || isNaN(node.runtime.maxChange) ||
365
+ !isFinite(node.runtime.kp) || !isFinite(node.runtime.ki) || !isFinite(node.runtime.kd) ||
366
+ !isFinite(node.runtime.setpoint) || !isFinite(node.runtime.deadband) || !isFinite(node.runtime.maxChange)) {
367
+ node.runtime.kp = node.runtime.ki = node.runtime.kd = node.runtime.setpoint = node.runtime.deadband = node.runtime.maxChange = 0;
368
+ }
369
+ if (node.runtime.deadband < 0 || node.runtime.maxChange < 0) {
370
+ node.runtime.deadband = node.runtime.maxChange = 0;
371
+ }
372
+ if (node.runtime.outMin != null && node.runtime.outMax != null && node.runtime.outMax <= node.runtime.outMin) {
373
+ node.runtime.outMin = node.runtime.outMax = null;
374
+ }
375
+ if (!["ReturnToZero", "HoldLastResult"].includes(node.runtime.dbBehavior)) {
376
+ node.runtime.dbBehavior = "ReturnToZero";
377
+ }
378
+ node.status({});
379
+ done();
380
+ });
381
+ }
382
+
383
+ RED.nodes.registerType("pid-block", PIDBlockNode);
384
+
385
+ // Serve runtime state for editor
386
+ RED.httpAdmin.get("/pid-block-runtime/:id", RED.auth.needsPermission("pid-block.read"), function(req, res) {
387
+ const node = RED.nodes.getNode(req.params.id);
388
+ if (node && node.type === "pid-block") {
389
+ res.json({
390
+ name: node.runtime.name,
391
+ kp: node.runtime.kp,
392
+ ki: node.runtime.ki,
393
+ kd: node.runtime.kd,
394
+ setpoint: node.runtime.setpoint,
395
+ deadband: node.runtime.deadband,
396
+ dbBehavior: node.runtime.dbBehavior,
397
+ outMin: node.runtime.outMin,
398
+ outMax: node.runtime.outMax,
399
+ maxChange: node.runtime.maxChange,
400
+ directAction: node.runtime.directAction,
401
+ run: node.runtime.run
402
+ });
403
+ } else {
404
+ res.status(404).json({ error: "Node not found" });
405
+ }
406
+ });
407
+ };
@@ -0,0 +1,66 @@
1
+ <!-- UI Template Section: Defines the edit dialog -->
2
+ <script type="text/html" data-template-name="priority-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
+ </script>
8
+
9
+ <!-- JavaScript Section: Registers the node and handles editor logic -->
10
+ <script type="text/javascript">
11
+ RED.nodes.registerType("priority-block", {
12
+ category: "control",
13
+ color: "#301934",
14
+ defaults: {
15
+ name: { value: "" }
16
+ },
17
+ inputs: 1,
18
+ outputs: 1,
19
+ inputLabels: ["input"],
20
+ outputLabels: ["output"],
21
+ icon: "font-awesome/fa-list-ol",
22
+ paletteLabel: "priority",
23
+ label: function() {
24
+ return this.name || "priority";
25
+ }
26
+ });
27
+ </script>
28
+
29
+ <!-- Help Section -->
30
+ <script type="text/markdown" data-help-name="priority-block">
31
+ Implements a priority array with 16 levels, default, and fallback.
32
+
33
+ ### Inputs
34
+ : context (string) : Identifies slot (`"priority1"` to `"priority16"`, `"default"`, `"fallback"`) for setting or clearing. Ignored if `msg.payload` is an object with `clear` key.
35
+ : payload (any | object | string) :
36
+ - Non-object, non-"clear" (number, boolean, null) Value for slot specified by `msg.context` (numbers parsed as float, null to relinquish).
37
+ - String `"clear"` Relinquish slot specified by `msg.context`.
38
+ - Object with `clear`
39
+ - `{ clear "priorityX" }` Relinquish single slot.
40
+ - `{ clear ["priorityX", ...] }` Relinquish multiple slots.
41
+ - `{ clear "all" }` Relinquish all slots.
42
+
43
+ ### Outputs
44
+ : payload (any) : Value from highest non-null slot (`priority1` to `fallback`), or `null` if none set.
45
+ : diagnostics.activePriority (string) : Active slot (`"priority1"` to `"priority16"`, `"default"`, `"fallback"`, or `null`).
46
+ : *other* (any) : the original message is preserved.
47
+
48
+ ### Details
49
+ Manages a priority array with 16 slots (`priority1` to `priority16`), `default`, and `fallback`.
50
+
51
+ Forwards the original message from the highest non-null slot (`priority1` highest, `fallback` lowest), adding `msg.diagnostics.activePriority`.
52
+
53
+ If no slots are set, outputs `{ payload null, diagnostics { activePriority null } }`.
54
+ Set slots via `msg.context` with `msg.payload` (number, boolean, null) or clear via `msg.payload.clear` or `msg.payload = "clear"`.
55
+
56
+ ### Status
57
+ - Green (dot): Configuration
58
+ - Blue (dot): Output, no alarm
59
+ - Red (dot): Output with alarm
60
+ - Red (ring): Errors
61
+ - Yellow (ring): Unknown context
62
+
63
+ ### References
64
+ - [Node-RED Documentation](https://nodered.org/docs/)
65
+ - [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
66
+ </script>