@bldgblocks/node-red-contrib-control 0.2.1 → 0.2.3

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.
@@ -131,7 +131,7 @@
131
131
  priority: { value: "high" },
132
132
  topic: { value: "Alarms_Default" },
133
133
  title: { value: "Alarm" },
134
- message: { value: "Condition triggered" },
134
+ message: { value: "Condition active" },
135
135
  messageType: { value: "str" },
136
136
  tags: { value: "" },
137
137
  units: { value: "°F" }
@@ -206,7 +206,7 @@
206
206
  "msg"
207
207
  ],
208
208
  typeField: "#node-input-messageType"
209
- }).typedInput("type", node.messageType || "str").typedInput("value", node.message || "Condition triggered");
209
+ }).typedInput("type", node.messageType || "str").typedInput("value", node.message || "Condition active");
210
210
 
211
211
  // Show/hide sections based on inputMode
212
212
  const updateDisplay = () => {
@@ -19,7 +19,7 @@ module.exports = function(RED) {
19
19
  node.priority = config.priority || "normal";
20
20
  node.topic = config.topic || "Alarms_Default";
21
21
  node.title = config.title || "Alarm";
22
- node.message = config.message || "Condition triggered";
22
+ node.message = config.message || "Condition active";
23
23
  node.messageType = config.messageType || "str";
24
24
  node.tags = config.tags || "";
25
25
  node.units = config.units || "";
@@ -66,84 +66,65 @@ module.exports = function(RED) {
66
66
  // Helper: Evaluate alarm condition and emit event if state changed
67
67
  // ====================================================================
68
68
  function evaluateAndEmit(inputValue) {
69
- // Evaluate alarm condition based on input mode
70
69
  let conditionNowMet = false;
71
70
  let numericValue = null;
72
71
 
73
72
  if (node.inputMode === "boolean") {
74
- // Boolean mode: compare directly with alarmWhenTrue
75
73
  conditionNowMet = (inputValue === node.alarmWhenTrue);
76
74
  node.currentValue = inputValue;
77
75
  } else {
78
- // Value mode: parse as numeric and check thresholds
79
76
  numericValue = inputValue;
80
77
  if (typeof inputValue === 'object' && inputValue !== null && inputValue.value !== undefined) {
81
78
  numericValue = inputValue.value;
82
79
  }
83
80
  numericValue = parseFloat(numericValue);
84
-
81
+
85
82
  if (isNaN(numericValue)) {
86
83
  utils.setStatusError(node, "Invalid numeric input");
87
84
  return;
88
85
  }
89
-
86
+
90
87
  node.currentValue = numericValue;
91
88
 
92
- // Check thresholds
89
+ // Schmitt-trigger thresholds: when alarm is already active, use
90
+ // the magnitude-adjusted band so the value must move decisively
91
+ // past the threshold before the alarm will consider clearing.
92
+ const effectiveHigh = node.alarmState
93
+ ? (node.highThreshold - node.hysteresisMagnitude)
94
+ : node.highThreshold;
95
+ const effectiveLow = node.alarmState
96
+ ? (node.lowThreshold + node.hysteresisMagnitude)
97
+ : node.lowThreshold;
98
+
93
99
  if (node.compareMode === "either") {
94
- conditionNowMet = (numericValue > node.highThreshold) || (numericValue < node.lowThreshold);
100
+ conditionNowMet = (numericValue > effectiveHigh) || (numericValue < effectiveLow);
95
101
  } else if (node.compareMode === "high-only") {
96
- conditionNowMet = (numericValue > node.highThreshold);
102
+ conditionNowMet = numericValue > effectiveHigh;
97
103
  } else if (node.compareMode === "low-only") {
98
- conditionNowMet = (numericValue < node.lowThreshold);
104
+ conditionNowMet = numericValue < effectiveLow;
99
105
  }
100
106
  }
101
107
 
102
- // Time-based hysteresis logic
103
- if (conditionNowMet && !node.conditionMet) {
104
- // Condition just became true - start hysteresis timer
105
- node.conditionMet = true;
106
-
107
- if (node.hysteresisTimer) clearTimeout(node.hysteresisTimer);
108
+ // Single debounce timer: when the condition transitions, wait
109
+ // hysteresisTime before committing the change to alarmState.
110
+ // This filters noise in both the activation and clearing directions.
111
+ if (conditionNowMet !== node.conditionMet) {
112
+ node.conditionMet = conditionNowMet;
108
113
 
109
- node.hysteresisTimer = setTimeout(() => {
110
- if (node.conditionMet && node.alarmState === false) {
111
- // Condition stayed true for hysteresisTime ms
112
- node.alarmState = true;
113
- emitAlarmEvent("false → true");
114
- }
115
- node.hysteresisTimer = null;
116
- }, node.hysteresisTime);
117
-
118
- } else if (!conditionNowMet && node.conditionMet) {
119
- // Condition just became false - cancel pending timer
120
- node.conditionMet = false;
121
-
122
114
  if (node.hysteresisTimer) {
123
115
  clearTimeout(node.hysteresisTimer);
124
116
  node.hysteresisTimer = null;
125
117
  }
126
118
 
127
- // Check magnitude hysteresis before clearing
128
- let shouldClear = true;
129
- if (node.inputMode === "value" && node.alarmState === true) {
130
- if (node.compareMode === "either" || node.compareMode === "high-only") {
131
- const clearThreshold = node.highThreshold - node.hysteresisMagnitude;
132
- if (numericValue > clearThreshold) {
133
- shouldClear = false;
134
- }
135
- }
136
- if (node.compareMode === "either" || node.compareMode === "low-only") {
137
- const clearThreshold = node.lowThreshold + node.hysteresisMagnitude;
138
- if (numericValue < clearThreshold) {
139
- shouldClear = false;
119
+ // Start timer only when condition disagrees with alarm state
120
+ if (conditionNowMet !== node.alarmState) {
121
+ node.hysteresisTimer = setTimeout(() => {
122
+ if (node.conditionMet !== node.alarmState) {
123
+ node.alarmState = node.conditionMet;
124
+ emitAlarmEvent(node.alarmState ? "false → true" : "true → false");
140
125
  }
141
- }
142
- }
143
-
144
- if (shouldClear && node.alarmState === true) {
145
- node.alarmState = false;
146
- emitAlarmEvent("true → false");
126
+ node.hysteresisTimer = null;
127
+ }, node.hysteresisTime);
147
128
  }
148
129
  }
149
130
 
@@ -155,9 +136,11 @@ module.exports = function(RED) {
155
136
  statusText = `${numericValue.toFixed(2)} ${node.units}`;
156
137
  }
157
138
 
158
- if (node.alarmState) {
139
+ if (node.alarmState && node.hysteresisTimer) {
140
+ utils.setStatusWarn(node, statusText + " [ALARM clearing...]");
141
+ } else if (node.alarmState) {
159
142
  utils.setStatusError(node, statusText + " [ALARM]");
160
- } else if (node.conditionMet) {
143
+ } else if (node.hysteresisTimer) {
161
144
  utils.setStatusWarn(node, statusText + " (hysteresis)");
162
145
  } else {
163
146
  utils.setStatusOK(node, statusText);
@@ -49,7 +49,7 @@ module.exports = function(RED) {
49
49
  // Send alarm message with status
50
50
  const msg = {
51
51
  alarm: eventData,
52
- status: { state: "triggered", transition: eventData.transition },
52
+ status: { state: "active", transition: eventData.transition },
53
53
  activeAlarmCount: activeCount,
54
54
  alarmKey: key
55
55
  };
@@ -38,13 +38,14 @@
38
38
 
39
39
  <!-- Help Section -->
40
40
  <script type="text/markdown" data-help-name="boolean-to-number-block">
41
- Converts a boolean or null input from a configured property to a numeric output.
41
+ Converts between boolean and numeric representations. Booleans are converted to `0`/`1`;
42
+ numeric `0`/`1` values are converted back to `false`/`true`. Also handles `null`.
42
43
 
43
44
  ### Inputs
44
- : input-property (boolean | null) : Value to convert, read from the configured Input Property.
45
+ : input-property (boolean | number | null) : Value to convert, read from the configured Input Property.
45
46
 
46
47
  ### Outputs
47
- : payload (number) : Converted value: `null` to `0` (if `nullToZero` is true) or `-1`, `false` to `0`, `true` to `1`.
48
+ : payload (number | boolean) : Converted value booleans become `0`/`1`, numbers `0`/`1` become `false`/`true`, `null` becomes `0` or `-1`.
48
49
 
49
50
  ### Properties
50
51
  : name (string) : Display name in editor.
@@ -52,9 +53,17 @@ Converts a boolean or null input from a configured property to a numeric output.
52
53
  : nullToZero (boolean) : When checked, `null` maps to `0`; when unchecked, `null` maps to `-1`.
53
54
 
54
55
  ### Details
55
- Converts boolean or null input (read from the configured **Input Property**, default: `msg.payload`) to a number:
56
+ Automatically converts in both directions (read from the configured **Input Property**, default: `msg.payload`):
57
+
58
+ **Boolean → Number:**
56
59
  - `true` → `1`
57
60
  - `false` → `0`
61
+
62
+ **Number → Boolean:**
63
+ - `1` → `true`
64
+ - `0` → `false`
65
+
66
+ **Null handling:**
58
67
  - `null` → `0` (if **Null Mapping** is checked) or `-1` (if unchecked)
59
68
 
60
69
  Output is always written to `msg.payload`. All other input message properties are passed through unchanged.
@@ -31,11 +31,15 @@ module.exports = function(RED) {
31
31
  const inputDisplay = inputValue === null ? "null" : String(inputValue);
32
32
  if (inputValue === null) {
33
33
  msg.payload = node.nullToZero ? 0 : -1;
34
- utils.setStatusChanged(node, `in: ${inputDisplay}, out: ${msg.payload}`);
34
+ utils.setStatusChanged(node, `${inputDisplay} -> ${msg.payload}`);
35
35
  send(msg);
36
36
  } else if (typeof inputValue === "boolean") {
37
37
  msg.payload = inputValue ? 1 : 0;
38
- utils.setStatusChanged(node, `in: ${inputDisplay}, out: ${msg.payload}`);
38
+ utils.setStatusChanged(node, `${inputDisplay} -> ${msg.payload}`);
39
+ send(msg);
40
+ } else if (typeof inputValue === "number" && (inputValue === 0 || inputValue === 1)) {
41
+ msg.payload = inputValue === 1;
42
+ utils.setStatusChanged(node, `${inputDisplay} -> ${msg.payload}`);
39
43
  send(msg);
40
44
  } else {
41
45
  utils.setStatusError(node, "invalid input type");
@@ -267,9 +267,17 @@ module.exports = function(RED) {
267
267
  }
268
268
  } else {
269
269
  // === Call deactivated ===
270
+ // Clear run-related timers and alarms — they are no longer
271
+ // relevant once the call is off.
270
272
  if (node.initialStatusTimer) { clearTimeout(node.initialStatusTimer); node.initialStatusTimer = null; }
271
273
  if (node.heartbeatTimer) { clearTimeout(node.heartbeatTimer); node.heartbeatTimer = null; }
272
274
  if (node.statusLostTimer) { clearTimeout(node.statusLostTimer); node.statusLostTimer = null; }
275
+ if (node.debounceTimer) { clearTimeout(node.debounceTimer); node.debounceTimer = null; }
276
+
277
+ // Clear any "during run" alarm (e.g. status lost) — no longer
278
+ // meaningful now that the call itself is off.
279
+ node.alarm = false;
280
+ node.alarmMessage = "";
273
281
 
274
282
  // Monitor that status goes inactive
275
283
  if (node.actualState) {
@@ -318,16 +326,33 @@ module.exports = function(RED) {
318
326
  }
319
327
 
320
328
  // If call active and status went false → status lost alarm
321
- if (node.requestedState && !newStatus && node.config.runLostStatus) {
329
+ // CRITICAL: Only alarm if we've already received at least one status=true
330
+ // response. And use the configured heartbeat/status timeout as the delay —
331
+ // a brief status dropout should NOT alarm faster than the configured
332
+ // tolerance window. If heartbeat was running, cancel it (we know status
333
+ // is false now) and let the statusLostTimer take over with the same delay.
334
+ if (node.requestedState && !newStatus && node.config.runLostStatus && !node.neverReceivedStatus) {
335
+ // Cancel heartbeat — status is explicitly false, no point monitoring freshness
336
+ if (node.heartbeatTimer) {
337
+ clearTimeout(node.heartbeatTimer);
338
+ node.heartbeatTimer = null;
339
+ }
340
+
341
+ // Use heartbeatTimeout as the delay if configured, otherwise statusTimeout.
342
+ // These are the user's configured tolerance windows for status gaps.
343
+ const lostDelay = node.config.heartbeatTimeout > 0
344
+ ? node.config.heartbeatTimeout * 1000
345
+ : (node.config.statusTimeout > 0 ? node.config.statusTimeout * 1000 : 5000);
346
+
322
347
  node.statusLostTimer = setTimeout(() => {
323
348
  node.statusLostTimer = null;
324
- if (node.requestedState && !node.actualState) {
349
+ if (node.requestedState && !node.actualState && !node.neverReceivedStatus) {
325
350
  node.alarm = true;
326
351
  node.alarmMessage = node.config.runLostStatusMessage;
327
352
  send(buildOutput());
328
353
  updateNodeStatus();
329
354
  }
330
- }, 100); // 100ms hysteresis
355
+ }, lostDelay);
331
356
  }
332
357
 
333
358
  // If call inactive and status goes false → all clear
@@ -341,6 +366,7 @@ module.exports = function(RED) {
341
366
  }
342
367
 
343
368
  // If status active without call and no clearTimer running → unexpected
369
+ // Skip if clearTimer is active — we're still in grace period after call deactivated
344
370
  if (!node.requestedState && newStatus && !node.clearTimer && node.config.statusWithoutCall) {
345
371
  node.statusLostTimer = setTimeout(() => {
346
372
  node.statusLostTimer = null;
@@ -9,12 +9,12 @@
9
9
  </div>
10
10
  <div class="form-row">
11
11
  <label for="node-input-algorithm" title="Control algorithm type"><i class="fa fa-cog"></i> Algorithm</label>
12
- <input type="text" id="node-input-algorithm" class="node-input-typed" placeholder="single">
12
+ <input type="text" id="node-input-algorithm" placeholder="single">
13
13
  <input type="hidden" id="node-input-algorithmType">
14
14
  </div>
15
15
  <div class="form-row single-only">
16
16
  <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>
17
- <input type="text" id="node-input-setpoint" class="node-input-typed" placeholder="70">
17
+ <input type="text" id="node-input-setpoint" placeholder="70">
18
18
  <input type="hidden" id="node-input-setpointType">
19
19
  </div>
20
20
  <div class="form-row single-only">
@@ -29,12 +29,12 @@
29
29
  </div>
30
30
  <div class="form-row split-only" style="display: none;">
31
31
  <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>
32
- <input type="text" id="node-input-heatingSetpoint" class="node-input-typed" placeholder="68">
32
+ <input type="text" id="node-input-heatingSetpoint" placeholder="68">
33
33
  <input type="hidden" id="node-input-heatingSetpointType">
34
34
  </div>
35
35
  <div class="form-row split-only" style="display: none;">
36
36
  <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>
37
- <input type="text" id="node-input-coolingSetpoint" class="node-input-typed" placeholder="74">
37
+ <input type="text" id="node-input-coolingSetpoint" placeholder="74">
38
38
  <input type="hidden" id="node-input-coolingSetpointType">
39
39
  </div>
40
40
  <div class="form-row split-only" style="display: none;">
@@ -66,7 +66,7 @@
66
66
 
67
67
  <div class="form-row">
68
68
  <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>
69
- <input type="text" id="node-input-swapTime" class="node-input-typed" placeholder="300">
69
+ <input type="text" id="node-input-swapTime" placeholder="300">
70
70
  <input type="hidden" id="node-input-swapTimeType">
71
71
  </div>
72
72
  <div class="form-row">
@@ -107,6 +107,7 @@
107
107
  setpoint: { value: "70" },
108
108
  setpointType: { value: "num" },
109
109
  deadband: { value: "2" },
110
+ deadbandType: { value: "num" },
110
111
  heatingSetpoint: { value: "68" },
111
112
  heatingSetpointType: { value: "num" },
112
113
  coolingSetpoint: { value: "74" },
@@ -116,6 +117,7 @@
116
117
  heatingOn: { value: "66" },
117
118
  heatingOnType: { value: "num" },
118
119
  extent: { value: "1" },
120
+ extentType: { value: "num" },
119
121
  swapTime: { value: "300" },
120
122
  swapTimeType: { value: "num" },
121
123
  minTempSetpoint: { value: "55" },
@@ -156,7 +158,7 @@
156
158
  ]
157
159
  }, "msg", "flow", "global"],
158
160
  typeField: "#node-input-operationModeType"
159
- }).typedInput("type", node.operationModeType).typedInput("value", node.operationMode);
161
+ });
160
162
 
161
163
  $("#node-input-algorithm").typedInput({
162
164
  default: "dropdown",
@@ -169,87 +171,91 @@
169
171
  ]
170
172
  }, "msg", "flow", "global"],
171
173
  typeField: "#node-input-algorithmType"
172
- }).typedInput("type", node.algorithmType).typedInput("value", node.algorithm);
174
+ });
173
175
 
174
176
  $("#node-input-setpoint").typedInput({
175
177
  default: "num",
176
178
  types: ["num", "msg", "flow", "global"],
177
179
  typeField: "#node-input-setpointType"
178
- }).typedInput("type", node.setpointType || "num").typedInput("value", node.setpoint);
180
+ });
179
181
 
180
182
  $("#node-input-heatingSetpoint").typedInput({
181
183
  default: "num",
182
184
  types: ["num", "msg", "flow", "global"],
183
185
  typeField: "#node-input-heatingSetpointType"
184
- }).typedInput("type", node.heatingSetpointType || "num").typedInput("value", node.heatingSetpoint);
186
+ });
185
187
 
