node-red-contrib-freya-nodes 0.1.1 → 0.2.1
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/circadian-core/circadian-core-node.js +14 -35
- package/nodes/lighting-controller/lighting-controller-node.html +106 -4
- package/nodes/lighting-controller/lighting-controller-node.js +98 -13
- package/nodes/precipitation-controller/precipitation-controller-node.html +80 -7
- package/nodes/precipitation-controller/precipitation-controller-node.js +88 -12
- package/package.json +1 -1
|
@@ -15,16 +15,6 @@ class AstronomicalCalculator {
|
|
|
15
15
|
const dayAngle = 2 * Math.PI * (dayOfYear - 81) / orbitalPeriod;
|
|
16
16
|
return this.degreesToRadians(axialTilt) * Math.sin(dayAngle);
|
|
17
17
|
}
|
|
18
|
-
static getDayLength(latitude, solarDeclination) {
|
|
19
|
-
const latRad = this.degreesToRadians(latitude);
|
|
20
|
-
const cosHourAngle = -Math.tan(latRad) * Math.tan(solarDeclination);
|
|
21
|
-
if (cosHourAngle > 1)
|
|
22
|
-
return 0;
|
|
23
|
-
if (cosHourAngle < -1)
|
|
24
|
-
return 24;
|
|
25
|
-
const hourAngle = Math.acos(cosHourAngle);
|
|
26
|
-
return 2 * this.radiansToDegrees(hourAngle) / 15;
|
|
27
|
-
}
|
|
28
18
|
static getSolarNoonOffset(longitude, timezone) {
|
|
29
19
|
const tzMatch = timezone.match(/UTC([+-]?\d+(?:\.\d+)?)/i);
|
|
30
20
|
const tzOffset = tzMatch ? parseFloat(tzMatch[1]) : 0;
|
|
@@ -42,7 +32,6 @@ const circadianCore = (RED) => {
|
|
|
42
32
|
node.status({ fill: 'red', shape: 'dot', text: 'no location config' });
|
|
43
33
|
node.error('Location configuration node not found');
|
|
44
34
|
}
|
|
45
|
-
this.locationConfig = RED.nodes.getNode(config.locationConfig);
|
|
46
35
|
this.phaseShift = parseFloat(config.phaseShift) || 0.0;
|
|
47
36
|
this.diurnalSwing = parseFloat(config.diurnalSwing) || 1.0;
|
|
48
37
|
this.seasonalSwing = parseFloat(config.seasonalSwing) || 1.0;
|
|
@@ -58,19 +47,12 @@ const circadianCore = (RED) => {
|
|
|
58
47
|
};
|
|
59
48
|
const getSeasonalFactor = (simulatedTime) => {
|
|
60
49
|
const dayOfYear = AstronomicalCalculator.getDayOfYear(simulatedTime);
|
|
61
|
-
const solarDeclination = AstronomicalCalculator.getSolarDeclination(dayOfYear, this.locationConfig.orbitalPeriod, this.locationConfig.axialTilt);
|
|
62
|
-
const latRad = AstronomicalCalculator.degreesToRadians(this.locationConfig.latitude);
|
|
63
|
-
const seasonalAmplitude = Math.abs(Math.sin(latRad)) * Math.sin(AstronomicalCalculator.degreesToRadians(this.locationConfig.axialTilt));
|
|
64
50
|
const seasonalAngle = 2 * Math.PI * (dayOfYear - 172) / this.locationConfig.orbitalPeriod;
|
|
65
|
-
|
|
66
|
-
return 0.5 + seasonalValue * 0.5;
|
|
51
|
+
return Math.cos(seasonalAngle);
|
|
67
52
|
};
|
|
68
53
|
const getDiurnalFactor = (simulatedTime) => {
|
|
69
|
-
const dayOfYear = AstronomicalCalculator.getDayOfYear(simulatedTime);
|
|
70
|
-
const solarDeclination = AstronomicalCalculator.getSolarDeclination(dayOfYear, this.locationConfig.orbitalPeriod, this.locationConfig.axialTilt);
|
|
71
|
-
const dayLength = AstronomicalCalculator.getDayLength(this.locationConfig.latitude, solarDeclination);
|
|
72
54
|
const solarNoonOffset = AstronomicalCalculator.getSolarNoonOffset(this.locationConfig.longitude, this.locationConfig.timezone);
|
|
73
|
-
const hoursFromMidnight = simulatedTime.getUTCHours() + simulatedTime.getUTCMinutes() / 60;
|
|
55
|
+
const hoursFromMidnight = simulatedTime.getUTCHours() + simulatedTime.getUTCMinutes() / 60 + simulatedTime.getUTCSeconds() / 3600;
|
|
74
56
|
const solarNoon = 12 + solarNoonOffset;
|
|
75
57
|
let hoursFromSolarNoon = hoursFromMidnight - solarNoon;
|
|
76
58
|
if (hoursFromSolarNoon > 12)
|
|
@@ -79,13 +61,7 @@ const circadianCore = (RED) => {
|
|
|
79
61
|
hoursFromSolarNoon += 24;
|
|
80
62
|
const phaseShiftRad = AstronomicalCalculator.degreesToRadians(this.phaseShift || 0);
|
|
81
63
|
const diurnalAngle = (2 * Math.PI * hoursFromSolarNoon / this.locationConfig.rotationalPeriod) + phaseShiftRad;
|
|
82
|
-
|
|
83
|
-
const maxDiurnalHours = dayLength / 2;
|
|
84
|
-
if (Math.abs(hoursFromSolarNoon) > maxDiurnalHours) {
|
|
85
|
-
return 0;
|
|
86
|
-
}
|
|
87
|
-
const diurnalValue = Math.cos(diurnalAngle);
|
|
88
|
-
return Math.max(0, diurnalValue) * dayFraction;
|
|
64
|
+
return Math.cos(diurnalAngle);
|
|
89
65
|
};
|
|
90
66
|
const calculateTarget = () => {
|
|
91
67
|
if (state.absoluteMin === undefined || state.absoluteMax === undefined) {
|
|
@@ -94,13 +70,15 @@ const circadianCore = (RED) => {
|
|
|
94
70
|
const simulatedTime = getSimulatedTime();
|
|
95
71
|
const seasonalFactor = getSeasonalFactor(simulatedTime);
|
|
96
72
|
const diurnalFactor = getDiurnalFactor(simulatedTime);
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const totalSwing =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
73
|
+
const sSwing = this.seasonalSwing || 1.0;
|
|
74
|
+
const dSwing = this.diurnalSwing || 1.0;
|
|
75
|
+
const totalSwing = sSwing + dSwing;
|
|
76
|
+
if (totalSwing === 0) {
|
|
77
|
+
return state.absoluteMin + (state.absoluteMax - state.absoluteMin) * 0.5;
|
|
78
|
+
}
|
|
79
|
+
const combined = (seasonalFactor * sSwing + diurnalFactor * dSwing) / totalSwing;
|
|
80
|
+
const factor = (combined + 1) / 2;
|
|
81
|
+
return state.absoluteMin + (state.absoluteMax - state.absoluteMin) * factor;
|
|
104
82
|
};
|
|
105
83
|
const emitTarget = () => {
|
|
106
84
|
const target = calculateTarget();
|
|
@@ -122,7 +100,7 @@ const circadianCore = (RED) => {
|
|
|
122
100
|
if (state.intervalId) {
|
|
123
101
|
clearInterval(state.intervalId);
|
|
124
102
|
}
|
|
125
|
-
const intervalMs = (
|
|
103
|
+
const intervalMs = (this.tickInterval || 60) * 1000;
|
|
126
104
|
state.intervalId = setInterval(() => {
|
|
127
105
|
if (state.absoluteMin !== undefined && state.absoluteMax !== undefined) {
|
|
128
106
|
emitTarget();
|
|
@@ -168,6 +146,7 @@ const circadianCore = (RED) => {
|
|
|
168
146
|
}
|
|
169
147
|
});
|
|
170
148
|
node.status({ fill: 'yellow', shape: 'dot', text: 'awaiting setpoints' });
|
|
149
|
+
startTickInterval();
|
|
171
150
|
}
|
|
172
151
|
RED.nodes.registerType('circadian core', CircadianCoreNode);
|
|
173
152
|
};
|
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
category: 'Freya Vivariums',
|
|
21
21
|
color: "#A2CA6F",
|
|
22
22
|
defaults: {
|
|
23
|
-
name: {value:""}
|
|
23
|
+
name: {value:""},
|
|
24
|
+
gain: {value: 1.0, required: true, validate: RED.validators.number()},
|
|
25
|
+
bottomCutOff: {value: 50.0, required: true, validate: RED.validators.number()},
|
|
26
|
+
mode: {value: "analog"},
|
|
27
|
+
scheduleTime1: {value: ""},
|
|
28
|
+
scheduleTime2: {value: ""},
|
|
29
|
+
scheduleMode: {value: "allowed"}
|
|
24
30
|
},
|
|
25
31
|
inputs:1,
|
|
26
32
|
outputs:2,
|
|
@@ -34,11 +40,107 @@
|
|
|
34
40
|
|
|
35
41
|
<script type="text/html" data-template-name="lighting controller">
|
|
36
42
|
<div class="form-row">
|
|
37
|
-
<label for="node-input-name"><i class="
|
|
38
|
-
<input type="text" id="node-input-name" placeholder="
|
|
43
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
44
|
+
<input type="text" id="node-input-name" placeholder="Lighting Controller">
|
|
45
|
+
</div>
|
|
46
|
+
<hr/>
|
|
47
|
+
|
|
48
|
+
<div class="form-row">
|
|
49
|
+
<label for="node-input-bottomCutOff"><i class="fa fa-scissors"></i> Bottom Cut-off</label>
|
|
50
|
+
<input type="number" id="node-input-bottomCutOff" placeholder="50.0" step="1" min="0" max="100">
|
|
51
|
+
<i class="fa fa-info-circle" title="Values below this percentage are cut off to 0%. This acts as a 'single diode rectifier + schotkey'."></i>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="form-row">
|
|
55
|
+
<label for="node-input-gain"><i class="fa fa-line-chart"></i> Gain</label>
|
|
56
|
+
<input type="number" id="node-input-gain" placeholder="1.0" step="0.1" min="0.1">
|
|
57
|
+
<i class="fa fa-info-circle" title="Multiplier applied to input after bottom cut-off. Values above 100% are clipped to 100%."></i>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="form-row">
|
|
61
|
+
<label for="node-input-mode"><i class="fa fa-sliders"></i> Output Mode</label>
|
|
62
|
+
<select id="node-input-mode">
|
|
63
|
+
<option value="analog">Analog - 0.0-100.0%</option>
|
|
64
|
+
<option value="digital">Digital - ON/OFF</option>
|
|
65
|
+
</select>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<hr/>
|
|
69
|
+
|
|
70
|
+
<div class="form-row">
|
|
71
|
+
<h3><i class="fa fa-clock-o"></i> Schedule (Optional)</h3>
|
|
72
|
+
<p style="font-size: 12px; color: #666; margin: 5px 0;">
|
|
73
|
+
Restrict lighting to specific time windows
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="form-row">
|
|
78
|
+
<label for="node-input-scheduleTime1">Start Time</label>
|
|
79
|
+
<input type="text" id="node-input-scheduleTime1" placeholder="HH:MM" pattern="[0-2][0-9]:[0-5][0-9]">
|
|
80
|
+
<i class="fa fa-info-circle" title="Start of allowed time window (24-hour format, e.g. 06:30)"></i>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="form-row">
|
|
84
|
+
<label for="node-input-scheduleTime2">End Time</label>
|
|
85
|
+
<input type="text" id="node-input-scheduleTime2" placeholder="HH:MM" pattern="[0-2][0-9]:[0-5][0-9]">
|
|
86
|
+
<i class="fa fa-info-circle" title="End of allowed time window (24-hour format, e.g. 22:00)"></i>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div class="form-row">
|
|
90
|
+
<label for="node-input-scheduleMode">Schedule Mode</label>
|
|
91
|
+
<select id="node-input-scheduleMode">
|
|
92
|
+
<option value="allowed">Allowed - Force OFF outside window</option>
|
|
93
|
+
<option value="guaranteed">Guaranteed - Force ON inside window</option>
|
|
94
|
+
</select>
|
|
39
95
|
</div>
|
|
40
96
|
</script>
|
|
41
97
|
|
|
42
98
|
<script type="text/html" data-help-name="lighting controller">
|
|
43
|
-
<p>
|
|
99
|
+
<p>
|
|
100
|
+
The <strong>Lighting Controller</strong> takes a raw brightness value from the circadian core,
|
|
101
|
+
applies a bottom cut-off, applies gain, clips to 0–100%, and optionally enforces a time-based schedule.
|
|
102
|
+
</p>
|
|
103
|
+
|
|
104
|
+
<h3>Configuration</h3>
|
|
105
|
+
<ul>
|
|
106
|
+
<li><strong>Bottom Cut-off:</strong> Values below this percentage are cut off to 0%. This acts as a "single diode rectifier + schotkey".</li>
|
|
107
|
+
<li><strong>Gain:</strong> Multiplier applied to input after bottom cut-off. Values above 100% are clipped to 100%.</li>
|
|
108
|
+
<li><strong>Output Mode:</strong>
|
|
109
|
+
<ul>
|
|
110
|
+
<li><strong>Analog:</strong> Outputs { lighting: <float 0.0-100.0> } with 1 decimal place</li>
|
|
111
|
+
<li><strong>Digital:</strong> Outputs { lighting: "on" } if value > 0, else { lighting: "off" }</li>
|
|
112
|
+
</ul>
|
|
113
|
+
</li>
|
|
114
|
+
<li><strong>Schedule (Optional):</strong> Restrict lighting to specific time windows
|
|
115
|
+
<ul>
|
|
116
|
+
<li><strong>Start/End Time:</strong> Time window in 24-hour format (HH:MM)</li>
|
|
117
|
+
<li><strong>Allowed Mode:</strong> Forces light OFF outside the time window</li>
|
|
118
|
+
<li><strong>Guaranteed Mode:</strong> Forces light ON inside the time window</li>
|
|
119
|
+
</ul>
|
|
120
|
+
</li>
|
|
121
|
+
</ul>
|
|
122
|
+
|
|
123
|
+
<h3>Signal Chain</h3>
|
|
124
|
+
<ol>
|
|
125
|
+
<li>Apply bottom cut-off (values below cut-off become 0%)</li>
|
|
126
|
+
<li>Multiply input by gain</li>
|
|
127
|
+
<li>Clamp to 0–100% (values above 100% are clipped to 100%)</li>
|
|
128
|
+
<li>Apply schedule gate (if configured):
|
|
129
|
+
<ul>
|
|
130
|
+
<li>Allowed mode: Force 0% outside Time1–Time2 window</li>
|
|
131
|
+
<li>Guaranteed mode: Force 100% inside Time1–Time2 window</li>
|
|
132
|
+
</ul>
|
|
133
|
+
</li>
|
|
134
|
+
</ol>
|
|
135
|
+
|
|
136
|
+
<h3>Inputs</h3>
|
|
137
|
+
<ul>
|
|
138
|
+
<li><strong>Input 1:</strong> msg.payload (number) from circadian core</li>
|
|
139
|
+
</ul>
|
|
140
|
+
|
|
141
|
+
<h3>Outputs</h3>
|
|
142
|
+
<ul>
|
|
143
|
+
<li><strong>Output 1:</strong> Actuator command with formatted lighting value</li>
|
|
144
|
+
<li><strong>Output 2:</strong> Status message for the status aggregator</li>
|
|
145
|
+
</ul>
|
|
44
146
|
</script>
|
|
@@ -1,22 +1,107 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
-
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
-
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
-
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
-
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
-
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
-
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
-
});
|
|
10
|
-
};
|
|
11
2
|
const lightingController = (RED) => {
|
|
12
3
|
function LightingControllerNode(config) {
|
|
13
4
|
RED.nodes.createNode(this, config);
|
|
14
5
|
const node = this;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
this.gain = parseFloat(config.gain) || 1.0;
|
|
7
|
+
this.bottomCutOff = parseFloat(config.bottomCutOff) || 50.0;
|
|
8
|
+
this.mode = config.mode || 'analog';
|
|
9
|
+
this.scheduleTime1 = config.scheduleTime1 || '';
|
|
10
|
+
this.scheduleTime2 = config.scheduleTime2 || '';
|
|
11
|
+
this.scheduleMode = config.scheduleMode || 'allowed';
|
|
12
|
+
const isWithinSchedule = () => {
|
|
13
|
+
if (!this.scheduleTime1 || !this.scheduleTime2) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const now = new Date();
|
|
18
|
+
const currentTime = now.getHours() * 60 + now.getMinutes();
|
|
19
|
+
const [hours1, minutes1] = this.scheduleTime1.split(':').map(Number);
|
|
20
|
+
const [hours2, minutes2] = this.scheduleTime2.split(':').map(Number);
|
|
21
|
+
const time1 = hours1 * 60 + minutes1;
|
|
22
|
+
const time2 = hours2 * 60 + minutes2;
|
|
23
|
+
if (time1 <= time2) {
|
|
24
|
+
return currentTime >= time1 && currentTime <= time2;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
return currentTime >= time1 || currentTime <= time2;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
node.warn('Error parsing schedule times: ' + error);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const formatTime = (timeStr) => {
|
|
36
|
+
if (!timeStr)
|
|
37
|
+
return '';
|
|
38
|
+
return timeStr;
|
|
39
|
+
};
|
|
40
|
+
node.on('input', (msg, send, done) => {
|
|
41
|
+
if (typeof msg.payload !== 'number') {
|
|
42
|
+
node.status({ fill: 'red', shape: 'dot', text: 'invalid input' });
|
|
43
|
+
done === null || done === void 0 ? void 0 : done(new Error('Input payload must be a number'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let value = msg.payload;
|
|
47
|
+
if (value < (this.bottomCutOff || 50.0)) {
|
|
48
|
+
value = 0;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
value = ((value - (this.bottomCutOff || 50.0)) / (100 - (this.bottomCutOff || 50.0))) * 100;
|
|
52
|
+
}
|
|
53
|
+
value = value * (this.gain || 1.0);
|
|
54
|
+
value = Math.max(0, Math.min(100, value));
|
|
55
|
+
let scheduleActive = false;
|
|
56
|
+
let scheduleStatus = '';
|
|
57
|
+
if (this.scheduleTime1 && this.scheduleTime2) {
|
|
58
|
+
const withinSchedule = isWithinSchedule();
|
|
59
|
+
if (this.scheduleMode === 'allowed') {
|
|
60
|
+
if (!withinSchedule) {
|
|
61
|
+
value = 0;
|
|
62
|
+
scheduleActive = true;
|
|
63
|
+
scheduleStatus = ' (outside schedule)';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (this.scheduleMode === 'guaranteed') {
|
|
67
|
+
if (withinSchedule) {
|
|
68
|
+
value = 100;
|
|
69
|
+
scheduleActive = true;
|
|
70
|
+
scheduleStatus = ' (guaranteed)';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
let outputPayload;
|
|
75
|
+
let statusText;
|
|
76
|
+
const finalValue = Math.round(value * 10) / 10;
|
|
77
|
+
if (this.mode === 'digital') {
|
|
78
|
+
outputPayload = { lighting: value > 0 ? 'on' : 'off' };
|
|
79
|
+
if (value > 0) {
|
|
80
|
+
statusText = 'ON (' + finalValue.toFixed(1) + '%)' + scheduleStatus;
|
|
81
|
+
node.status({ fill: 'green', shape: 'dot', text: statusText });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
statusText = 'OFF (0%)' + scheduleStatus;
|
|
85
|
+
node.status({ fill: 'grey', shape: 'dot', text: statusText });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
outputPayload = { lighting: finalValue };
|
|
90
|
+
if (finalValue > 0) {
|
|
91
|
+
statusText = finalValue.toFixed(1) + '%' + scheduleStatus;
|
|
92
|
+
node.status({ fill: 'green', shape: 'dot', text: statusText });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
statusText = '0%' + scheduleStatus;
|
|
96
|
+
node.status({ fill: 'grey', shape: 'dot', text: statusText });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
send([
|
|
100
|
+
{ payload: outputPayload, topic: 'actuators' },
|
|
101
|
+
{ payload: Object.assign(Object.assign({}, outputPayload), { scheduleActive, gain: this.gain, bottomCutOff: this.bottomCutOff, mode: this.mode }), topic: 'status' }
|
|
102
|
+
]);
|
|
18
103
|
done === null || done === void 0 ? void 0 : done();
|
|
19
|
-
})
|
|
104
|
+
});
|
|
20
105
|
}
|
|
21
106
|
RED.nodes.registerType('lighting controller', LightingControllerNode);
|
|
22
107
|
};
|
|
@@ -20,12 +20,16 @@
|
|
|
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; }}
|
|
24
27
|
},
|
|
25
28
|
inputs:1,
|
|
26
|
-
outputs:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
outputs:1,
|
|
30
|
+
inputLabels:["parameter update"],
|
|
31
|
+
outputLabels:["actuator command"],
|
|
32
|
+
icon: "font-awesome/fa-tint",
|
|
29
33
|
label: function() {
|
|
30
34
|
return this.name || "Precipitation Controller";
|
|
31
35
|
}
|
|
@@ -34,11 +38,80 @@
|
|
|
34
38
|
|
|
35
39
|
<script type="text/html" data-template-name="precipitation controller">
|
|
36
40
|
<div class="form-row">
|
|
37
|
-
<label for="node-input-name"><i class="
|
|
38
|
-
<input type="text" id="node-input-name" placeholder="
|
|
41
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
42
|
+
<input type="text" id="node-input-name" placeholder="Precipitation Controller">
|
|
43
|
+
</div>
|
|
44
|
+
<hr/>
|
|
45
|
+
<div class="form-row">
|
|
46
|
+
<label for="node-input-interval"><i class="fa fa-clock-o"></i> Interval (minutes)</label>
|
|
47
|
+
<input type="number" id="node-input-interval" placeholder="60" step="1" min="1">
|
|
48
|
+
</div>
|
|
49
|
+
<div class="form-row">
|
|
50
|
+
<label for="node-input-duration"><i class="fa fa-tint"></i> Duration (seconds)</label>
|
|
51
|
+
<input type="number" id="node-input-duration" placeholder="30" step="1" min="1">
|
|
52
|
+
</div>
|
|
53
|
+
<hr/>
|
|
54
|
+
<div class="form-row">
|
|
55
|
+
<label for="node-input-tickInterval"><i class="fa fa-clock-o"></i> Tick Interval (seconds)</label>
|
|
56
|
+
<input type="number" id="node-input-tickInterval" placeholder="60" step="1" min="1">
|
|
39
57
|
</div>
|
|
40
58
|
</script>
|
|
41
59
|
|
|
42
60
|
<script type="text/html" data-help-name="precipitation controller">
|
|
43
|
-
<p>The
|
|
61
|
+
<p>The <b>Precipitation Controller</b> drives a pump relay on an interval/duration cycle
|
|
62
|
+
for vivarium misting or rain simulation.</p>
|
|
63
|
+
|
|
64
|
+
<h3>Input</h3>
|
|
65
|
+
<dl class="message-properties">
|
|
66
|
+
<dt>msg.topic = "interval" <span class="property-type">number</span></dt>
|
|
67
|
+
<dd><code>msg.payload</code> sets the precipitation interval in minutes, overriding the configured default</dd>
|
|
68
|
+
|
|
69
|
+
<dt>msg.topic = "duration" <span class="property-type">number</span></dt>
|
|
70
|
+
<dd><code>msg.payload</code> sets the pump duration in seconds, overriding the configured default</dd>
|
|
71
|
+
|
|
72
|
+
<dt>msg.topic = "night" <span class="property-type">boolean</span></dt>
|
|
73
|
+
<dd><code>msg.payload</code> signals nighttime (<code>true</code>) or daytime (<code>false</code>).
|
|
74
|
+
During nighttime no new precipitation events are started. If the pump is already active
|
|
75
|
+
when night begins, the current cycle finishes normally.</dd>
|
|
76
|
+
</dl>
|
|
77
|
+
<p>Messages with unrecognised topics are ignored.</p>
|
|
78
|
+
|
|
79
|
+
<h3>Output</h3>
|
|
80
|
+
<dl class="message-properties">
|
|
81
|
+
<dt>msg.payload <span class="property-type">object</span></dt>
|
|
82
|
+
<dd><code>{ pump: "on" }</code> when the pump turns on, <code>{ pump: "off" }</code> when it turns off.
|
|
83
|
+
<code>msg.topic</code> is set to <code>"actuators"</code>.
|
|
84
|
+
Two messages are sent per precipitation event.</dd>
|
|
85
|
+
</dl>
|
|
86
|
+
|
|
87
|
+
<h3>Details</h3>
|
|
88
|
+
<p>The node runs an autonomous internal tick timer. On each tick it evaluates whether
|
|
89
|
+
precipitation is due:</p>
|
|
90
|
+
<ul>
|
|
91
|
+
<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>
|
|
93
|
+
<li>If this is the first tick after deploy, or the configured interval has elapsed
|
|
94
|
+
since the last event, the pump is turned on for the configured duration and then
|
|
95
|
+
automatically turned off.</li>
|
|
96
|
+
<li>Otherwise the node updates its status to show the time remaining
|
|
97
|
+
until the next event.</li>
|
|
98
|
+
</ul>
|
|
99
|
+
<p>On redeploy or shutdown the node clears all timers and forces the pump off
|
|
100
|
+
to prevent it from getting stuck on.</p>
|
|
101
|
+
|
|
102
|
+
<h3>Configuration</h3>
|
|
103
|
+
<ul>
|
|
104
|
+
<li><strong>Interval:</strong> Time between precipitation events in minutes (default: 60)</li>
|
|
105
|
+
<li><strong>Duration:</strong> How long the pump runs per event in seconds (default: 30)</li>
|
|
106
|
+
<li><strong>Tick Interval:</strong> How often to evaluate whether precipitation is due in seconds (default: 60)</li>
|
|
107
|
+
</ul>
|
|
108
|
+
|
|
109
|
+
<h3>Status</h3>
|
|
110
|
+
<ul>
|
|
111
|
+
<li><strong>idle</strong> (grey/ring) — initial state before first tick</li>
|
|
112
|
+
<li><strong>pump on</strong> (blue/dot) — pump is running, shows duration</li>
|
|
113
|
+
<li><strong>next in …</strong> (green/ring) — waiting, shows time until next event</li>
|
|
114
|
+
<li><strong>last: HH:MM:SS</strong> (grey/ring) — after pump off, shows timestamp</li>
|
|
115
|
+
<li><strong>paused (night)</strong> (grey/ring) — precipitation disabled during nighttime</li>
|
|
116
|
+
</ul>
|
|
44
117
|
</script>
|
|
@@ -1,22 +1,98 @@
|
|
|
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
|
+
const state = {
|
|
21
|
+
lastPrecipitation: null,
|
|
22
|
+
pumpActive: false,
|
|
23
|
+
intervalMs: (node.interval || 60) * 60000,
|
|
24
|
+
durationMs: (node.duration || 30) * 1000,
|
|
25
|
+
night: false
|
|
26
|
+
};
|
|
27
|
+
const evaluatePrecipitation = () => {
|
|
28
|
+
if (state.pumpActive) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (state.night) {
|
|
32
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'paused (night)' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
if (state.lastPrecipitation === null || (now - state.lastPrecipitation) >= state.intervalMs) {
|
|
37
|
+
state.pumpActive = true;
|
|
38
|
+
state.lastPrecipitation = now;
|
|
39
|
+
node.send({ payload: { pump: 'on' }, topic: 'actuators' });
|
|
40
|
+
node.status({ fill: 'blue', shape: 'dot', text: `pump on (${formatDuration(state.durationMs)})` });
|
|
41
|
+
state.offTimer = setTimeout(() => {
|
|
42
|
+
state.pumpActive = false;
|
|
43
|
+
state.offTimer = undefined;
|
|
44
|
+
node.send({ payload: { pump: 'off' }, topic: 'actuators' });
|
|
45
|
+
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false });
|
|
46
|
+
node.status({ fill: 'grey', shape: 'ring', text: `last: ${timeStr}` });
|
|
47
|
+
}, state.durationMs);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const elapsed = now - state.lastPrecipitation;
|
|
51
|
+
const remaining = state.intervalMs - elapsed;
|
|
52
|
+
node.status({ fill: 'green', shape: 'ring', text: `next in ${formatDuration(remaining)}` });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const startTickInterval = () => {
|
|
56
|
+
if (state.tickTimerId) {
|
|
57
|
+
clearInterval(state.tickTimerId);
|
|
58
|
+
}
|
|
59
|
+
const intervalMs = (node.tickInterval || 60) * 1000;
|
|
60
|
+
state.tickTimerId = setInterval(() => {
|
|
61
|
+
evaluatePrecipitation();
|
|
62
|
+
}, intervalMs);
|
|
63
|
+
};
|
|
64
|
+
node.on('input', (msg, send, done) => {
|
|
65
|
+
if (msg.topic === 'interval' && typeof msg.payload === 'number' && isFinite(msg.payload)) {
|
|
66
|
+
state.intervalMs = msg.payload * 60000;
|
|
67
|
+
node.log(`Interval updated: ${msg.payload} minutes`);
|
|
68
|
+
}
|
|
69
|
+
else if (msg.topic === 'duration' && typeof msg.payload === 'number' && isFinite(msg.payload)) {
|
|
70
|
+
state.durationMs = msg.payload * 1000;
|
|
71
|
+
node.log(`Duration updated: ${msg.payload} seconds`);
|
|
72
|
+
}
|
|
73
|
+
else if (msg.topic === 'night' && typeof msg.payload === 'boolean') {
|
|
74
|
+
state.night = msg.payload;
|
|
75
|
+
node.log(`Night state updated: ${msg.payload}`);
|
|
76
|
+
}
|
|
18
77
|
done === null || done === void 0 ? void 0 : done();
|
|
19
|
-
})
|
|
78
|
+
});
|
|
79
|
+
node.on('close', (done) => {
|
|
80
|
+
if (state.tickTimerId) {
|
|
81
|
+
clearInterval(state.tickTimerId);
|
|
82
|
+
state.tickTimerId = undefined;
|
|
83
|
+
}
|
|
84
|
+
if (state.offTimer) {
|
|
85
|
+
clearTimeout(state.offTimer);
|
|
86
|
+
state.offTimer = undefined;
|
|
87
|
+
}
|
|
88
|
+
if (state.pumpActive) {
|
|
89
|
+
state.pumpActive = false;
|
|
90
|
+
node.send({ payload: { pump: 'off' }, topic: 'actuators' });
|
|
91
|
+
}
|
|
92
|
+
done();
|
|
93
|
+
});
|
|
94
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'idle' });
|
|
95
|
+
startTickInterval();
|
|
20
96
|
}
|
|
21
97
|
RED.nodes.registerType('precipitation controller', PrecipitationControllerNode);
|
|
22
98
|
};
|