node-red-contrib-freya-nodes 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.
- package/nodes/humidity-controller/humidity-controller-node.html +16 -5
- package/nodes/humidity-controller/humidity-controller-node.js +28 -15
- package/nodes/lighting-controller/lighting-controller-node.html +15 -3
- package/nodes/lighting-controller/lighting-controller-node.js +17 -1
- package/nodes/precipitation-controller/precipitation-controller-node.html +35 -9
- package/nodes/precipitation-controller/precipitation-controller-node.js +43 -6
- package/nodes/temperature-controller/temperature-controller-node.html +19 -5
- package/nodes/temperature-controller/temperature-controller-node.js +28 -15
- package/package.json +1 -1
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
inputs:1,
|
|
31
31
|
outputs:2,
|
|
32
|
-
outputLabels:["
|
|
32
|
+
outputLabels:["control (actuators)", "status"],
|
|
33
33
|
icon: "font-awesome/fa-tint",
|
|
34
34
|
label: function() {
|
|
35
35
|
return this.name || "Humidity Controller";
|
|
@@ -125,11 +125,13 @@
|
|
|
125
125
|
|
|
126
126
|
<h3>Outputs</h3>
|
|
127
127
|
<dl class="message-properties">
|
|
128
|
-
<dt>Output 1:
|
|
129
|
-
<dd>
|
|
130
|
-
|
|
128
|
+
<dt>Output 1: Control <span class="property-type">object</span></dt>
|
|
129
|
+
<dd><code>msg.payload</code> is <code>{ humidifier: "on"/"off", dehumidifier: "on"/"off" }</code>
|
|
130
|
+
with <code>msg.topic = "actuators"</code>.</dd>
|
|
131
|
+
|
|
131
132
|
<dt>Output 2: Status <span class="property-type">object</span></dt>
|
|
132
|
-
<dd>
|
|
133
|
+
<dd><code>msg.payload</code> contains <code>state</code>, <code>timestamp</code>, and optional
|
|
134
|
+
context fields. <code>msg.topic = "status"</code>. Emitted only on state changes.</dd>
|
|
133
135
|
</dl>
|
|
134
136
|
|
|
135
137
|
<h3>Control Logic</h3>
|
|
@@ -156,4 +158,13 @@
|
|
|
156
158
|
<li><strong>Auto Recovery:</strong> Resumes normal control immediately when sensor returns</li>
|
|
157
159
|
<li><strong>Status Alerts:</strong> Emits configurable severity alerts on watchdog trigger</li>
|
|
158
160
|
</ul>
|
|
161
|
+
|
|
162
|
+
<h3>Status Messages</h3>
|
|
163
|
+
<p>Status messages are emitted on output 2 only when the state changes:</p>
|
|
164
|
+
<ul>
|
|
165
|
+
<li><strong>idle</strong> — at target or awaiting inputs</li>
|
|
166
|
+
<li><strong>humidifying</strong> — humidifier is active (includes <code>target</code>, <code>reading</code>, <code>deadband</code>, <code>safetyOverride</code>)</li>
|
|
167
|
+
<li><strong>dehumidifying</strong> — dehumidifier is active (includes <code>target</code>, <code>reading</code>, <code>deadband</code>, <code>safetyOverride</code>)</li>
|
|
168
|
+
<li><strong>error</strong> — open-loop mode, watchdog triggered (includes <code>severity</code>, <code>message</code>)</li>
|
|
169
|
+
</ul>
|
|
159
170
|
</script>
|
|
@@ -11,7 +11,22 @@ const humidityController = (RED) => {
|
|
|
11
11
|
const state = {
|
|
12
12
|
currentState: 'idle',
|
|
13
13
|
safetyOverride: null,
|
|
14
|
-
openLoopMode: false
|
|
14
|
+
openLoopMode: false,
|
|
15
|
+
previousStatusState: null
|
|
16
|
+
};
|
|
17
|
+
const emitStatus = (newState, optionalFields) => {
|
|
18
|
+
if (newState === state.previousStatusState) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
state.previousStatusState = newState;
|
|
22
|
+
const statusPayload = {
|
|
23
|
+
state: newState,
|
|
24
|
+
timestamp: Date.now()
|
|
25
|
+
};
|
|
26
|
+
if (optionalFields) {
|
|
27
|
+
Object.assign(statusPayload, optionalFields);
|
|
28
|
+
}
|
|
29
|
+
return { payload: statusPayload, topic: 'status' };
|
|
15
30
|
};
|
|
16
31
|
const startWatchdog = () => {
|
|
17
32
|
if (state.watchdogTimer) {
|
|
@@ -19,6 +34,7 @@ const humidityController = (RED) => {
|
|
|
19
34
|
}
|
|
20
35
|
state.watchdogTimer = setTimeout(() => {
|
|
21
36
|
state.openLoopMode = true;
|
|
37
|
+
state.previousStatusState = 'error';
|
|
22
38
|
node.send([
|
|
23
39
|
{
|
|
24
40
|
payload: {
|
|
@@ -29,10 +45,10 @@ const humidityController = (RED) => {
|
|
|
29
45
|
},
|
|
30
46
|
{
|
|
31
47
|
payload: {
|
|
48
|
+
state: 'error',
|
|
49
|
+
timestamp: Date.now(),
|
|
32
50
|
severity: node.watchdogSeverity,
|
|
33
|
-
message: `No sensor feedback for ${node.watchdogTimeout}s
|
|
34
|
-
state: 'open-loop',
|
|
35
|
-
timestamp: new Date().toISOString()
|
|
51
|
+
message: `No sensor feedback for ${node.watchdogTimeout}s`
|
|
36
52
|
},
|
|
37
53
|
topic: 'status'
|
|
38
54
|
}
|
|
@@ -114,6 +130,12 @@ const humidityController = (RED) => {
|
|
|
114
130
|
};
|
|
115
131
|
const _executeControlLoop = () => {
|
|
116
132
|
const control = calculateControl();
|
|
133
|
+
const statusMsg = emitStatus(control.state, {
|
|
134
|
+
target: state.targetHumidity,
|
|
135
|
+
reading: state.currentHumidity,
|
|
136
|
+
deadband: this.deadband,
|
|
137
|
+
safetyOverride: control.override || null
|
|
138
|
+
});
|
|
117
139
|
node.send([
|
|
118
140
|
{
|
|
119
141
|
payload: {
|
|
@@ -122,17 +144,7 @@ const humidityController = (RED) => {
|
|
|
122
144
|
},
|
|
123
145
|
topic: 'actuators'
|
|
124
146
|
},
|
|
125
|
-
|
|
126
|
-
payload: {
|
|
127
|
-
state: control.state,
|
|
128
|
-
target: state.targetHumidity,
|
|
129
|
-
reading: state.currentHumidity,
|
|
130
|
-
safetyOverride: control.override || null,
|
|
131
|
-
deadband: this.deadband,
|
|
132
|
-
openLoopMode: state.openLoopMode
|
|
133
|
-
},
|
|
134
|
-
topic: 'status'
|
|
135
|
-
}
|
|
147
|
+
statusMsg
|
|
136
148
|
]);
|
|
137
149
|
updateStatus(control);
|
|
138
150
|
};
|
|
@@ -168,6 +180,7 @@ const humidityController = (RED) => {
|
|
|
168
180
|
stopWatchdog();
|
|
169
181
|
});
|
|
170
182
|
node.status({ fill: 'yellow', shape: 'dot', text: 'awaiting inputs' });
|
|
183
|
+
emitStatus('idle');
|
|
171
184
|
}
|
|
172
185
|
RED.nodes.registerType('humidity controller', HumidityControllerNode);
|
|
173
186
|
};
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
inputs:1,
|
|
32
32
|
outputs:2,
|
|
33
|
-
outputLabels:["control", "status"],
|
|
33
|
+
outputLabels:["control (actuators)", "status"],
|
|
34
34
|
icon: "font-awesome/fa-lightbulb-o",
|
|
35
35
|
label: function() {
|
|
36
36
|
return this.name || "Lighting Controller";
|
|
@@ -139,8 +139,20 @@
|
|
|
139
139
|
</ul>
|
|
140
140
|
|
|
141
141
|
<h3>Outputs</h3>
|
|
142
|
+
<dl class="message-properties">
|
|
143
|
+
<dt>Output 1: Control <span class="property-type">object</span></dt>
|
|
144
|
+
<dd><code>msg.payload</code> contains the lighting command (analog: <code>{ lighting: 0.0–100.0 }</code>,
|
|
145
|
+
digital: <code>{ lighting: "on"/"off" }</code>). <code>msg.topic = "actuators"</code>.</dd>
|
|
146
|
+
|
|
147
|
+
<dt>Output 2: Status <span class="property-type">object</span></dt>
|
|
148
|
+
<dd><code>msg.payload</code> contains <code>state</code>, <code>timestamp</code>, and optional
|
|
149
|
+
context fields. <code>msg.topic = "status"</code>. Emitted on every input.</dd>
|
|
150
|
+
</dl>
|
|
151
|
+
|
|
152
|
+
<h3>Status Messages</h3>
|
|
153
|
+
<p>Status messages are emitted on output 2 on every input, reflecting the current brightness and state:</p>
|
|
142
154
|
<ul>
|
|
143
|
-
<li><strong>
|
|
144
|
-
<li><strong>
|
|
155
|
+
<li><strong>idle</strong> — lighting is off (brightness = 0)</li>
|
|
156
|
+
<li><strong>active</strong> — lighting is on (includes <code>brightness</code> %, <code>mode</code>, <code>scheduleActive</code>)</li>
|
|
145
157
|
</ul>
|
|
146
158
|
</script>
|
|
@@ -9,6 +9,16 @@ const lightingController = (RED) => {
|
|
|
9
9
|
this.scheduleTime1 = config.scheduleTime1 || '';
|
|
10
10
|
this.scheduleTime2 = config.scheduleTime2 || '';
|
|
11
11
|
this.scheduleMode = config.scheduleMode || 'allowed';
|
|
12
|
+
const buildStatus = (currentState, optionalFields) => {
|
|
13
|
+
const statusPayload = {
|
|
14
|
+
state: currentState,
|
|
15
|
+
timestamp: Date.now()
|
|
16
|
+
};
|
|
17
|
+
if (optionalFields) {
|
|
18
|
+
Object.assign(statusPayload, optionalFields);
|
|
19
|
+
}
|
|
20
|
+
return { payload: statusPayload, topic: 'status' };
|
|
21
|
+
};
|
|
12
22
|
const isWithinSchedule = () => {
|
|
13
23
|
if (!this.scheduleTime1 || !this.scheduleTime2) {
|
|
14
24
|
return true;
|
|
@@ -96,9 +106,15 @@ const lightingController = (RED) => {
|
|
|
96
106
|
node.status({ fill: 'grey', shape: 'dot', text: statusText });
|
|
97
107
|
}
|
|
98
108
|
}
|
|
109
|
+
const currentState = finalValue > 0 ? 'active' : 'idle';
|
|
110
|
+
const statusMsg = buildStatus(currentState, {
|
|
111
|
+
brightness: finalValue,
|
|
112
|
+
mode: this.mode,
|
|
113
|
+
scheduleActive
|
|
114
|
+
});
|
|
99
115
|
send([
|
|
100
116
|
{ payload: outputPayload, topic: 'actuators' },
|
|
101
|
-
|
|
117
|
+
statusMsg
|
|
102
118
|
]);
|
|
103
119
|
done === null || done === void 0 ? void 0 : done();
|
|
104
120
|
});
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
tickInterval: {value: 60, required: true, validate: function(v) { return RED.validators.number()(v) && parseInt(v) >= 1; }}
|
|
27
27
|
},
|
|
28
28
|
inputs:1,
|
|
29
|
-
outputs:
|
|
29
|
+
outputs:2,
|
|
30
30
|
inputLabels:["parameter update"],
|
|
31
|
-
outputLabels:["
|
|
31
|
+
outputLabels:["control (actuators)", "status"],
|
|
32
32
|
icon: "font-awesome/fa-tint",
|
|
33
33
|
label: function() {
|
|
34
34
|
return this.name || "Precipitation Controller";
|
|
@@ -69,19 +69,28 @@
|
|
|
69
69
|
<dt>msg.topic = "duration" <span class="property-type">number</span></dt>
|
|
70
70
|
<dd><code>msg.payload</code> sets the pump duration in seconds, overriding the configured default</dd>
|
|
71
71
|
|
|
72
|
+
<dt>msg.topic = "nightDisable" <span class="property-type">boolean</span></dt>
|
|
73
|
+
<dd><code>msg.payload</code> enables (<code>true</code>) or disables (<code>false</code>) the
|
|
74
|
+
nighttime pause feature at runtime. Defaults to <code>false</code> on deploy.
|
|
75
|
+
Switching from <code>true</code> to <code>false</code> while paused resumes normal operation immediately.</dd>
|
|
76
|
+
|
|
72
77
|
<dt>msg.topic = "night" <span class="property-type">boolean</span></dt>
|
|
73
78
|
<dd><code>msg.payload</code> signals nighttime (<code>true</code>) or daytime (<code>false</code>).
|
|
79
|
+
Only effective when nighttime disable has been enabled via the <code>nightDisable</code> topic.
|
|
74
80
|
During nighttime no new precipitation events are started. If the pump is already active
|
|
75
81
|
when night begins, the current cycle finishes normally.</dd>
|
|
76
82
|
</dl>
|
|
77
83
|
<p>Messages with unrecognised topics are ignored.</p>
|
|
78
84
|
|
|
79
|
-
<h3>
|
|
85
|
+
<h3>Outputs</h3>
|
|
80
86
|
<dl class="message-properties">
|
|
81
|
-
<dt>
|
|
82
|
-
<dd><code>{ pump: "on" }</code>
|
|
83
|
-
<code>msg.topic
|
|
84
|
-
|
|
87
|
+
<dt>Output 1: Control <span class="property-type">object</span></dt>
|
|
88
|
+
<dd><code>msg.payload</code> is <code>{ pump: "on" }</code> or <code>{ pump: "off" }</code>
|
|
89
|
+
with <code>msg.topic = "actuators"</code>. Two messages are sent per precipitation event.</dd>
|
|
90
|
+
|
|
91
|
+
<dt>Output 2: Status <span class="property-type">object</span></dt>
|
|
92
|
+
<dd><code>msg.payload</code> contains <code>state</code>, <code>timestamp</code>, and optional
|
|
93
|
+
context fields. <code>msg.topic = "status"</code>. Emitted only on state changes.</dd>
|
|
85
94
|
</dl>
|
|
86
95
|
|
|
87
96
|
<h3>Details</h3>
|
|
@@ -89,7 +98,7 @@
|
|
|
89
98
|
precipitation is due:</p>
|
|
90
99
|
<ul>
|
|
91
100
|
<li>If the pump is already running the tick is skipped (no overlapping cycles).</li>
|
|
92
|
-
<li>If nighttime is signalled, the tick is skipped.</li>
|
|
101
|
+
<li>If nighttime disable is enabled and night is signalled, the tick is skipped.</li>
|
|
93
102
|
<li>If this is the first tick after deploy, or the configured interval has elapsed
|
|
94
103
|
since the last event, the pump is turned on for the configured duration and then
|
|
95
104
|
automatically turned off.</li>
|
|
@@ -99,6 +108,23 @@
|
|
|
99
108
|
<p>On redeploy or shutdown the node clears all timers and forces the pump off
|
|
100
109
|
to prevent it from getting stuck on.</p>
|
|
101
110
|
|
|
111
|
+
<h3>Nighttime Disable</h3>
|
|
112
|
+
<p>The nighttime disable feature is controlled at runtime via <code>msg.topic = "nightDisable"</code>.
|
|
113
|
+
On deploy it defaults to off (precipitation runs 24/7). When enabled, the node tracks a
|
|
114
|
+
night/day state updated via <code>msg.topic = "night"</code>. During nighttime no new
|
|
115
|
+
precipitation events are started. If the pump is already active when night begins, the
|
|
116
|
+
current cycle is allowed to finish normally. Switching nighttime disable off while paused
|
|
117
|
+
causes the node to resume normal tick evaluation immediately.</p>
|
|
118
|
+
|
|
119
|
+
<h3>Status Messages</h3>
|
|
120
|
+
<p>Status messages are emitted on output 2 only when the state changes:</p>
|
|
121
|
+
<ul>
|
|
122
|
+
<li><strong>idle</strong> — initial state after deploy, no evaluation yet</li>
|
|
123
|
+
<li><strong>active</strong> — pump is on (includes <code>duration</code> in ms)</li>
|
|
124
|
+
<li><strong>waiting</strong> — pump off, counting down (includes <code>remaining</code> in ms)</li>
|
|
125
|
+
<li><strong>paused</strong> — nighttime, precipitation suppressed</li>
|
|
126
|
+
</ul>
|
|
127
|
+
|
|
102
128
|
<h3>Configuration</h3>
|
|
103
129
|
<ul>
|
|
104
130
|
<li><strong>Interval:</strong> Time between precipitation events in minutes (default: 60)</li>
|
|
@@ -106,7 +132,7 @@
|
|
|
106
132
|
<li><strong>Tick Interval:</strong> How often to evaluate whether precipitation is due in seconds (default: 60)</li>
|
|
107
133
|
</ul>
|
|
108
134
|
|
|
109
|
-
<h3>Status</h3>
|
|
135
|
+
<h3>Node Status</h3>
|
|
110
136
|
<ul>
|
|
111
137
|
<li><strong>idle</strong> (grey/ring) — initial state before first tick</li>
|
|
112
138
|
<li><strong>pump on</strong> (blue/dot) — pump is running, shows duration</li>
|
|
@@ -22,34 +22,62 @@ const precipitationController = (RED) => {
|
|
|
22
22
|
pumpActive: false,
|
|
23
23
|
intervalMs: (node.interval || 60) * 60000,
|
|
24
24
|
durationMs: (node.duration || 30) * 1000,
|
|
25
|
-
|
|
25
|
+
nightDisable: false,
|
|
26
|
+
night: false,
|
|
27
|
+
previousState: null
|
|
28
|
+
};
|
|
29
|
+
const emitStatus = (newState, optionalFields) => {
|
|
30
|
+
if (newState === state.previousState) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
state.previousState = newState;
|
|
34
|
+
const statusPayload = {
|
|
35
|
+
state: newState,
|
|
36
|
+
timestamp: Date.now()
|
|
37
|
+
};
|
|
38
|
+
if (optionalFields) {
|
|
39
|
+
Object.assign(statusPayload, optionalFields);
|
|
40
|
+
}
|
|
41
|
+
node.send([null, { payload: statusPayload, topic: 'status' }]);
|
|
26
42
|
};
|
|
27
43
|
const evaluatePrecipitation = () => {
|
|
28
44
|
if (state.pumpActive) {
|
|
29
45
|
return;
|
|
30
46
|
}
|
|
31
|
-
if (state.night) {
|
|
47
|
+
if (state.nightDisable && state.night) {
|
|
32
48
|
node.status({ fill: 'grey', shape: 'ring', text: 'paused (night)' });
|
|
49
|
+
emitStatus('paused');
|
|
33
50
|
return;
|
|
34
51
|
}
|
|
35
52
|
const now = Date.now();
|
|
36
53
|
if (state.lastPrecipitation === null || (now - state.lastPrecipitation) >= state.intervalMs) {
|
|
37
54
|
state.pumpActive = true;
|
|
38
55
|
state.lastPrecipitation = now;
|
|
39
|
-
node.send({ payload: { pump: 'on' }, topic: 'actuators' });
|
|
40
56
|
node.status({ fill: 'blue', shape: 'dot', text: `pump on (${formatDuration(state.durationMs)})` });
|
|
57
|
+
state.previousState = 'active';
|
|
58
|
+
node.send([
|
|
59
|
+
{ payload: { pump: 'on' }, topic: 'actuators' },
|
|
60
|
+
{ payload: { state: 'active', timestamp: now, duration: state.durationMs }, topic: 'status' }
|
|
61
|
+
]);
|
|
41
62
|
state.offTimer = setTimeout(() => {
|
|
42
63
|
state.pumpActive = false;
|
|
43
64
|
state.offTimer = undefined;
|
|
44
|
-
|
|
45
|
-
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false });
|
|
65
|
+
const offNow = Date.now();
|
|
66
|
+
const timeStr = new Date(offNow).toLocaleTimeString('en-GB', { hour12: false });
|
|
46
67
|
node.status({ fill: 'grey', shape: 'ring', text: `last: ${timeStr}` });
|
|
68
|
+
const remaining = state.intervalMs - (offNow - (state.lastPrecipitation || offNow));
|
|
69
|
+
state.previousState = 'waiting';
|
|
70
|
+
node.send([
|
|
71
|
+
{ payload: { pump: 'off' }, topic: 'actuators' },
|
|
72
|
+
{ payload: { state: 'waiting', timestamp: offNow, remaining: Math.max(0, remaining) }, topic: 'status' }
|
|
73
|
+
]);
|
|
47
74
|
}, state.durationMs);
|
|
48
75
|
}
|
|
49
76
|
else {
|
|
50
77
|
const elapsed = now - state.lastPrecipitation;
|
|
51
78
|
const remaining = state.intervalMs - elapsed;
|
|
52
79
|
node.status({ fill: 'green', shape: 'ring', text: `next in ${formatDuration(remaining)}` });
|
|
80
|
+
emitStatus('waiting', { remaining });
|
|
53
81
|
}
|
|
54
82
|
};
|
|
55
83
|
const startTickInterval = () => {
|
|
@@ -70,6 +98,14 @@ const precipitationController = (RED) => {
|
|
|
70
98
|
state.durationMs = msg.payload * 1000;
|
|
71
99
|
node.log(`Duration updated: ${msg.payload} seconds`);
|
|
72
100
|
}
|
|
101
|
+
else if (msg.topic === 'nightDisable' && typeof msg.payload === 'boolean') {
|
|
102
|
+
const wasEnabled = state.nightDisable;
|
|
103
|
+
state.nightDisable = msg.payload;
|
|
104
|
+
node.log(`Nighttime disable updated: ${msg.payload}`);
|
|
105
|
+
if (wasEnabled && !msg.payload && state.night && !state.pumpActive) {
|
|
106
|
+
evaluatePrecipitation();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
73
109
|
else if (msg.topic === 'night' && typeof msg.payload === 'boolean') {
|
|
74
110
|
state.night = msg.payload;
|
|
75
111
|
node.log(`Night state updated: ${msg.payload}`);
|
|
@@ -87,11 +123,12 @@ const precipitationController = (RED) => {
|
|
|
87
123
|
}
|
|
88
124
|
if (state.pumpActive) {
|
|
89
125
|
state.pumpActive = false;
|
|
90
|
-
node.send({ payload: { pump: 'off' }, topic: 'actuators' });
|
|
126
|
+
node.send([{ payload: { pump: 'off' }, topic: 'actuators' }, null]);
|
|
91
127
|
}
|
|
92
128
|
done();
|
|
93
129
|
});
|
|
94
130
|
node.status({ fill: 'grey', shape: 'ring', text: 'idle' });
|
|
131
|
+
emitStatus('idle');
|
|
95
132
|
startTickInterval();
|
|
96
133
|
}
|
|
97
134
|
RED.nodes.registerType('precipitation controller', PrecipitationControllerNode);
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
inputs:1,
|
|
31
31
|
outputs:2,
|
|
32
|
-
outputLabels:["
|
|
32
|
+
outputLabels:["control (actuators)", "status"],
|
|
33
33
|
icon: "font-awesome/fa-thermometer-half",
|
|
34
34
|
label: function() {
|
|
35
35
|
return this.name||"Temperature Controller";
|
|
@@ -97,10 +97,15 @@
|
|
|
97
97
|
</dl>
|
|
98
98
|
|
|
99
99
|
<h3>Outputs</h3>
|
|
100
|
-
<
|
|
101
|
-
<
|
|
102
|
-
<
|
|
103
|
-
|
|
100
|
+
<dl class="message-properties">
|
|
101
|
+
<dt>Output 1: Control <span class="property-type">object</span></dt>
|
|
102
|
+
<dd><code>msg.payload</code> is <code>{ heater: "on"/"off", cooler: "on"/"off" }</code>
|
|
103
|
+
with <code>msg.topic = "actuators"</code>.</dd>
|
|
104
|
+
|
|
105
|
+
<dt>Output 2: Status <span class="property-type">object</span></dt>
|
|
106
|
+
<dd><code>msg.payload</code> contains <code>state</code>, <code>timestamp</code>, and optional
|
|
107
|
+
context fields. <code>msg.topic = "status"</code>. Emitted only on state changes.</dd>
|
|
108
|
+
</dl>
|
|
104
109
|
|
|
105
110
|
<h3>Control Logic</h3>
|
|
106
111
|
<ul>
|
|
@@ -122,4 +127,13 @@
|
|
|
122
127
|
<li><strong>Auto Recovery:</strong> Resumes normal control immediately when sensor returns</li>
|
|
123
128
|
<li><strong>Status Alerts:</strong> Emits configurable severity alerts on watchdog trigger</li>
|
|
124
129
|
</ul>
|
|
130
|
+
|
|
131
|
+
<h3>Status Messages</h3>
|
|
132
|
+
<p>Status messages are emitted on output 2 only when the state changes:</p>
|
|
133
|
+
<ul>
|
|
134
|
+
<li><strong>idle</strong> — at target or awaiting inputs</li>
|
|
135
|
+
<li><strong>heating</strong> — heater is active (includes <code>target</code>, <code>reading</code>, <code>deadband</code>, <code>safetyOverride</code>)</li>
|
|
136
|
+
<li><strong>cooling</strong> — cooler is active (includes <code>target</code>, <code>reading</code>, <code>deadband</code>, <code>safetyOverride</code>)</li>
|
|
137
|
+
<li><strong>error</strong> — open-loop mode, watchdog triggered (includes <code>severity</code>, <code>message</code>)</li>
|
|
138
|
+
</ul>
|
|
125
139
|
</script>
|
|
@@ -11,7 +11,22 @@ const temperatureController = (RED) => {
|
|
|
11
11
|
const state = {
|
|
12
12
|
currentState: 'idle',
|
|
13
13
|
safetyOverride: null,
|
|
14
|
-
openLoopMode: false
|
|
14
|
+
openLoopMode: false,
|
|
15
|
+
previousStatusState: null
|
|
16
|
+
};
|
|
17
|
+
const emitStatus = (newState, optionalFields) => {
|
|
18
|
+
if (newState === state.previousStatusState) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
state.previousStatusState = newState;
|
|
22
|
+
const statusPayload = {
|
|
23
|
+
state: newState,
|
|
24
|
+
timestamp: Date.now()
|
|
25
|
+
};
|
|
26
|
+
if (optionalFields) {
|
|
27
|
+
Object.assign(statusPayload, optionalFields);
|
|
28
|
+
}
|
|
29
|
+
return { payload: statusPayload, topic: 'status' };
|
|
15
30
|
};
|
|
16
31
|
const startWatchdog = () => {
|
|
17
32
|
if (state.watchdogTimer) {
|
|
@@ -19,6 +34,7 @@ const temperatureController = (RED) => {
|
|
|
19
34
|
}
|
|
20
35
|
state.watchdogTimer = setTimeout(() => {
|
|
21
36
|
state.openLoopMode = true;
|
|
37
|
+
state.previousStatusState = 'error';
|
|
22
38
|
node.send([
|
|
23
39
|
{
|
|
24
40
|
payload: {
|
|
@@ -29,10 +45,10 @@ const temperatureController = (RED) => {
|
|
|
29
45
|
},
|
|
30
46
|
{
|
|
31
47
|
payload: {
|
|
48
|
+
state: 'error',
|
|
49
|
+
timestamp: Date.now(),
|
|
32
50
|
severity: node.watchdogSeverity,
|
|
33
|
-
message: `No sensor feedback for ${node.watchdogTimeout}s
|
|
34
|
-
state: 'open-loop',
|
|
35
|
-
timestamp: new Date().toISOString()
|
|
51
|
+
message: `No sensor feedback for ${node.watchdogTimeout}s`
|
|
36
52
|
},
|
|
37
53
|
topic: 'status'
|
|
38
54
|
}
|
|
@@ -114,6 +130,12 @@ const temperatureController = (RED) => {
|
|
|
114
130
|
};
|
|
115
131
|
const _executeControlLoop = () => {
|
|
116
132
|
const control = calculateControl();
|
|
133
|
+
const statusMsg = emitStatus(control.state, {
|
|
134
|
+
target: state.targetTemperature,
|
|
135
|
+
reading: state.currentTemperature,
|
|
136
|
+
deadband: this.deadband,
|
|
137
|
+
safetyOverride: control.override || null
|
|
138
|
+
});
|
|
117
139
|
node.send([
|
|
118
140
|
{
|
|
119
141
|
payload: {
|
|
@@ -122,17 +144,7 @@ const temperatureController = (RED) => {
|
|
|
122
144
|
},
|
|
123
145
|
topic: 'actuators'
|
|
124
146
|
},
|
|
125
|
-
|
|
126
|
-
payload: {
|
|
127
|
-
state: control.state,
|
|
128
|
-
target: state.targetTemperature,
|
|
129
|
-
reading: state.currentTemperature,
|
|
130
|
-
safetyOverride: control.override || null,
|
|
131
|
-
deadband: this.deadband,
|
|
132
|
-
openLoopMode: state.openLoopMode
|
|
133
|
-
},
|
|
134
|
-
topic: 'status'
|
|
135
|
-
}
|
|
147
|
+
statusMsg
|
|
136
148
|
]);
|
|
137
149
|
updateStatus(control);
|
|
138
150
|
};
|
|
@@ -168,6 +180,7 @@ const temperatureController = (RED) => {
|
|
|
168
180
|
stopWatchdog();
|
|
169
181
|
});
|
|
170
182
|
node.status({ fill: 'yellow', shape: 'dot', text: 'awaiting inputs' });
|
|
183
|
+
emitStatus('idle');
|
|
171
184
|
}
|
|
172
185
|
RED.nodes.registerType('temperature controller', TemperatureControllerNode);
|
|
173
186
|
};
|