186
188
  $("#node-input-coolingSetpoint").typedInput({
187
189
  default: "num",
188
190
  types: ["num", "msg", "flow", "global"],
189
191
  typeField: "#node-input-coolingSetpointType"
190
- }).typedInput("type", node.coolingSetpointType || "num").typedInput("value", node.coolingSetpoint);
192
+ });
191
193
 
192
194
  $("#node-input-heatingOn").typedInput({
193
195
  default: "num",
194
196
  types: ["num", "msg", "flow", "global"],
195
197
  typeField: "#node-input-heatingOnType"
196
- }).typedInput("type", node.heatingOnType || "num").typedInput("value", node.heatingOn);
198
+ });
197
199
 
198
200
  $("#node-input-coolingOn").typedInput({
199
201
  default: "num",
200
202
  types: ["num", "msg", "flow", "global"],
201
203
  typeField: "#node-input-coolingOnType"
202
- }).typedInput("type", node.coolingOnType || "num").typedInput("value", node.coolingOn);
204
+ });
203
205
 
204
206
  $("#node-input-swapTime").typedInput({
205
207
  default: "num",
206
208
  types: ["num", "msg", "flow", "global"],
207
209
  typeField: "#node-input-swapTimeType"
208
- }).typedInput("type", node.swapTimeType || "num").typedInput("value", node.swapTime);
210
+ });
209
211
 
