node-red-contrib-freya-nodes 0.2.0 → 0.2.2
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 +106 -6
- package/nodes/precipitation-controller/precipitation-controller-node.js +119 -12
- 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
|
});
|
|
@@ -20,12 +20,17 @@
|
|
|
20
20
|
category: 'Freya Vivariums',
|
|
21
21
|
color: "#A2CA6F",
|
|
22
22
|
defaults: {
|
|
23
|
-
name: {value:""}
|
|
23
|
+
name: {value:""},
|
|
24
|
+
interval: {value: 60, required: true, validate: RED.validators.number()},
|
|
25
|
+
duration: {value: 30, required: true, validate: RED.validators.number()},
|
|
26
|
+
tickInterval: {value: 60, required: true, validate: function(v) { return RED.validators.number()(v) && parseInt(v) >= 1; }},
|
|
27
|
+
nightDisable: {value: false}
|
|
24
28
|
},
|
|
25
29
|
inputs:1,
|
|
26
30
|
outputs:2,
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
inputLabels:["parameter update"],
|
|
32
|
+
outputLabels:["control (actuators)", "status"],
|
|
33
|
+
icon: "font-awesome/fa-tint",
|
|
29
34
|
label: function() {
|
|
30
35
|
return this.name || "Precipitation Controller";
|
|
31
36
|
}
|
|
@@ -34,11 +39,106 @@
|
|
|
34
39
|
|
|
35
40
|
<script type="text/html" data-template-name="precipitation controller">
|
|
36
41
|
<div class="form-row">
|
|
37
|
-
<label for="node-input-name"><i class="
|
|
38
|
-
<input type="text" id="node-input-name" placeholder="
|
|
42
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
43
|
+
<input type="text" id="node-input-name" placeholder="Precipitation Controller">
|
|
44
|
+
</div>
|
|
45
|
+
<hr/>
|
|
46
|
+
<div class="form-row">
|
|
47
|
+
<label for="node-input-interval"><i class="fa fa-clock-o"></i> Interval (minutes)</label>
|
|
48
|
+
<input type="number" id="node-input-interval" placeholder="60" step="1" min="1">
|
|
49
|
+
</div>
|
|
50
|
+
<div class="form-row">
|
|
51
|
+
<label for="node-input-duration"><i class="fa fa-tint"></i> Duration (seconds)</label>
|
|
52
|
+
<input type="number" id="node-input-duration" placeholder="30" step="1" min="1">
|
|
53
|
+
</div>
|
|
54
|
+
<hr/>
|
|
55
|
+
<div class="form-row">
|
|
56
|
+
<label for="node-input-tickInterval"><i class="fa fa-clock-o"></i> Tick Interval (seconds)</label>
|
|
57
|
+
<input type="number" id="node-input-tickInterval" placeholder="60" step="1" min="1">
|
|
58
|
+
</div>
|
|
59
|
+
<hr/>
|
|
60
|
+
<div class="form-row">
|
|
61
|
+
<input type="checkbox" id="node-input-nightDisable" style="display:inline-block; width:auto; vertical-align:top;">
|
|
62
|
+
<label for="node-input-nightDisable" style="width:auto;"><i class="fa fa-moon-o"></i> Disable precipitation at night</label>
|
|
39
63
|
</div>
|
|
40
64
|
</script>
|
|
41
65
|
|
|
42
66
|
<script type="text/html" data-help-name="precipitation controller">
|
|
43
|
-
<p>The
|
|
67
|
+
<p>The <b>Precipitation Controller</b> drives a pump relay on an interval/duration cycle
|
|
68
|
+
for vivarium misting or rain simulation.</p>
|
|
69
|
+
|
|
70
|
+
<h3>Input</h3>
|
|
71
|
+
<dl class="message-properties">
|
|
72
|
+
<dt>msg.topic = "interval" <span class="property-type">number</span></dt>
|
|
73
|
+
<dd><code>msg.payload</code> sets the precipitation interval in milliseconds, overriding the configured default</dd>
|
|
74
|
+
|
|
75
|
+
<dt>msg.topic = "duration" <span class="property-type">number</span></dt>
|
|
76
|
+
<dd><code>msg.payload</code> sets the pump duration in milliseconds, overriding the configured default</dd>
|
|
77
|
+
|
|
78
|
+
<dt>msg.topic = "night" <span class="property-type">boolean</span></dt>
|
|
79
|
+
<dd><code>msg.payload</code> signals nighttime (<code>true</code>) or daytime (<code>false</code>).
|
|
80
|
+
Only effective when the nighttime disable toggle is enabled in the editor.
|
|
81
|
+
During nighttime no new precipitation events are started. If the pump is already active
|
|
82
|
+
when night begins, the current cycle finishes normally.</dd>
|
|
83
|
+
</dl>
|
|
84
|
+
<p>Messages with unrecognised topics are ignored.</p>
|
|
85
|
+
|
|
86
|
+
<h3>Outputs</h3>
|
|
87
|
+
<dl class="message-properties">
|
|
88
|
+
<dt>Output 1: Control <span class="property-type">object</span></dt>
|
|
89
|
+
<dd><code>msg.payload</code> is <code>{ pump: "on" }</code> or <code>{ pump: "off" }</code>
|
|
90
|
+
with <code>msg.topic = "actuators"</code>. Two messages are sent per precipitation event.</dd>
|
|
91
|
+
|
|
92
|
+
<dt>Output 2: Status <span class="property-type">object</span></dt>
|
|
93
|
+
<dd><code>msg.payload</code> contains <code>state</code>, <code>timestamp</code>, and optional
|
|
94
|
+
context fields. <code>msg.topic = "status"</code>. Emitted only on state changes.</dd>
|
|
95
|
+
</dl>
|
|
96
|
+
|
|
97
|
+
<h3>Details</h3>
|
|
98
|
+
<p>The node runs an autonomous internal tick timer. On each tick it evaluates whether
|
|
99
|
+
precipitation is due:</p>
|
|
100
|
+
<ul>
|
|
101
|
+
<li>If the pump is already running the tick is skipped (no overlapping cycles).</li>
|
|
102
|
+
<li>If nighttime disable is enabled and night is signalled, the tick is skipped.</li>
|
|
103
|
+
<li>If this is the first tick after deploy, or the configured interval has elapsed
|
|
104
|
+
since the last event, the pump is turned on for the configured duration and then
|
|
105
|
+
automatically turned off.</li>
|
|
106
|
+
<li>Otherwise the node updates its status to show the time remaining
|
|
107
|
+
until the next event.</li>
|
|
108
|
+
</ul>
|
|
109
|
+
<p>On redeploy or shutdown the node clears all timers and forces the pump off
|
|
110
|
+
to prevent it from getting stuck on.</p>
|
|
111
|
+
|
|
112
|
+
<h3>Nighttime Disable</h3>
|
|
113
|
+
<p>When the nighttime disable toggle is enabled, the node tracks a night/day state
|
|
114
|
+
updated via <code>msg.topic = "night"</code>. During nighttime no new precipitation events
|
|
115
|
+
are started. If the pump is already active when night begins, the current cycle is
|
|
116
|
+
allowed to finish normally. When the toggle is off, night messages are ignored and
|
|
117
|
+
precipitation runs 24/7.</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
|
+
|
|
128
|
+
<h3>Configuration</h3>
|
|
129
|
+
<ul>
|
|
130
|
+
<li><strong>Interval:</strong> Time between precipitation events in minutes (default: 60)</li>
|
|
131
|
+
<li><strong>Duration:</strong> How long the pump runs per event in seconds (default: 30)</li>
|
|
132
|
+
<li><strong>Tick Interval:</strong> How often to evaluate whether precipitation is due in seconds (default: 60)</li>
|
|
133
|
+
<li><strong>Disable precipitation at night:</strong> When enabled, precipitation is paused during nighttime</li>
|
|
134
|
+
</ul>
|
|
135
|
+
|
|
136
|
+
<h3>Node Status</h3>
|
|
137
|
+
<ul>
|
|
138
|
+
<li><strong>idle</strong> (grey/ring) — initial state before first tick</li>
|
|
139
|
+
<li><strong>pump on</strong> (blue/dot) — pump is running, shows duration</li>
|
|
140
|
+
<li><strong>next in …</strong> (green/ring) — waiting, shows time until next event</li>
|
|
141
|
+
<li><strong>last: HH:MM:SS</strong> (grey/ring) — after pump off, shows timestamp</li>
|
|
142
|
+
<li><strong>paused (night)</strong> (grey/ring) — precipitation disabled during nighttime</li>
|
|
143
|
+
</ul>
|
|
44
144
|
</script>
|
|
@@ -1,22 +1,129 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
const formatDuration = (ms) => {
|
|
3
|
+
if (ms >= 3600000) {
|
|
4
|
+
return (ms / 3600000).toFixed(1) + 'h';
|
|
5
|
+
}
|
|
6
|
+
else if (ms >= 60000) {
|
|
7
|
+
return (ms / 60000).toFixed(1) + 'm';
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
return (ms / 1000).toFixed(0) + 's';
|
|
11
|
+
}
|
|
10
12
|
};
|
|
11
13
|
const precipitationController = (RED) => {
|
|
12
14
|
function PrecipitationControllerNode(config) {
|
|
13
15
|
RED.nodes.createNode(this, config);
|
|
14
16
|
const node = this;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
this.interval = parseFloat(config.interval) || 60;
|
|
18
|
+
this.duration = parseFloat(config.duration) || 30;
|
|
19
|
+
this.tickInterval = parseInt(config.tickInterval) || 60;
|
|
20
|
+
this.nightDisable = config.nightDisable || false;
|
|
21
|
+
const state = {
|
|
22
|
+
lastPrecipitation: null,
|
|
23
|
+
pumpActive: false,
|
|
24
|
+
intervalMs: (node.interval || 60) * 60000,
|
|
25
|
+
durationMs: (node.duration || 30) * 1000,
|
|
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' }]);
|
|
42
|
+
};
|
|
43
|
+
const evaluatePrecipitation = () => {
|
|
44
|
+
if (state.pumpActive) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (node.nightDisable && state.night) {
|
|
48
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'paused (night)' });
|
|
49
|
+
emitStatus('paused');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
if (state.lastPrecipitation === null || (now - state.lastPrecipitation) >= state.intervalMs) {
|
|
54
|
+
state.pumpActive = true;
|
|
55
|
+
state.lastPrecipitation = now;
|
|
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
|
+
]);
|
|
62
|
+
state.offTimer = setTimeout(() => {
|
|
63
|
+
state.pumpActive = false;
|
|
64
|
+
state.offTimer = undefined;
|
|
65
|
+
const offNow = Date.now();
|
|
66
|
+
const timeStr = new Date(offNow).toLocaleTimeString('en-GB', { hour12: false });
|
|
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
|
+
]);
|
|
74
|
+
}, state.durationMs);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const elapsed = now - state.lastPrecipitation;
|
|
78
|
+
const remaining = state.intervalMs - elapsed;
|
|
79
|
+
node.status({ fill: 'green', shape: 'ring', text: `next in ${formatDuration(remaining)}` });
|
|
80
|
+
emitStatus('waiting', { remaining });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const startTickInterval = () => {
|
|
84
|
+
if (state.tickTimerId) {
|
|
85
|
+
clearInterval(state.tickTimerId);
|
|
86
|
+
}
|
|
87
|
+
const intervalMs = (node.tickInterval || 60) * 1000;
|
|
88
|
+
state.tickTimerId = setInterval(() => {
|
|
89
|
+
evaluatePrecipitation();
|
|
90
|
+
}, intervalMs);
|
|
91
|
+
};
|
|
92
|
+
node.on('input', (msg, send, done) => {
|
|
93
|
+
if (msg.topic === 'interval' && typeof msg.payload === 'number' && isFinite(msg.payload)) {
|
|
94
|
+
state.intervalMs = msg.payload;
|
|
95
|
+
node.log(`Interval updated: ${msg.payload}ms`);
|
|
96
|
+
}
|
|
97
|
+
else if (msg.topic === 'duration' && typeof msg.payload === 'number' && isFinite(msg.payload)) {
|
|
98
|
+
state.durationMs = msg.payload;
|
|
99
|
+
node.log(`Duration updated: ${msg.payload}ms`);
|
|
100
|
+
}
|
|
101
|
+
else if (msg.topic === 'night' && typeof msg.payload === 'boolean') {
|
|
102
|
+
if (node.nightDisable) {
|
|
103
|
+
state.night = msg.payload;
|
|
104
|
+
node.log(`Night state updated: ${msg.payload}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
18
107
|
done === null || done === void 0 ? void 0 : done();
|
|
19
|
-
})
|
|
108
|
+
});
|
|
109
|
+
node.on('close', (done) => {
|
|
110
|
+
if (state.tickTimerId) {
|
|
111
|
+
clearInterval(state.tickTimerId);
|
|
112
|
+
state.tickTimerId = undefined;
|
|
113
|
+
}
|
|
114
|
+
if (state.offTimer) {
|
|
115
|
+
clearTimeout(state.offTimer);
|
|
116
|
+
state.offTimer = undefined;
|
|
117
|
+
}
|
|
118
|
+
if (state.pumpActive) {
|
|
119
|
+
state.pumpActive = false;
|
|
120
|
+
node.send([{ payload: { pump: 'off' }, topic: 'actuators' }, null]);
|
|
121
|
+
}
|
|
122
|
+
done();
|
|
123
|
+
});
|
|
124
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'idle' });
|
|
125
|
+
emitStatus('idle');
|
|
126
|
+
startTickInterval();
|
|
20
127
|
}
|
|
21
128
|
RED.nodes.registerType('precipitation controller', PrecipitationControllerNode);
|
|
22
129
|
};
|
|
@@ -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
|
};
|