@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,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>