210
212
  $("#node-input-deadband").typedInput({
211
213
  default: "num",
212
214
  types: ["num", "msg", "flow", "global"],
213
215
  typeField: "#node-input-deadbandType"
214
- }).typedInput("type", node.deadbandType || "num").typedInput("value", node.deadband);
216
+ });
215
217
 
216
218
  $("#node-input-extent").typedInput({
217
219
  default: "num",
218
220
  types: ["num", "msg", "flow", "global"],
219
221
  typeField: "#node-input-extentType"
220
- }).typedInput("type", node.extentType || "num").typedInput("value", node.extent);
222
+ });
221
223
 
222
224
  $("#node-input-minTempSetpoint").typedInput({
223
225
  default: "num",
224
226
  types: ["num", "msg", "flow", "global"],
225
227
  typeField: "#node-input-minTempSetpointType"
226
- }).typedInput("type", node.minTempSetpointType || "num").typedInput("value", node.minTempSetpoint);
228
+ });
227
229
 
228
230
  $("#node-input-maxTempSetpoint").typedInput({
229
231
  default: "num",
230
232
  types: ["num", "msg", "flow", "global"],
231
233
  typeField: "#node-input-maxTempSetpointType"
232
- }).typedInput("type", node.maxTempSetpointType || "num").typedInput("value", node.maxTempSetpoint);
234
+ });
233
235
 
