@bldgblocks/node-red-contrib-control 0.1.37 → 0.2.0

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.
@@ -1,39 +1,90 @@
1
- //const { parse } = require('echarts/types/src/export/api/time.js');
1
+ // ============================================================================
2
+ // Changeover Block - HVAC Heating/Cooling Mode Selector
3
+ // ============================================================================
4
+ // Determines whether an HVAC system should be in heating or cooling mode
5
+ // based on temperature input and setpoint configuration.
6
+ //
7
+ // Supports three algorithms:
8
+ // - single: one setpoint ± deadband/2 defines heating/cooling thresholds
9
+ // - split: separate heating/cooling setpoints with extent buffer
10
+ // - specified: explicit heatingOn/coolingOn trigger temperatures
11
+ //
12
+ // Operation modes:
13
+ // - auto: temperature-driven switching with swap timer to prevent cycling
14
+ // - heat: locked to heating regardless of temperature
15
+ // - cool: locked to cooling regardless of temperature
16
+ //
17
+ // All configuration is via typed inputs (editor, msg, flow, global).
18
+ // ============================================================================
2
19
 
3
20
  module.exports = function(RED) {
4
21
  const utils = require('./utils')(RED);
5
22
 
23
+ const VALID_MODES = ["auto", "heat", "cool"];
24
+ const VALID_ALGORITHMS = ["single", "split", "specified"];
25
+ const MIN_SWAP_TIME = 60; // seconds
26
+
6
27
  function ChangeoverBlockNode(config) {
7
28
  RED.nodes.createNode(this, config);
8
29
  const node = this;
9
-
10
- // Initialize runtime state
11
- // Initialize state
30
+
31
+ // ====================================================================
32
+ // Configuration — static defaults parsed from editor config
33
+ // ====================================================================
12
34
  node.name = config.name;
13
35
  node.inputProperty = config.inputProperty || "payload";
14
- node.initWindow = parseFloat(config.initWindow);
36
+
37
+ // Use helper to avoid || clobbering legitimate zero values
38
+ const num = (v, fallback) => { const n = parseFloat(v); return isNaN(n) ? fallback : n; };
39
+
40
+ node.setpoint = num(config.setpoint, 70);
41
+ node.heatingSetpoint = num(config.heatingSetpoint, 68);
42
+ node.coolingSetpoint = num(config.coolingSetpoint, 74);
43
+ node.heatingOn = num(config.heatingOn, 66);
44
+ node.coolingOn = num(config.coolingOn, 74);
45
+ node.deadband = num(config.deadband, 2);
46
+ node.extent = num(config.extent, 1);
47
+ node.swapTime = num(config.swapTime, 300);
48
+ node.minTempSetpoint = num(config.minTempSetpoint, 55);
49
+ node.maxTempSetpoint = num(config.maxTempSetpoint, 90);
50
+ node.initWindow = num(config.initWindow, 10);
51
+
52
+ // Enum typed inputs: when type is dynamic (msg/flow/global), config value
53
+ // holds the property PATH, not a valid enum value — default safely.
54
+ node.algorithm = VALID_ALGORITHMS.includes(config.algorithm) ? config.algorithm : "single";
55
+ node.operationMode = VALID_MODES.includes(config.operationMode) ? config.operationMode : "auto";
56
+
57
+ // ====================================================================
58
+ // Runtime state
59
+ // ====================================================================
60
+ node.currentMode = node.operationMode === "cool" ? "cooling" : "heating";
15
61
  node.lastTemperature = null;
16
62
  node.lastModeChange = 0;
17
- node.setpoint = parseFloat(config.setpoint);
18
- node.heatingSetpoint = parseFloat(config.heatingSetpoint);
19
- node.coolingSetpoint = parseFloat(config.coolingSetpoint);
20
- node.swapTime = parseFloat(config.swapTime);
21
- node.deadband = parseFloat(config.deadband);
22
- node.extent = parseFloat(config.extent);
23
- node.minTempSetpoint = parseFloat(config.minTempSetpoint);
24
- node.maxTempSetpoint = parseFloat(config.maxTempSetpoint);
25
- node.algorithm = config.algorithm;
26
- node.operationMode = config.operationMode;
27
- node.currentMode = config.operationMode === "cool" ? "cooling" : "heating";
28
-
29
- // Initialize state
63
+ node.isBusy = false;
64
+
30
65
  let initComplete = false;
31
66
  let conditionStartTime = null;
32
67
  let pendingMode = null;
33
68
  const initStartTime = Date.now() / 1000;
34
69
 
35
- node.isBusy = false;
70
+ // ====================================================================
71
+ // Typed-input evaluation helpers
72
+ // ====================================================================
73
+ function evalNumeric(configValue, configType, fallback, msg) {
74
+ return utils.evaluateNodeProperty(configValue, configType, node, msg)
75
+ .then(val => { const n = parseFloat(val); return isNaN(n) ? fallback : n; })
76
+ .catch(() => fallback);
77
+ }
78
+
79
+ function evalEnum(configValue, configType, allowed, fallback, msg) {
80
+ return utils.evaluateNodeProperty(configValue, configType, node, msg)
81
+ .then(val => allowed.includes(val) ? val : fallback)
82
+ .catch(() => fallback);
83
+ }
36
84
 
85
+ // ====================================================================
86
+ // Main input handler
87
+ // ====================================================================
37
88
  node.on("input", async function(msg, send, done) {
38
89
  send = send || function() { node.send.apply(node, arguments); };
39
90
 
@@ -41,442 +92,289 @@ module.exports = function(RED) {
41
92
  utils.setStatusError(node, "invalid message");
42
93
  if (done) done();
43
94
  return;
44
- }
95
+ }
96
+
97
+ // ----------------------------------------------------------------
98
+ // 1. Evaluate typed inputs (async phase — acquire busy lock)
99
+ // ----------------------------------------------------------------
100
+ if (node.isBusy) {
101
+ utils.setStatusBusy(node, "busy - dropped msg");
102
+ if (done) done();
103
+ return;
104
+ }
105
+ node.isBusy = true;
45
106
 
46
- // Evaluate dynamic properties
47
107
  try {
48
- // Check busy lock
49
- if (node.isBusy) {
50
- // Update status to let user know they are pushing too fast
51
- utils.setStatusBusy(node, "busy - dropped msg");
52
- if (done) done();
53
- return;
54
- }
108
+ const results = await Promise.all([
109
+ evalNumeric(config.setpoint, config.setpointType, node.setpoint, msg), // 0
110
+ evalNumeric(config.heatingSetpoint, config.heatingSetpointType, node.heatingSetpoint, msg), // 1
111
+ evalNumeric(config.coolingSetpoint, config.coolingSetpointType, node.coolingSetpoint, msg), // 2
112
+ evalNumeric(config.heatingOn, config.heatingOnType, node.heatingOn, msg), // 3
113
+ evalNumeric(config.coolingOn, config.coolingOnType, node.coolingOn, msg), // 4
114
+ evalNumeric(config.deadband, config.deadbandType, node.deadband, msg), // 5
115
+ evalNumeric(config.extent, config.extentType, node.extent, msg), // 6
116
+ evalNumeric(config.swapTime, config.swapTimeType, node.swapTime, msg), // 7
117
+ evalNumeric(config.minTempSetpoint, config.minTempSetpointType, node.minTempSetpoint, msg), // 8
118
+ evalNumeric(config.maxTempSetpoint, config.maxTempSetpointType, node.maxTempSetpoint, msg), // 9
119
+ evalEnum(config.algorithm, config.algorithmType, VALID_ALGORITHMS, node.algorithm, msg), // 10
120
+ evalEnum(config.operationMode, config.operationModeType, VALID_MODES, node.operationMode, msg), // 11
121
+ ]);
122
+
123
+ node.setpoint = results[0];
124
+ node.heatingSetpoint = results[1];
125
+ node.coolingSetpoint = results[2];
126
+ node.heatingOn = results[3];
127
+ node.coolingOn = results[4];
128
+ node.deadband = results[5];
129
+ node.extent = results[6];
130
+ node.swapTime = results[7];
131
+ node.minTempSetpoint = results[8];
132
+ node.maxTempSetpoint = results[9];
133
+ node.algorithm = results[10];
134
+ node.operationMode = results[11];
55
135
 
56
- // Lock node during evaluation
57
- node.isBusy = true;
58
-
59
- // Begin evaluations
60
- const evaluations = [];
61
-
62
- evaluations.push(
63
- utils.requiresEvaluation(config.setpointType)
64
- ? utils.evaluateNodeProperty(config.setpoint, config.setpointType, node, msg)
65
- .then(val => parseFloat(val))
66
- : Promise.resolve(node.setpoint),
67
- );
68
-
69
- evaluations.push(
70
- utils.requiresEvaluation(config.heatingSetpointType)
71
- ? utils.evaluateNodeProperty(config.heatingSetpoint, config.heatingSetpointType, node, msg)
72
- .then(val => parseFloat(val))
73
- : Promise.resolve(node.heatingSetpoint),
74
- );
75
-
76
- evaluations.push(
77
- utils.requiresEvaluation(config.coolingSetpointType)
78
- ? utils.evaluateNodeProperty(config.coolingSetpoint, config.coolingSetpointType, node, msg)
79
- .then(val => parseFloat(val))
80
- : Promise.resolve(node.coolingSetpoint),
81
- );
82
-
83
- evaluations.push(
84
- utils.requiresEvaluation(config.swapTimeType)
85
- ? utils.evaluateNodeProperty(config.swapTime, config.swapTimeType, node, msg)
86
- .then(val => parseFloat(val))
87
- : Promise.resolve(node.swapTime),
88
- );
89
-
90
- evaluations.push(
91
- utils.requiresEvaluation(config.deadbandType)
92
- ? utils.evaluateNodeProperty(config.deadband, config.deadbandType, node, msg)
93
- .then(val => parseFloat(val))
94
- : Promise.resolve(node.deadband),
95
- );
96
-
97
- evaluations.push(
98
- utils.requiresEvaluation(config.extentType)
99
- ? utils.evaluateNodeProperty(config.extent, config.extentType, node, msg)
100
- .then(val => parseFloat(val))
101
- : Promise.resolve(node.extent),
102
- );
103
-
104
- evaluations.push(
105
- utils.requiresEvaluation(config.minTempSetpointType)
106
- ? utils.evaluateNodeProperty(config.minTempSetpoint, config.minTempSetpointType, node, msg)
107
- .then(val => parseFloat(val))
108
- : Promise.resolve(node.minTempSetpoint),
109
- );
110
-
111
- evaluations.push(
112
- utils.requiresEvaluation(config.maxTempSetpointType)
113
- ? utils.evaluateNodeProperty(config.maxTempSetpoint, config.maxTempSetpointType, node, msg)
114
- .then(val => parseFloat(val))
115
- : Promise.resolve(node.maxTempSetpoint),
116
- );
117
-
118
- evaluations.push(
119
- utils.requiresEvaluation(config.algorithmType)
120
- ? utils.evaluateNodeProperty(config.algorithm, config.algorithmType, node, msg)
121
- : Promise.resolve(node.algorithm),
122
- );
123
-
124
- evaluations.push(
125
- utils.requiresEvaluation(config.operationModeType)
126
- ? utils.evaluateNodeProperty(config.operationMode, config.operationModeType, node, msg)
127
- : Promise.resolve(node.operationMode),
128
- );
129
-
130
- const results = await Promise.all(evaluations);
131
-
132
- // Update runtime with evaluated values
133
-
134
- if (!isNaN(results[0])) node.setpoint = results[0];
135
- if (!isNaN(results[1])) node.heatingSetpoint = results[1];
136
- if (!isNaN(results[2])) node.coolingSetpoint = results[2];
137
- if (!isNaN(results[3])) node.swapTime = results[3];
138
- if (!isNaN(results[4])) node.deadband = results[4];
139
- if (!isNaN(results[5])) node.extent = results[5];
140
- if (!isNaN(results[6])) node.minTempSetpoint = results[6];
141
- if (!isNaN(results[7])) node.maxTempSetpoint = results[7];
142
- if (results[8]) node.algorithm = results[8];
143
- if (results[9]) node.operationMode = results[9];
144
- node.currentMode = node.operationMode === "cool" ? "cooling" : "heating";
145
-
146
136
  } catch (err) {
147
137
  node.error(`Error evaluating properties: ${err.message}`);
148
138
  if (done) done();
149
139
  return;
150
140
  } finally {
151
- // Release, all synchronous from here on
152
141
  node.isBusy = false;
153
142
  }
154
143
 
155
- // Validate
156
- if (node.coolingSetpoint < node.heatingSetpoint
157
- || node.maxTempSetpoint < node.minTempSetpoint
158
- || node.deadband <= 0 || node.extent < 0) {
159
- utils.setStatusError(node, "error validating properties, check setpoints");
160
- if (done) done(err);
144
+ // ----------------------------------------------------------------
145
+ // 2. Enforce constraints
146
+ // ----------------------------------------------------------------
147
+ if (node.swapTime < MIN_SWAP_TIME) {
148
+ node.swapTime = MIN_SWAP_TIME;
149
+ }
150
+ if (node.deadband <= 0) {
151
+ utils.setStatusError(node, "deadband must be > 0");
152
+ if (done) done();
161
153
  return;
162
154
  }
163
-
164
- if (node.swapTime < 60) {
165
- node.swapTime = 60;
166
- utils.setStatusError(node, "swapTime below 60s, using 60");
155
+ if (node.extent < 0) {
156
+ utils.setStatusError(node, "extent must be >= 0");
157
+ if (done) done();
158
+ return;
167
159
  }
168
-
169
- if (node.coolingSetpoint < node.heatingSetpoint) {
170
- node.coolingSetpoint = node.heatingSetpoint + 4;
171
- utils.setStatusError(node, "invalid setpoints, using fallback");
160
+ if (node.maxTempSetpoint <= node.minTempSetpoint) {
161
+ utils.setStatusError(node, "maxTempSetpoint must be > minTempSetpoint");
162
+ if (done) done();
163
+ return;
172
164
  }
173
-
174
- if (msg.hasOwnProperty("context")) {
175
- if (!msg.hasOwnProperty("payload")) {
176
- utils.setStatusError(node, `missing payload for ${msg.context}`);
177
- if (done) done();
178
- return;
179
- }
180
-
181
- const value = parseFloat(msg.payload);
182
- switch (msg.context) {
183
- case "operationMode":
184
- if (!["auto", "heat", "cool"].includes(msg.payload)) {
185
- utils.setStatusError(node, "invalid operationMode");
186
- if (done) done();
187
- return;
188
- }
189
- node.operationMode = msg.payload;
190
- utils.setStatusOK(node, `in: operationMode=${msg.payload}, out: ${node.currentMode}`);
191
- break;
192
- case "algorithm":
193
- if (!["single", "split"].includes(msg.payload)) {
194
- utils.setStatusError(node, "invalid algorithm");
195
- if (done) done();
196
- return;
197
- }
198
- node.algorithm = msg.payload;
199
- utils.setStatusOK(node, `in: algorithm=${msg.payload}, out: ${node.currentMode}`);
200
- break;
201
- case "setpoint":
202
- if (isNaN(value) || value < node.minTempSetpoint || value > node.maxTempSetpoint) {
203
- utils.setStatusError(node, "invalid setpoint");
204
- if (done) done();
205
- return;
206
- }
207
- node.setpoint = value.toString();
208
- node.setpointType = "num";
209
- utils.setStatusOK(node, `in: setpoint=${value.toFixed(1)}, out: ${node.currentMode}`);
210
- break;
211
- case "deadband":
212
- if (isNaN(value) || value <= 0) {
213
- utils.setStatusError(node, "invalid deadband");
214
- if (done) done();
215
- return;
216
- }
217
- node.deadband = value;
218
- utils.setStatusOK(node, `in: deadband=${value.toFixed(1)}, out: ${node.currentMode}`);
219
- break;
220
- case "heatingSetpoint":
221
- if (isNaN(value) || value < node.minTempSetpoint || value > node.maxTempSetpoint || value > node.coolingSetpoint) {
222
- utils.setStatusError(node, "invalid heatingSetpoint");
223
- if (done) done();
224
- return;
225
- }
226
- node.heatingSetpoint = value.toString();
227
- node.heatingSetpointType = "num";
228
- utils.setStatusOK(node, `in: heatingSetpoint=${value.toFixed(1)}, out: ${node.currentMode}`);
229
- break;
230
- case "coolingSetpoint":
231
- if (isNaN(value) || value < node.minTempSetpoint || value > node.maxTempSetpoint || value < node.heatingSetpoint) {
232
- utils.setStatusError(node, "invalid coolingSetpoint");
233
- if (done) done();
234
- return;
235
- }
236
- node.coolingSetpoint = value.toString();
237
- node.coolingSetpointType = "num";
238
- utils.setStatusOK(node, `in: coolingSetpoint=${value.toFixed(1)}, out: ${node.currentMode}`);
239
- break;
240
- case "extent":
241
- if (isNaN(value) || value < 0) {
242
- utils.setStatusError(node, "invalid extent");
243
- if (done) done();
244
- return;
245
- }
246
- node.extent = value;
247
- utils.setStatusOK(node, `in: extent=${value.toFixed(1)}, out: ${node.currentMode}`);
248
- break;
249
- case "swapTime":
250
- if (isNaN(value) || value < 60) {
251
- utils.setStatusError(node, "invalid swapTime, minimum 60s");
252
- if (done) done();
253
- return;
254
- }
255
- node.swapTime = value.toString();
256
- node.swapTimeType = "num";
257
- utils.setStatusOK(node, `in: swapTime=${value.toFixed(0)}, out: ${node.currentMode}`);
258
- break;
259
- case "minTempSetpoint":
260
- if (isNaN(value) || value >= node.maxTempSetpoint ||
261
- (node.algorithm === "single" && value > node.setpoint) ||
262
- (node.algorithm === "split" && (value > node.heatingSetpoint || value > node.coolingSetpoint))) {
263
- utils.setStatusError(node, "invalid minTempSetpoint");
264
- if (done) done();
265
- return;
266
- }
267
- node.minTempSetpoint = value;
268
- utils.setStatusOK(node, `in: minTempSetpoint=${value.toFixed(1)}, out: ${node.currentMode}`);
269
- break;
270
- case "maxTempSetpoint":
271
- if (isNaN(value) || value <= node.minTempSetpoint ||
272
- (node.algorithm === "single" && value < node.setpoint) ||
273
- (node.algorithm === "split" && (value < node.heatingSetpoint || value < node.coolingSetpoint))) {
274
- utils.setStatusError(node, "invalid maxTempSetpoint");
275
- if (done) done();
276
- return;
277
- }
278
- node.maxTempSetpoint = value;
279
- utils.setStatusOK(node, `in: maxTempSetpoint=${value.toFixed(1)}, out: ${node.currentMode}`);
280
- break;
281
- case "initWindow":
282
- if (isNaN(value) || value < 0) {
283
- utils.setStatusError(node, "invalid initWindow");
284
- if (done) done();
285
- return;
286
- }
287
- node.initWindow = value;
288
- utils.setStatusOK(node, `in: initWindow=${value.toFixed(0)}, out: ${node.currentMode}`);
289
- break;
290
- default:
291
- utils.setStatusWarn(node, "unknown context");
292
- if (done) done();
293
- return;
294
- }
295
- conditionStartTime = null;
296
- pendingMode = null;
297
-
298
- send(evaluateState() || buildOutputs());
165
+ if (node.algorithm === "split" && node.coolingSetpoint <= node.heatingSetpoint) {
166
+ utils.setStatusError(node, "coolingSetpoint must be > heatingSetpoint");
299
167
  if (done) done();
300
168
  return;
301
169
  }
302
-
303
- if (!msg.hasOwnProperty("payload")) {
304
- utils.setStatusError(node, "missing temperature payload property");
170
+ if (node.algorithm === "specified" && node.coolingOn <= node.heatingOn) {
171
+ utils.setStatusError(node, "coolingOn must be > heatingOn");
305
172
  if (done) done();
306
173
  return;
307
174
  }
308
175
 
176
+ // ----------------------------------------------------------------
177
+ // 3. Lock currentMode for explicit heat/cool operation modes
178
+ // ----------------------------------------------------------------
179
+ if (node.operationMode === "heat") {
180
+ node.currentMode = "heating";
181
+ conditionStartTime = null;
182
+ pendingMode = null;
183
+ } else if (node.operationMode === "cool") {
184
+ node.currentMode = "cooling";
185
+ conditionStartTime = null;
186
+ pendingMode = null;
187
+ }
188
+
189
+ // ----------------------------------------------------------------
190
+ // 4. Read temperature from msg
191
+ // ----------------------------------------------------------------
309
192
  let input;
310
193
  try {
311
194
  input = parseFloat(RED.util.getMessageProperty(msg, node.inputProperty));
312
- } catch (err) {
195
+ } catch (e) {
313
196
  input = NaN;
314
197
  }
315
198
  if (isNaN(input)) {
316
- utils.setStatusError(node, "missing or invalid input property");
199
+ utils.setStatusError(node, "missing or invalid temperature");
317
200
  if (done) done();
318
201
  return;
319
202
  }
320
-
321
- if (node.lastTemperature !== input) {
322
- node.lastTemperature = input;
323
- }
203
+ node.lastTemperature = input;
324
204
 
205
+ // ----------------------------------------------------------------
206
+ // 5. Init window — wait for sensors to stabilize
207
+ // ----------------------------------------------------------------
325
208
  const now = Date.now() / 1000;
326
- if (!initComplete && now - initStartTime >= node.initWindow) {
327
- initComplete = true;
328
- evaluateInitialMode();
329
- }
330
-
331
209
  if (!initComplete) {
332
- updateStatus();
333
- if (done) done();
334
- return;
210
+ if (now - initStartTime >= node.initWindow) {
211
+ initComplete = true;
212
+ evaluateInitialMode();
213
+ } else {
214
+ updateStatus();
215
+ if (done) done();
216
+ return;
217
+ }
335
218
  }
336
219
 
337
- send(evaluateState() || buildOutputs());
220
+ // ----------------------------------------------------------------
221
+ // 6. Evaluate mode (auto switching with swap timer)
222
+ // ----------------------------------------------------------------
223
+ evaluateState();
224
+
225
+ // ----------------------------------------------------------------
226
+ // 7. Build and send output
227
+ // ----------------------------------------------------------------
228
+ send(buildOutputs(msg));
338
229
  updateStatus();
339
230
  if (done) done();
340
231
  });
341
232
 
233
+ // ====================================================================
234
+ // Calculate thresholds for the current algorithm
235
+ // ====================================================================
236
+ function getThresholds() {
237
+ switch (node.algorithm) {
238
+ case "single":
239
+ return {
240
+ heating: node.setpoint - node.deadband / 2,
241
+ cooling: node.setpoint + node.deadband / 2
242
+ };
243
+ case "split":
244
+ return {
245
+ heating: node.heatingSetpoint - node.extent,
246
+ cooling: node.coolingSetpoint + node.extent
247
+ };
248
+ case "specified":
249
+ return {
250
+ heating: node.heatingOn,
251
+ cooling: node.coolingOn
252
+ };
253
+ default:
254
+ return {
255
+ heating: node.setpoint - node.deadband / 2,
256
+ cooling: node.setpoint + node.deadband / 2
257
+ };
258
+ }
259
+ }
260
+
261
+ // ====================================================================
262
+ // Initial mode — set immediately without swap timer
263
+ // ====================================================================
342
264
  function evaluateInitialMode() {
343
265
  if (node.lastTemperature === null) return;
344
- const temp = node.lastTemperature;
345
- let newMode = node.currentMode;
346
-
347
- if (node.operationMode === "heat") {
348
- newMode = "heating";
349
- } else if (node.operationMode === "cool") {
350
- newMode = "cooling";
351
- } else {
352
- let heatingThreshold, coolingThreshold;
353
- if (node.algorithm === "single") {
354
- heatingThreshold = node.setpoint - node.deadband / 2;
355
- coolingThreshold = node.setpoint + node.deadband / 2;
356
- } else if (node.algorithm === "split") {
357
- heatingThreshold = node.heatingSetpoint - node.extent;
358
- coolingThreshold = node.coolingSetpoint + node.extent;
359
- } else if (node.algorithm === "specified") {
360
- heatingThreshold = node.heatingOn - node.extent;
361
- coolingThreshold = node.coolingOn + node.extent;
362
- }
266
+ if (node.operationMode !== "auto") return; // already locked
363
267
 
364
- if (temp < heatingThreshold) {
365
- newMode = "heating";
366
- } else if (temp > coolingThreshold) {
367
- newMode = "cooling";
368
- }
268
+ const { heating, cooling } = getThresholds();
269
+ if (node.lastTemperature < heating) {
270
+ node.currentMode = "heating";
271
+ } else if (node.lastTemperature > cooling) {
272
+ node.currentMode = "cooling";
369
273
  }
370
-
371
- node.currentMode = newMode;
372
274
  node.lastModeChange = Date.now() / 1000;
373
275
  }
374
276
 
277
+ // ====================================================================
278
+ // Auto-mode state evaluation with swap timer
279
+ // ====================================================================
375
280
  function evaluateState() {
376
- const now = Date.now() / 1000;
377
- if (!initComplete) return null;
378
-
379
- let newMode = node.currentMode;
380
- if (node.operationMode === "heat") {
381
- newMode = "heating";
382
- conditionStartTime = null;
383
- pendingMode = null;
384
- } else if (node.operationMode === "cool") {
385
- newMode = "cooling";
386
- conditionStartTime = null;
387
- pendingMode = null;
388
- } else if (node.lastTemperature !== null) {
389
- let heatingThreshold, coolingThreshold;
390
- if (node.algorithm === "single") {
391
- heatingThreshold = node.setpoint - node.deadband / 2;
392
- coolingThreshold = node.setpoint + node.deadband / 2;
393
- } else if (node.algorithm === "split") {
394
- heatingThreshold = node.heatingSetpoint - node.extent;
395
- coolingThreshold = node.coolingSetpoint + node.extent;
396
- } else if (node.algorithm === "specified") {
397
- heatingThreshold = node.heatingOn - node.extent;
398
- coolingThreshold = node.coolingOn + node.extent;
399
- }
281
+ if (!initComplete) return;
282
+ if (node.operationMode !== "auto") return; // locked modes handled in step 3
283
+ if (node.lastTemperature === null) return;
400
284
 
401
- let desiredMode = node.currentMode;
402
- if (node.lastTemperature < heatingThreshold) {
403
- desiredMode = "heating";
404
- } else if (node.lastTemperature > coolingThreshold) {
405
- desiredMode = "cooling";
406
- }
285
+ const now = Date.now() / 1000;
286
+ const { heating, cooling } = getThresholds();
287
+
288
+ // Determine what mode temperature demands
289
+ let desiredMode = node.currentMode;
290
+ if (node.lastTemperature < heating) {
291
+ desiredMode = "heating";
292
+ } else if (node.lastTemperature > cooling) {
293
+ desiredMode = "cooling";
294
+ }
407
295
 
408
- if (desiredMode !== node.currentMode) {
409
- if (pendingMode !== desiredMode) {
410
- conditionStartTime = now;
411
- pendingMode = desiredMode;
412
- } else if (conditionStartTime && now - conditionStartTime >= node.swapTime) {
413
- newMode = desiredMode;
414
- conditionStartTime = null;
415
- pendingMode = null;
416
- }
417
- } else {
296
+ if (desiredMode !== node.currentMode) {
297
+ // Temperature demands a mode change — apply swap timer
298
+ if (pendingMode !== desiredMode) {
299
+ // New pending direction — start the countdown
300
+ conditionStartTime = now;
301
+ pendingMode = desiredMode;
302
+ } else if (conditionStartTime && now - conditionStartTime >= node.swapTime) {
303
+ // Countdown expired — execute the swap
304
+ node.currentMode = desiredMode;
305
+ node.lastModeChange = now;
418
306
  conditionStartTime = null;
419
307
  pendingMode = null;
420
308
  }
309
+ // else: still counting down — do nothing
310
+ } else {
311
+ // Temperature no longer demands a change — cancel any pending swap
312
+ conditionStartTime = null;
313
+ pendingMode = null;
421
314
  }
422
-
423
- if (newMode !== node.currentMode) {
424
- node.currentMode = newMode;
425
- node.lastModeChange = now;
426
- }
427
-
428
- return null;
429
315
  }
430
316
 
431
- function buildOutputs() {
317
+ // ====================================================================
318
+ // Build output message
319
+ // ====================================================================
320
+ function buildOutputs(msg) {
432
321
  const isHeating = node.currentMode === "heating";
433
- let effectiveHeatingSetpoint, effectiveCoolingSetpoint;
434
- if (node.algorithm === "single") {
435
- effectiveHeatingSetpoint = node.setpoint - node.deadband / 2;
436
- effectiveCoolingSetpoint = node.setpoint + node.deadband / 2;
437
- } else if (node.algorithm === "split") {
438
- effectiveHeatingSetpoint = node.heatingSetpoint;
439
- effectiveCoolingSetpoint = node.coolingSetpoint;
440
- } else if (node.algorithm === "specified") {
441
- effectiveHeatingSetpoint = node.heatingOn;
442
- effectiveCoolingSetpoint = node.coolingOn;
443
- }
444
-
445
- return [
446
- {
447
- payload: isHeating,
448
- context: "isHeating",
449
- status: {
450
- mode: node.currentMode,
451
- isHeating,
452
- heatingSetpoint: effectiveHeatingSetpoint,
453
- coolingSetpoint: effectiveCoolingSetpoint,
454
- temperature: node.lastTemperature
455
- }
456
- },
457
- ];
322
+ const { heating: effectiveHeating, cooling: effectiveCooling } = getThresholds();
323
+
324
+ // Preserve all original message properties (e.g., singleSetpoint, splitHeatingSetpoint)
325
+ // and add/overwrite changeover-specific fields
326
+ msg.payload = node.lastTemperature;
327
+ msg.isHeating = isHeating;
328
+ msg.status = {
329
+ mode: node.currentMode,
330
+ operationMode: node.operationMode,
331
+ isHeating,
332
+ heatingSetpoint: effectiveHeating,
333
+ coolingSetpoint: effectiveCooling,
334
+ temperature: node.lastTemperature
335
+ };
336
+
337
+ return [msg];
458
338
  }
459
339
 
340
+ // ====================================================================
341
+ // Node status display
342
+ // ====================================================================
460
343
  function updateStatus() {
461
344
  const now = Date.now() / 1000;
462
- const inInitWindow = !initComplete && now - initStartTime < node.initWindow;
345
+ const isHeating = node.currentMode === "heating";
346
+
347
+ if (!initComplete) {
348
+ const remaining = Math.max(0, node.initWindow - (now - initStartTime));
349
+ utils.setStatusBusy(node, `init ${remaining.toFixed(0)}s [${node.operationMode}] ${node.currentMode}`);
350
+ return;
351
+ }
463
352
 
464
- if (inInitWindow) {
465
- utils.setStatusBusy(node, `initializing, out: ${node.currentMode}`);
353
+ const temp = node.lastTemperature !== null ? node.lastTemperature.toFixed(1) : "?";
354
+ const { heating, cooling } = getThresholds();
355
+ // Show the threshold that explains the current mode:
356
+ // heating → show cooling threshold (we're heating because temp < cooling threshold)
357
+ // cooling → show heating threshold (we're cooling because temp > heating threshold)
358
+ const threshold = isHeating
359
+ ? `<${cooling.toFixed(1)}`
360
+ : `>${heating.toFixed(1)}`;
361
+ let text = `${temp}° ${threshold} [${node.operationMode}] ${node.currentMode}`;
362
+
363
+ if (pendingMode && conditionStartTime) {
364
+ const remaining = Math.max(0, node.swapTime - (now - conditionStartTime));
365
+ text += ` → ${pendingMode} ${remaining.toFixed(0)}s`;
366
+ }
367
+
368
+ if (now - node.lastModeChange < 1) {
369
+ utils.setStatusChanged(node, text);
466
370
  } else {
467
- let statusText = `in: temp=${node.lastTemperature !== null ? node.lastTemperature.toFixed(1) : "unknown"}, out: ${node.currentMode}`;
468
- if (pendingMode && conditionStartTime) {
469
- const remaining = Math.max(0, node.swapTime - (now - conditionStartTime));
470
- statusText += `, pending: ${pendingMode} in ${remaining.toFixed(0)}s`;
471
- }
472
- if (now - node.lastModeChange < 1) {
473
- utils.setStatusChanged(node, statusText);
474
- } else {
475
- utils.setStatusUnchanged(node, statusText);
476
- }
371
+ utils.setStatusUnchanged(node, text);
477
372
  }
478
373
  }
479
374
 
375
+ // ====================================================================
376
+ // Cleanup
377
+ // ====================================================================
480
378
  node.on("close", function(done) {
481
379
  done();
482
380
  });