@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,234 @@
1
+ <script type="text/html" data-template-name="changeover-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-algorithm" title="Control algorithm type"><i class="fa fa-cog"></i> Algorithm</label>
8
+ <select id="node-input-algorithm">
9
+ <option value="single">Single Setpoint</option>
10
+ <option value="split">Split Setpoints</option>
11
+ </select>
12
+ </div>
13
+ <div class="form-row single-only">
14
+ <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>
15
+ <input type="text" id="node-input-setpoint" class="node-input-typed" placeholder="70">
16
+ <input type="hidden" id="node-input-setpointType">
17
+ </div>
18
+ <div class="form-row single-only">
19
+ <label for="node-input-deadband" title="Temperature range for no action (positive number)"><i class="fa fa-arrows-v"></i> Deadband</label>
20
+ <input type="text" id="node-input-deadband" placeholder="2">
21
+ </div>
22
+ <div class="form-row split-only" style="display: none;">
23
+ <label for="node-input-heatingSetpoint" title="Heating setpoint for split algorithm (number from num, msg, flow, or global)"><i class="fa fa-thermometer-empty"></i> Heating Setpoint</label>
24
+ <input type="text" id="node-input-heatingSetpoint" class="node-input-typed" placeholder="68">
25
+ <input type="hidden" id="node-input-heatingSetpointType">
26
+ </div>
27
+ <div class="form-row split-only" style="display: none;">
28
+ <label for="node-input-coolingSetpoint" title="Cooling setpoint for split algorithm (number from num, msg, flow, or global)"><i class="fa fa-thermometer-full"></i> Cooling Setpoint</label>
29
+ <input type="text" id="node-input-coolingSetpoint" class="node-input-typed" placeholder="74">
30
+ <input type="hidden" id="node-input-coolingSetpointType">
31
+ </div>
32
+ <div class="form-row split-only" style="display: none;">
33
+ <div id="split-setpoint-warning" style="color: red; display: none; margin-top: 5px;">
34
+ Cooling setpoint must be greater than heating setpoint
35
+ </div>
36
+ </div>
37
+ <div class="form-row">
38
+ <label for="node-input-extent" title="Extra buffer mode switching thresholds (non-negative number)"><i class="fa fa-tachometer"></i> Extent</label>
39
+ <input type="text" id="node-input-extent" placeholder="1">
40
+ </div>
41
+ <div class="form-row">
42
+ <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>
43
+ <input type="text" id="node-input-swapTime" class="node-input-typed" placeholder="300">
44
+ <input type="hidden" id="node-input-swapTimeType">
45
+ </div>
46
+ <div class="form-row">
47
+ <label for="node-input-minTempSetpoint" title="Minimum allowable setpoint"><i class="fa fa-thermometer-empty"></i> Min Setpoint</label>
48
+ <input type="text" id="node-input-minTempSetpoint" placeholder="55">
49
+ <input type="hidden" id="node-input-minTempSetpointType">
50
+ </div>
51
+ <div class="form-row">
52
+ <label for="node-input-maxTempSetpoint" title="Maximum allowable setpoint"><i class="fa fa-thermometer-full"></i> Max Setpoint</label>
53
+ <input type="text" id="node-input-maxTempSetpoint" placeholder="90">
54
+ <input type="hidden" id="node-input-maxTempSetpointType">
55
+ </div>
56
+ <div class="form-row">
57
+ <label for="node-input-initWindow" title="Initialization window to collect inputs before mode selection (seconds, non-negative number)"><i class="fa fa-hourglass-start"></i> Init Window</label>
58
+ <input type="text" id="node-input-initWindow" placeholder="10">
59
+ </div>
60
+ <div class="form-row">
61
+ <label for="node-input-operationMode" title="Operation mode"><i class="fa fa-cogs"></i> Operation Mode</label>
62
+ <select id="node-input-operationMode">
63
+ <option value="auto">Auto</option>
64
+ <option value="heat">Heat</option>
65
+ <option value="cool">Cool</option>
66
+ </select>
67
+ </div>
68
+ </script>
69
+
70
+ <script type="text/javascript">
71
+ RED.nodes.registerType("changeover-block", {
72
+ category: "control",
73
+ color: "#301934",
74
+ defaults: {
75
+ name: { value: "" },
76
+ algorithm: { value: "single" },
77
+ setpoint: { value: "70" },
78
+ setpointType: { value: "num" },
79
+ deadband: { value: "2" },
80
+ heatingSetpoint: { value: "68" },
81
+ heatingSetpointType: { value: "num" },
82
+ coolingSetpoint: { value: "74" },
83
+ coolingSetpointType: { value: "num" },
84
+ extent: { value: "1" },
85
+ swapTime: { value: "300" },
86
+ swapTimeType: { value: "num" },
87
+ minTempSetpoint: { value: "55" },
88
+ minTempSetpointType: { value: "num"},
89
+ maxTempSetpoint: { value: "90" },
90
+ maxTempSetpointType: { value: "num"},
91
+ initWindow: { value: "10" },
92
+ operationMode: { value: "auto" }
93
+ },
94
+ inputs: 1,
95
+ outputs: 2,
96
+ inputLabels: ["temperature"],
97
+ outputLabels: ["isHeating", "status"],
98
+ icon: "font-awesome/fa-retweet",
99
+ paletteLabel: "changeover",
100
+ label: function() {
101
+ return this.name || "changeover";
102
+ },
103
+ oneditprepare: function() {
104
+ const node = this;
105
+ const $algorithm = $("#node-input-algorithm");
106
+ const $singleFields = $(".single-only");
107
+ const $splitFields = $(".split-only");
108
+
109
+ try {
110
+ // Initialize typed inputs
111
+ $("#node-input-setpoint").typedInput({
112
+ default: "num",
113
+ types: ["num", "msg", "flow", "global"],
114
+ typeField: "#node-input-setpointType"
115
+ }).typedInput("type", node.setpointType || "num").typedInput("value", node.setpoint);
116
+
117
+ $("#node-input-heatingSetpoint").typedInput({
118
+ default: "num",
119
+ types: ["num", "msg", "flow", "global"],
120
+ typeField: "#node-input-heatingSetpointType"
121
+ }).typedInput("type", node.heatingSetpointType || "num").typedInput("value", node.heatingSetpoint);
122
+
123
+ $("#node-input-coolingSetpoint").typedInput({
124
+ default: "num",
125
+ types: ["num", "msg", "flow", "global"],
126
+ typeField: "#node-input-coolingSetpointType"
127
+ }).typedInput("type", node.coolingSetpointType || "num").typedInput("value", node.coolingSetpoint);
128
+
129
+ $("#node-input-swapTime").typedInput({
130
+ default: "num",
131
+ types: ["num", "msg", "flow", "global"],
132
+ typeField: "#node-input-swapTimeType"
133
+ }).typedInput("type", node.swapTimeType || "num").typedInput("value", node.swapTime);
134
+
135
+ $("#node-input-minTempSetpoint").typedInput({
136
+ default: "num",
137
+ types: ["num", "msg", "flow", "global"],
138
+ typeField: "#node-input-minTempSetpointType"
139
+ }).typedInput("type", node.minTempSetpointType || "num").typedInput("value", node.minTempSetpoint);
140
+
141
+ $("#node-input-maxTempSetpoint").typedInput({
142
+ default: "num",
143
+ types: ["num", "msg", "flow", "global"],
144
+ typeField: "#node-input-maxTempSetpointType"
145
+ }).typedInput("type", node.maxTempSetpointType || "num").typedInput("value", node.maxTempSetpoint);
146
+
147
+ // Toggle fields based on algorithm
148
+ function toggleFields() {
149
+ if ($algorithm.val() === "single") {
150
+ $singleFields.show();
151
+ $splitFields.hide();
152
+ $("#split-setpoint-warning").hide();
153
+ } else {
154
+ $singleFields.hide();
155
+ $splitFields.show();
156
+ }
157
+ }
158
+
159
+ $algorithm.on("change", toggleFields);
160
+ toggleFields();
161
+
162
+ // Validate non-typed inputs
163
+ $("#node-input-deadband").on("input", function() {
164
+ const val = parseFloat($(this).val());
165
+ $(this).toggleClass("input-error", isNaN(val) || val <= 0);
166
+ });
167
+
168
+ $("#node-input-extent").on("input", function() {
169
+ const val = parseFloat($(this).val());
170
+ $(this).toggleClass("input-error", isNaN(val) || val < 0);
171
+ });
172
+
173
+ $("#node-input-initWindow").on("input", function() {
174
+ const val = parseFloat($(this).val());
175
+ $(this).toggleClass("input-error", isNaN(val) || val < 0);
176
+ });
177
+
178
+ } catch (err) {
179
+ console.error("Error in changeover-block oneditprepare:", err);
180
+ }
181
+ }
182
+ });
183
+ </script>
184
+
185
+ <script type="text/markdown" data-help-name="changeover-block">
186
+ Manages HVAC mode switching between heating and cooling based on temperature inputs and setpoint configurations.
187
+
188
+ ### Inputs
189
+ : context (string) : Configuration commands - (`"operationMode"`, `"algorithm"`, `"setpoint"`, `"deadband"`, `"heatingSetpoint"`, `"coolingSetpoint"`, `"extent"`, `"swapTime"`, `"minTempSetpoint"`, `"maxTempSetpoint"`, `"initWindow"`). Unknown values trigger a warning.
190
+ : payload (number | string) : Temperature for mode evaluation; configuration value with `msg.context`.
191
+
192
+ ### Outputs
193
+ : isHeating (boolean) : `true` for heating, `false` for cooling, with `msg.context = "isHeating"`.
194
+ : status (object) : `{ mode, isHeating, heatingSetpoint, coolingSetpoint, temperature }`.
195
+
196
+ ### Algorithms
197
+ The node supports two algorithms for determining HVAC mode
198
+
199
+ - **Single Setpoint**
200
+ - Uses a single `setpoint`, `deadband`, and `extent`.
201
+ - **Heating** is triggered if the input temperature is below `setpoint - deadband/2 - extent`.
202
+ - **Cooling** is triggered if the temperature exceeds `setpoint + deadband/2 + extent`.
203
+ - Example With `setpoint=70`, `deadband=2`, `extent=1`, heating starts below `70 - 2/2 - 1 = 68`, and cooling starts above `70 + 2/2 + 1 = 72`.
204
+ - The `extent` widens thresholds to prevent nuisance mode changes due to overshoot.
205
+
206
+ - **Split Setpoint**
207
+ - Uses separate `heatingSetpoint`, `coolingSetpoint`, and `extent`.
208
+ - **Heating** is triggered if the temperature is below `heatingSetpoint - extent`.
209
+ - **Cooling** is triggered if the temperature exceeds `coolingSetpoint + extent`.
210
+ - Example With `heatingSetpoint=68`, `coolingSetpoint=74`, `extent=1`, heating starts below `68 - 1 = 67`, and cooling starts above `74 + 1 = 75`.
211
+ - Ensures `coolingSetpoint >= heatingSetpoint` to avoid overlap.
212
+
213
+ ### Details
214
+ Controls HVAC mode (heating or cooling) based on `msg.payload` temperature compared to setpoints.
215
+ Utilizes a delay on startup for sensor normalization and caches the last temperature to make an immediate decision after the `initWindow` period.
216
+
217
+ - In **auto** mode, the node switches modes based on the algorithm thresholds, requiring the condition to persist for `swapTime` seconds (minimum 60s). A countdown is shown (e.g., `pending cooling in 120s`).
218
+ - In **heat** or **cool** mode, the node locks to heating or cooling, respectively, ignoring temperature inputs.
219
+ - The `extent` widens switching thresholds to prevent unnecessary mode changes while allowing sharing of `deadband` with global variables and other nodes.
220
+ - Configuration options can be set via the editor or `msg.context`
221
+ - Effective setpoints (`effectiveHeatingThreshold`, `effectiveCoolingThreshold`) are always displayed in "Effective Setpoints" for both new and active nodes,
222
+ showing the calculated mode-switching thresholds.
223
+
224
+ ### Status
225
+ - Green (dot): Configuration update
226
+ - Blue (dot): State changed
227
+ - Blue (ring): State unchanged
228
+ - Red (ring): Error
229
+ - Yellow (ring): Warning
230
+
231
+ ### References
232
+ - [Node-RED Documentation](https://nodered.org/docs/)
233
+ - [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
234
+ </script>
@@ -0,0 +1,392 @@
1
+ module.exports = function(RED) {
2
+ function ChangeoverBlockNode(config) {
3
+ RED.nodes.createNode(this, config);
4
+ const node = this;
5
+
6
+ // Initialize runtime state
7
+ node.runtime = {
8
+ name: config.name,
9
+ algorithm: config.algorithm,
10
+ deadband: parseFloat(config.deadband),
11
+ extent: parseFloat(config.extent),
12
+ minTempSetpoint: parseFloat(config.minTempSetpoint),
13
+ maxTempSetpoint: parseFloat(config.maxTempSetpoint),
14
+ operationMode: config.operationMode,
15
+ initWindow: parseFloat(config.initWindow),
16
+ currentMode: (config.operationMode === "cool" ? "cooling" : "heating"),
17
+ lastTemperature: null,
18
+ lastModeChange: 0
19
+ };
20
+
21
+ // Initialize state
22
+ let initComplete = false;
23
+ let conditionStartTime = null;
24
+ let pendingMode = null;
25
+ const initStartTime = Date.now() / 1000;
26
+
27
+ node.on("input", function(msg, send, done) {
28
+ send = send || function() { node.send.apply(node, arguments); };
29
+
30
+ if (!msg) {
31
+ node.status({ fill: "red", shape: "ring", text: "invalid message" });
32
+ if (done) done();
33
+ return;
34
+ }
35
+
36
+ // Resolve typed inputs
37
+ let minTemp = node.runtime.minTempSetpoint;
38
+ let maxTemp = node.runtime.maxTempSetpoint;
39
+
40
+ if (node.runtime.algorithm === "single") {
41
+ node.runtime.setpoint = RED.util.evaluateNodeProperty(
42
+ config.setpoint, config.setpointType, node, msg
43
+ );
44
+ node.runtime.setpoint = parseFloat(node.runtime.setpoint);
45
+ } else {
46
+ node.runtime.heatingSetpoint = RED.util.evaluateNodeProperty(
47
+ config.heatingSetpoint, config.heatingSetpointType, node, msg
48
+ );
49
+ node.runtime.heatingSetpoint = parseFloat(node.runtime.heatingSetpoint);
50
+
51
+ node.runtime.coolingSetpoint = RED.util.evaluateNodeProperty(
52
+ config.coolingSetpoint, config.coolingSetpointType, node, msg
53
+ );
54
+ node.runtime.coolingSetpoint = parseFloat(node.runtime.coolingSetpoint);
55
+
56
+ // Validate
57
+ if (node.runtime.coolingSetpoint < node.runtime.heatingSetpoint) {
58
+ node.runtime.coolingSetpoint = node.runtime.heatingSetpoint + 4;
59
+ node.status({ fill: "red", shape: "ring", text: "invalid setpoints, using fallback" });
60
+ }
61
+ }
62
+
63
+ node.runtime.swapTime = RED.util.evaluateNodeProperty(
64
+ config.swapTime, config.swapTimeType, node, msg
65
+ );
66
+ node.runtime.swapTime = parseFloat(node.runtime.swapTime);
67
+
68
+ if (node.runtime.swapTime < 60) {
69
+ node.runtime.swapTime = 60;
70
+ node.status({ fill: "red", shape: "ring", text: "swapTime below 60s, using 60" });
71
+ }
72
+
73
+ if (msg.hasOwnProperty("context")) {
74
+ if (!msg.hasOwnProperty("payload")) {
75
+ node.status({ fill: "red", shape: "ring", text: `missing payload for ${msg.context}` });
76
+ if (done) done();
77
+ return;
78
+ }
79
+
80
+ const value = parseFloat(msg.payload);
81
+ switch (msg.context) {
82
+ case "operationMode":
83
+ if (!["auto", "heat", "cool"].includes(msg.payload)) {
84
+ node.status({ fill: "red", shape: "ring", text: "invalid operationMode" });
85
+ if (done) done();
86
+ return;
87
+ }
88
+ node.runtime.operationMode = msg.payload;
89
+ node.status({ fill: "green", shape: "dot", text: `in: operationMode=${msg.payload}, out: ${node.runtime.currentMode}` });
90
+ break;
91
+ case "algorithm":
92
+ if (!["single", "split"].includes(msg.payload)) {
93
+ node.status({ fill: "red", shape: "ring", text: "invalid algorithm" });
94
+ if (done) done();
95
+ return;
96
+ }
97
+ node.runtime.algorithm = msg.payload;
98
+ node.status({ fill: "green", shape: "dot", text: `in: algorithm=${msg.payload}, out: ${node.runtime.currentMode}` });
99
+ break;
100
+ case "setpoint":
101
+ if (node.runtime.algorithm !== "single") {
102
+ node.status({ fill: "red", shape: "ring", text: "setpoint not used in split algorithm" });
103
+ if (done) done();
104
+ return;
105
+ }
106
+ if (isNaN(value) || value < minTemp || value > maxTemp) {
107
+ node.status({ fill: "red", shape: "ring", text: "invalid setpoint" });
108
+ if (done) done();
109
+ return;
110
+ }
111
+ node.runtime.setpoint = value.toString();
112
+ node.runtime.setpointType = "num";
113
+ node.status({ fill: "green", shape: "dot", text: `in: setpoint=${value.toFixed(1)}, out: ${node.runtime.currentMode}` });
114
+ break;
115
+ case "deadband":
116
+ if (node.runtime.algorithm !== "single") {
117
+ node.status({ fill: "red", shape: "ring", text: "deadband not used in split algorithm" });
118
+ if (done) done();
119
+ return;
120
+ }
121
+ if (isNaN(value) || value <= 0) {
122
+ node.status({ fill: "red", shape: "ring", text: "invalid deadband" });
123
+ if (done) done();
124
+ return;
125
+ }
126
+ node.runtime.deadband = value;
127
+ node.status({ fill: "green", shape: "dot", text: `in: deadband=${value.toFixed(1)}, out: ${node.runtime.currentMode}` });
128
+ break;
129
+ case "heatingSetpoint":
130
+ if (node.runtime.algorithm !== "split") {
131
+ node.status({ fill: "red", shape: "ring", text: "heatingSetpoint not used in single algorithm" });
132
+ if (done) done();
133
+ return;
134
+ }
135
+ if (isNaN(value) || value < minTemp || value > maxTemp || value > node.runtime.coolingSetpoint) {
136
+ node.status({ fill: "red", shape: "ring", text: "invalid heatingSetpoint" });
137
+ if (done) done();
138
+ return;
139
+ }
140
+ node.runtime.heatingSetpoint = value.toString();
141
+ node.runtime.heatingSetpointType = "num";
142
+ node.status({ fill: "green", shape: "dot", text: `in: heatingSetpoint=${value.toFixed(1)}, out: ${node.runtime.currentMode}` });
143
+ break;
144
+ case "coolingSetpoint":
145
+ if (node.runtime.algorithm !== "split") {
146
+ node.status({ fill: "red", shape: "ring", text: "coolingSetpoint not used in single algorithm" });
147
+ if (done) done();
148
+ return;
149
+ }
150
+ if (isNaN(value) || value < minTemp || value > maxTemp || value < node.runtime.heatingSetpoint) {
151
+ node.status({ fill: "red", shape: "ring", text: "invalid coolingSetpoint" });
152
+ if (done) done();
153
+ return;
154
+ }
155
+ node.runtime.coolingSetpoint = value.toString();
156
+ node.runtime.coolingSetpointType = "num";
157
+ node.status({ fill: "green", shape: "dot", text: `in: coolingSetpoint=${value.toFixed(1)}, out: ${node.runtime.currentMode}` });
158
+ break;
159
+ case "extent":
160
+ if (isNaN(value) || value < 0) {
161
+ node.status({ fill: "red", shape: "ring", text: "invalid extent" });
162
+ if (done) done();
163
+ return;
164
+ }
165
+ node.runtime.extent = value;
166
+ node.status({ fill: "green", shape: "dot", text: `in: extent=${value.toFixed(1)}, out: ${node.runtime.currentMode}` });
167
+ break;
168
+ case "swapTime":
169
+ if (isNaN(value) || value < 60) {
170
+ node.status({ fill: "red", shape: "ring", text: "invalid swapTime, minimum 60s" });
171
+ if (done) done();
172
+ return;
173
+ }
174
+ node.runtime.swapTime = value.toString();
175
+ node.runtime.swapTimeType = "num";
176
+ node.status({ fill: "green", shape: "dot", text: `in: swapTime=${value.toFixed(0)}, out: ${node.runtime.currentMode}` });
177
+ break;
178
+ case "minTempSetpoint":
179
+ if (isNaN(value) || value >= node.runtime.maxTempSetpoint ||
180
+ (node.runtime.algorithm === "single" && value > node.runtime.setpoint) ||
181
+ (node.runtime.algorithm === "split" && (value > node.runtime.heatingSetpoint || value > node.runtime.coolingSetpoint))) {
182
+ node.status({ fill: "red", shape: "ring", text: "invalid minTempSetpoint" });
183
+ if (done) done();
184
+ return;
185
+ }
186
+ node.runtime.minTempSetpoint = value;
187
+ node.status({ fill: "green", shape: "dot", text: `in: minTempSetpoint=${value.toFixed(1)}, out: ${node.runtime.currentMode}` });
188
+ break;
189
+ case "maxTempSetpoint":
190
+ if (isNaN(value) || value <= node.runtime.minTempSetpoint ||
191
+ (node.runtime.algorithm === "single" && value < node.runtime.setpoint) ||
192
+ (node.runtime.algorithm === "split" && (value < node.runtime.heatingSetpoint || value < node.runtime.coolingSetpoint))) {
193
+ node.status({ fill: "red", shape: "ring", text: "invalid maxTempSetpoint" });
194
+ if (done) done();
195
+ return;
196
+ }
197
+ node.runtime.maxTempSetpoint = value;
198
+ node.status({ fill: "green", shape: "dot", text: `in: maxTempSetpoint=${value.toFixed(1)}, out: ${node.runtime.currentMode}` });
199
+ break;
200
+ case "initWindow":
201
+ if (isNaN(value) || value < 0) {
202
+ node.status({ fill: "red", shape: "ring", text: "invalid initWindow" });
203
+ if (done) done();
204
+ return;
205
+ }
206
+ node.runtime.initWindow = value;
207
+ node.status({ fill: "green", shape: "dot", text: `in: initWindow=${value.toFixed(0)}, out: ${node.runtime.currentMode}` });
208
+ break;
209
+ default:
210
+ node.status({ fill: "yellow", shape: "ring", text: "unknown context" });
211
+ if (done) done();
212
+ return;
213
+ }
214
+ conditionStartTime = null;
215
+ pendingMode = null;
216
+
217
+ send(evaluateState() || buildOutputs());
218
+ if (done) done();
219
+ return;
220
+ }
221
+
222
+ if (!msg.hasOwnProperty("payload")) {
223
+ node.status({ fill: "red", shape: "ring", text: "missing temperature" });
224
+ if (done) done();
225
+ return;
226
+ }
227
+
228
+ let input = parseFloat(msg.payload);
229
+ if (isNaN(input)) {
230
+ node.status({ fill: "red", shape: "ring", text: "invalid temperature" });
231
+ if (done) done();
232
+ return;
233
+ }
234
+
235
+ if (node.runtime.lastTemperature !== input) {
236
+ node.runtime.lastTemperature = input;
237
+ }
238
+
239
+ const now = Date.now() / 1000;
240
+ if (!initComplete && now - initStartTime >= node.runtime.initWindow) {
241
+ initComplete = true;
242
+ evaluateInitialMode();
243
+ }
244
+
245
+ if (!initComplete) {
246
+ updateStatus();
247
+ if (done) done();
248
+ return;
249
+ }
250
+
251
+
252
+
253
+ send(evaluateState() || buildOutputs());
254
+ updateStatus();
255
+ if (done) done();
256
+ });
257
+
258
+ function evaluateInitialMode() {
259
+ if (node.runtime.lastTemperature === null) return;
260
+ const temp = node.runtime.lastTemperature;
261
+ let newMode = node.runtime.currentMode;
262
+
263
+ if (node.runtime.operationMode === "heat") {
264
+ newMode = "heating";
265
+ } else if (node.runtime.operationMode === "cool") {
266
+ newMode = "cooling";
267
+ } else {
268
+ let heatingThreshold, coolingThreshold;
269
+ if (node.runtime.algorithm === "single") {
270
+ heatingThreshold = node.runtime.setpoint - node.runtime.deadband / 2 - node.runtime.extent;
271
+ coolingThreshold = node.runtime.setpoint + node.runtime.deadband / 2 + node.runtime.extent;
272
+ } else {
273
+ heatingThreshold = node.runtime.heatingSetpoint - node.runtime.extent;
274
+ coolingThreshold = node.runtime.coolingSetpoint + node.runtime.extent;
275
+ }
276
+
277
+ if (temp < heatingThreshold) {
278
+ newMode = "heating";
279
+ } else if (temp > coolingThreshold) {
280
+ newMode = "cooling";
281
+ }
282
+ }
283
+
284
+ node.runtime.currentMode = newMode;
285
+ node.runtime.lastModeChange = Date.now() / 1000;
286
+ }
287
+
288
+ function evaluateState() {
289
+ const now = Date.now() / 1000;
290
+ if (!initComplete) return null;
291
+
292
+ let newMode = node.runtime.currentMode;
293
+ if (node.runtime.operationMode === "heat") {
294
+ newMode = "heating";
295
+ conditionStartTime = null;
296
+ pendingMode = null;
297
+ } else if (node.runtime.operationMode === "cool") {
298
+ newMode = "cooling";
299
+ conditionStartTime = null;
300
+ pendingMode = null;
301
+ } else if (node.runtime.lastTemperature !== null) {
302
+ let heatingThreshold, coolingThreshold;
303
+ if (node.runtime.algorithm === "single") {
304
+ heatingThreshold = node.runtime.setpoint - node.runtime.deadband / 2 - node.runtime.extent;
305
+ coolingThreshold = node.runtime.setpoint + node.runtime.deadband / 2 + node.runtime.extent;
306
+ } else {
307
+ heatingThreshold = node.runtime.heatingSetpoint - node.runtime.extent;
308
+ coolingThreshold = node.runtime.coolingSetpoint + node.runtime.extent;
309
+ }
310
+
311
+ let desiredMode = node.runtime.currentMode;
312
+ if (node.runtime.lastTemperature < heatingThreshold) {
313
+ desiredMode = "heating";
314
+ } else if (node.runtime.lastTemperature > coolingThreshold) {
315
+ desiredMode = "cooling";
316
+ }
317
+
318
+ if (desiredMode !== node.runtime.currentMode) {
319
+ if (pendingMode !== desiredMode) {
320
+ conditionStartTime = now;
321
+ pendingMode = desiredMode;
322
+ } else if (conditionStartTime && now - conditionStartTime >= node.runtime.swapTime) {
323
+ newMode = desiredMode;
324
+ conditionStartTime = null;
325
+ pendingMode = null;
326
+ }
327
+ } else {
328
+ conditionStartTime = null;
329
+ pendingMode = null;
330
+ }
331
+ }
332
+
333
+ if (newMode !== node.runtime.currentMode) {
334
+ node.runtime.currentMode = newMode;
335
+ node.runtime.lastModeChange = now;
336
+ }
337
+
338
+ return null;
339
+ }
340
+
341
+ function buildOutputs() {
342
+ const isHeating = node.runtime.currentMode === "heating";
343
+ let heatingSetpoint, coolingSetpoint;
344
+ if (node.runtime.algorithm === "single") {
345
+ heatingSetpoint = node.runtime.setpoint - node.runtime.deadband / 2;
346
+ coolingSetpoint = node.runtime.setpoint + node.runtime.deadband / 2;
347
+ } else {
348
+ heatingSetpoint = node.runtime.heatingSetpoint;
349
+ coolingSetpoint = node.runtime.coolingSetpoint;
350
+ }
351
+
352
+ return [
353
+ { payload: isHeating, context: "isHeating" },
354
+ {
355
+ payload: {
356
+ mode: node.runtime.currentMode,
357
+ isHeating,
358
+ heatingSetpoint,
359
+ coolingSetpoint,
360
+ temperature: node.runtime.lastTemperature
361
+ }
362
+ }
363
+ ];
364
+ }
365
+
366
+ function updateStatus() {
367
+ const now = Date.now() / 1000;
368
+ const inInitWindow = !initComplete && now - initStartTime < node.runtime.initWindow;
369
+
370
+ if (inInitWindow) {
371
+ node.status({ fill: "yellow", shape: "ring", text: `initializing, out: ${node.runtime.currentMode}` });
372
+ } else {
373
+ let statusText = `in: temp=${node.runtime.lastTemperature !== null ? node.runtime.lastTemperature.toFixed(1) : "unknown"}, out: ${node.runtime.currentMode}`;
374
+ if (pendingMode && conditionStartTime) {
375
+ const remaining = Math.max(0, node.runtime.swapTime - (now - conditionStartTime));
376
+ statusText += `, pending: ${pendingMode} in ${remaining.toFixed(0)}s`;
377
+ }
378
+ node.status({
379
+ fill: "blue",
380
+ shape: now - node.runtime.lastModeChange < 1 ? "dot" : "ring",
381
+ text: statusText
382
+ });
383
+ }
384
+ }
385
+
386
+ node.on("close", function(done) {
387
+ done();
388
+ });
389
+ }
390
+
391
+ RED.nodes.registerType("changeover-block", ChangeoverBlockNode);
392
+ };