@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.
- package/LICENSE.md +38 -5
- package/README.md +10 -0
- package/nodes/accumulate-block.html +3 -3
- package/nodes/alarm-collector.html +11 -0
- package/nodes/alarm-collector.js +31 -5
- package/nodes/alarm-config.html +11 -6
- package/nodes/alarm-config.js +34 -35
- package/nodes/alarm-service.js +2 -2
- package/nodes/and-block.js +1 -1
- package/nodes/boolean-switch-block.html +27 -14
- package/nodes/boolean-switch-block.js +22 -12
- package/nodes/call-status-block.html +83 -56
- package/nodes/call-status-block.js +335 -248
- package/nodes/changeover-block.html +30 -31
- package/nodes/changeover-block.js +287 -389
- package/nodes/contextual-label-block.js +3 -3
- package/nodes/delay-block.js +74 -13
- package/nodes/global-getter.html +173 -12
- package/nodes/global-getter.js +29 -14
- package/nodes/global-setter.html +37 -0
- package/nodes/global-setter.js +96 -14
- package/nodes/history-buffer.js +32 -27
- package/nodes/history-collector.html +3 -1
- package/nodes/history-collector.js +4 -4
- package/nodes/history-config.html +8 -2
- package/nodes/network-point-read.js +6 -1
- package/nodes/network-point-register.html +1 -1
- package/nodes/network-service-bridge.js +43 -11
- package/nodes/network-service-registry.html +236 -27
- package/nodes/network-service-registry.js +1 -1
- package/nodes/or-block.js +1 -1
- package/nodes/priority-block.js +1 -1
- package/nodes/tstat-block.html +34 -79
- package/nodes/tstat-block.js +223 -345
- package/nodes/utils.js +1 -1
- package/package.json +90 -75
|
@@ -1,39 +1,90 @@
|
|
|
1
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
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.
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
155
|
+
if (node.extent < 0) {
|
|
156
|
+
utils.setStatusError(node, "extent must be >= 0");
|
|
157
|
+
if (done) done();
|
|
158
|
+
return;
|
|
167
159
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
160
|
+
if (node.maxTempSetpoint <= node.minTempSetpoint) {
|
|
161
|
+
utils.setStatusError(node, "maxTempSetpoint must be > minTempSetpoint");
|
|
162
|
+
if (done) done();
|
|
163
|
+
return;
|
|
172
164
|
}
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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 (
|
|
195
|
+
} catch (e) {
|
|
313
196
|
input = NaN;
|
|
314
197
|
}
|
|
315
198
|
if (isNaN(input)) {
|
|
316
|
-
utils.setStatusError(node, "missing or invalid
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
377
|
-
if (
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
317
|
+
// ====================================================================
|
|
318
|
+
// Build output message
|
|
319
|
+
// ====================================================================
|
|
320
|
+
function buildOutputs(msg) {
|
|
432
321
|
const isHeating = node.currentMode === "heating";
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
});
|