234
236
  // Toggle fields based on algorithm
235
237
  function toggleFields() {
236
- if ($algorithm.val() === "single") {
238
+ const type = $("#node-input-algorithm").typedInput("type");
239
+ if (type !== "dropdown") {
240
+ // Dynamic source — hide all, resolved at runtime
241
+ $singleFields.hide();
242
+ $splitFields.hide();
243
+ $specifiedFields.hide();
244
+ return;
245
+ }
246
+ const algorithm = $("#node-input-algorithm").typedInput("value");
247
+ if (algorithm === "single") {
237
248
  $singleFields.show();
238
249
  $splitFields.hide();
239
250
  $specifiedFields.hide();
240
- } else if ($algorithm.val() === "split") {
251
+ } else if (algorithm === "split") {
241
252
  $singleFields.hide();
242
253
  $splitFields.show();
243
254
  $specifiedFields.hide();
244
- } else if ($algorithm.val() === "specified") {
255
+ } else if (algorithm === "specified") {
245
256
  $singleFields.hide();
246
257
  $splitFields.hide();
247
258
  $specifiedFields.show();
248
- } else {
249
- $algorithm.val("single");
250
- $singleFields.show();
251
- $splitFields.hide();
252
- $specifiedFields.hide();
253
259
  }
254
260
  }
255
261
 
@@ -328,6 +328,7 @@ module.exports = function(RED) {
328
328
  msg.status = {
329
329
  mode: node.currentMode,
330
330
  operationMode: node.operationMode,
331
+ algorithm: node.algorithm,
331
332
  isHeating,
332
333
  heatingSetpoint: effectiveHeating,
333
334
  coolingSetpoint: effectiveCooling,
@@ -352,13 +353,31 @@ module.exports = function(RED) {
352
353
 
353
354
  const temp = node.lastTemperature !== null ? node.lastTemperature.toFixed(1) : "?";
354
355
  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}`;
356
+ let thresholdText, hysteresisText;
357
+ if (isHeating) {
358
+ thresholdText = `<${cooling.toFixed(1)}`;
359
+ if (node.lastTemperature !== null && node.lastTemperature < cooling) {
360
+ hysteresisText = " (on)";
361
+ } else if (node.lastTemperature !== null && node.lastTemperature >= heating && node.lastTemperature < cooling) {
362
+ hysteresisText = ` (holding, swap at >${heating.toFixed(1)})`;
363
+ } else if (node.lastTemperature !== null && node.lastTemperature >= cooling) {
364
+ hysteresisText = " (off)";
365
+ } else {
366
+ hysteresisText = "";
367
+ }
368
+ } else {
369
+ thresholdText = `>${heating.toFixed(1)}`;
370
+ if (node.lastTemperature !== null && node.lastTemperature > heating) {
371
+ hysteresisText = " (on)";
372
+ } else if (node.lastTemperature !== null && node.lastTemperature <= cooling && node.lastTemperature > heating) {
373
+ hysteresisText = ` (holding, swap at <${cooling.toFixed(1)})`;
374
+ } else if (node.lastTemperature !== null && node.lastTemperature <= heating) {
375
+ hysteresisText = " (off)";
376
+ } else {
377
+ hysteresisText = "";
378
+ }
379
+ }
380
+ let text = `${temp}° ${thresholdText} [${node.operationMode}] ${node.currentMode}${hysteresisText}`;
362
381
 
363
382
  if (pendingMode && conditionStartTime) {
364
383
  const remaining = Math.max(0, node.swapTime - (now - conditionStartTime));
@@ -12,9 +12,15 @@
12
12
  <select id="node-input-statusDisplay">
13
13
  <option value="default">Comment to status (Default)</option>
14
14
  <option value="name">Name to status</option>
15
+ <option value="property">Message property to status</option>
15
16
  <option value="none">No status</option>
16
17
  </select>
17
18
  </div>
19
+ <div class="form-row" id="row-statusProperty" style="display:none;">
20
+ <label for="node-input-statusProperty" title="Message property or JSONata expression to display in status"><i class="fa fa-code"></i> Property</label>
21
+ <input type="text" id="node-input-statusProperty" placeholder="payload" style="width:70%;">
22
+ <input type="hidden" id="node-input-statusPropertyType">
23
+ </div>
18
24
  </script>
19
25
 
20
26
  <script type="text/javascript">
@@ -24,7 +30,9 @@
24
30
  defaults: {
25
31
  name: { value: "comment" },
26
32
  comment: { value: "No comment", validate: function(v) { return v.length < 100 } },
27
- statusDisplay: { value: "default" }
33
+ statusDisplay: { value: "default" },
34
+ statusProperty: { value: "payload" },
35
+ statusPropertyType: { value: "msg" }
28
36
  },
29
37
  inputs: 1,
30
38
  outputs: 1,
@@ -35,9 +43,31 @@
35
43
  label: function() {
36
44
  return this.name || this.comment || "comment";
37
45
  },
46
+ oneditprepare: function() {
47
+ var node = this;
48
+
49
+ $("#node-input-statusProperty").typedInput({
50
+ default: "msg",
51
+ types: ["msg", "jsonata"],
52
+ typeField: "#node-input-statusPropertyType"
53
+ }).typedInput("type", node.statusPropertyType || "msg")
54
+ .typedInput("value", node.statusProperty || "payload");
55
+
56
+ function togglePropertyRow() {
57
+ if ($("#node-input-statusDisplay").val() === "property") {
58
+ $("#row-statusProperty").show();
59
+ } else {
60
+ $("#row-statusProperty").hide();
61
+ }
62
+ }
63
+
64
+ $("#node-input-statusDisplay").on("change", togglePropertyRow);
65
+ togglePropertyRow();
66
+ },
38
67
  oneditsave: function() {
39
68
  // Update status immediately
40
69
  const node = RED.nodes.getNode(this.id);
70
+ if (!node) return;
41
71
  let status = {};
42
72
  switch (node.statusDisplay) {
43
73
  case "default":
@@ -46,18 +76,20 @@
46
76
  case "name":
47
77
  status = { fill: "green", shape: "dot", text: node.name || "comment" };
48
78
  break;
79
+ case "property":
80
+ status = { fill: "green", shape: "dot", text: "waiting for input" };
81
+ break;
49
82
  case "none":
50
83
  break;
51
84
  default:
52
85
  }
53
-
54
86
  node.status(status);
55
87
  }
56
88
  });
57
89
  </script>
58
90
 
59
91
  <script type="text/markdown" data-help-name="comment-block">
60
- Displays a configurable comment, node name, or no status persistently and on input.
92
+ Displays a configurable comment, node name, message property, or no status persistently and on input.
61
93
 
62
94
  ### Inputs
63
95
  : payload (any) : Passthrough.
@@ -66,16 +98,23 @@ Displays a configurable comment, node name, or no status persistently and on inp
66
98
  : payload (any) : Passthrough.
67
99
 
68
100
  ### Details
69
- Displays a status (comment, node name, or none) on node creation, editor saves, and input messages.
101
+ Displays a status (comment, node name, message property, or none) on node creation, editor saves, and input messages.
102
+
103
+ Wire this node inline to observe values flowing through without interrupting the message chain.
104
+
105
+ **Status Display modes:**
106
+ - **Comment to status (Default)**: Shows the configured comment text.
107
+ - **Name to status**: Shows the node's name.
108
+ - **Message property to status**: Evaluates a `msg` property path (e.g., `payload`, `value`, `activePriority`) or a JSONata expression on each input and displays the result.
109
+ - **No status**: Hides the status indicator.
70
110
 
71
- Used for flow annotations or debugging without requiring input to trigger status.
111
+ When using **Message property** mode, the status updates on every input message. If the property is not found, a yellow warning is shown.
72
112
 
73
113
  ### Status
74
- - Green (dot): Configuration update
75
- - Blue (dot): State changed
76
- - Blue (ring): State unchanged
114
+ - Green (dot): Static display (comment/name)
115
+ - Blue (dot): Property value displayed
116
+ - Yellow (ring): Property not found
77
117
  - Red (ring): Error
78
- - Yellow (ring): Warning
79
118
 
80
119
  ### References
81
120
  - [Node-RED Documentation](https://nodered.org/docs/)