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.
@@ -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
- const seasonalValue = Math.cos(seasonalAngle) * seasonalAmplitude;
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
- const dayFraction = dayLength / 24;
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 seasonalContribution = seasonalFactor * (this.seasonalSwing || 1.0);
98
- const diurnalContribution = diurnalFactor * (this.diurnalSwing || 1.0);
99
- const totalSwing = (this.seasonalSwing || 1.0) + (this.diurnalSwing || 1.0);
100
- const combinedFactor = totalSwing > 0
101
- ? Math.max(0, Math.min(1, (seasonalContribution + diurnalContribution) / totalSwing))
102
- : 0.5;
103
- return state.absoluteMin + (state.absoluteMax - state.absoluteMin) * combinedFactor;
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 = (node.tickInterval || 60) * 1000;
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="icon-tag"></i> Name</label>
38
- <input type="text" id="node-input-name" placeholder="Name">
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>The lighting controller lorem ipsum ...</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: &lt;float 0.0-100.0&gt; } 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
- node.status({ fill: 'green', shape: 'dot', text: 'running' });
16
- node.on('input', (msg, send, done) => __awaiter(this, void 0, void 0, function* () {
17
- send(msg);
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:2,
27
- outputLabels:["control", "status"],
28
- icon: "font-awesome/fa-cloud",
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="icon-tag"></i> Name</label>
38
- <input type="text" id="node-input-name" placeholder="Name">
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 precipitation controller lorem ipsum ...</p>
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
- 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
- });
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
- node.status({ fill: 'green', shape: 'dot', text: 'running' });
16
- node.on('input', (msg, send, done) => __awaiter(this, void 0, void 0, function* () {
17
- send(msg);
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-freya-nodes",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Custom nodes for Freya Vivarium Control System",
5
5
  "author": "Sanne 'SpuQ' Santens",
6
6
  "license": "GPL-3.0",