aquarium_control/mineral/mineral_injection.rs
1/* Copyright 2024 Uwe Martin
2
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
5The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
7THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8*/
9
10//! Implements the physical actuation of a single mineral dosing pump.
11//!
12//! This module provides the `MineralInjectionTrait` and its concrete implementation,
13//! `MineralInjection`. Its sole responsibility is to translate a command to dose a
14//! specific mineral into the physical actions of switching a peristaltic pump on and
15//! off for a precise duration. It acts as the final link between the logical `Balling`
16//! controller and the `RelayManager` that controls the hardware.
17//!
18//! ## Key Components
19//!
20//! - **`MineralInjectionTrait`**: An abstraction for the mineral injection process.
21//! This is a critical design element that decouples the `Balling` controller from the
22//! concrete implementation, enabling the use of mock injectors for unit testing.
23//!
24//! - **`MineralInjection` Struct**: The primary, stateless implementation of the trait.
25//!
26//! - **`inject_mineral()` Method**: The main entry point. It orchestrates the sequence of:
27//! 1. Sending a `SwitchOn` command to the `RelayManager`.
28//! 2. Entering a timed loop that keeps the pump running.
29//! 3. Continuously checking for a `Quit` command from the `SignalHandler`.
30//! 4. Sending a `SwitchOff` command upon completion or interruption.
31//!
32//! ## Design and Architecture
33//!
34//! The module is designed for safe, robust, and interruptible operation.
35//!
36//! - **Interruptible Operation**: The waiting period is not a simple `sleep` call. It's
37//! an active `loop` using a `SpinSleeper` that frequently checks for external `Quit`
38//! commands. This ensures that a dosing operation can be aborted almost instantly,
39//! which is a crucial safety and responsiveness feature.
40//!
41//! - **Guaranteed Cleanup**: The `SwitchOff` command is always sent to the `RelayManager`
42//! at the end of the function, regardless of whether the dosing completed normally or
43//! was interrupted. This prevents a pump from being left in the "on" state indefinitely.
44//!
45//! - **Trait-based Decoupling**: By depending on the `MineralInjectionTrait`, the main
46//! `Balling` module can be tested in isolation without needing real hardware or a
47//! running `RelayManager` thread. The tests in this module use a mock version of `RelayManager`.
48
49use crate::mineral::balling_channels::BallingChannels;
50use crate::utilities::channel_content::{AquariumDevice, InternalCommand};
51use crate::utilities::proc_ext_req::ProcessExternalRequestTrait;
52use log::error;
53use spin_sleep::SpinSleeper;
54use std::time::{Duration, Instant};
55
56/// Trait for the execution of the Balling mineral dosing.
57/// This trait allows running the main control with a mock implementation for testing.
58pub trait MineralInjectionTrait {
59 /// Actuates a specific peristaltic pump to inject a mineral solution into the main tank for a defined duration.
60 ///
61 /// This method controls the selected `mineral_pump` by sending `SwitchOn` and `SwitchOff` commands
62 /// to the relay manager. It runs the pump for the `dosing_duration_millis` or until a
63 /// `Quit` command is received from the signal handler, allowing for early termination.
64 ///
65 /// # Arguments
66 /// * `balling_channels` - A mutable reference to the `BallingChannels` struct.
67 /// * `mineral_pump` - The `AquariumDevice` enum variant identifying which peristaltic pump to actuate.
68 /// * `dosing_duration_millis` - The target duration (in milliseconds) for which the pump should run.
69 ///
70 /// # Returns
71 /// A tuple `(bool, u32)`:
72 /// - The `bool` is `true` if a `Quit` command was received from the signal handler during injection,
73 /// indicating that the operation was interrupted. It's `false` otherwise.
74 /// - The `u32` is the actual duration (in milliseconds) the pump ran before stopping or being interrupted.
75 fn inject_mineral(
76 &mut self,
77 balling_channels: &mut BallingChannels,
78 mineral_pump: AquariumDevice,
79 dosing_duration_millis: u32,
80 ) -> (bool, u32);
81}
82
83#[cfg_attr(doc, aquamarine::aquamarine)]
84/// Struct implements the MineralInjectionTrait for executing the Balling mineral dosing.
85/// It also contains state attributes for the actuators.
86/// Thread communication is as follows:
87/// ```mermaid
88/// graph LR
89/// mineral_injection[Mineral injection] --> relay_manager[Relay Manager]
90/// relay_manager --> mineral_injection
91/// signal_handler[Signal handler] --> mineral_injection
92/// ```
93pub struct MineralInjection;
94
95impl MineralInjection {
96 /// Provide a struct of type MineralInjection
97 pub fn new() -> MineralInjection {
98 MineralInjection
99 }
100
101 /// Sends a command to the relay manager and handles the response.
102 /// Logs an error if either the send or receive operation fails.
103 fn send_and_confirm(
104 &self,
105 balling_channels: &mut BallingChannels,
106 command: InternalCommand,
107 action_description: &str,
108 ) {
109 if let Err(e) = balling_channels.send_to_relay_manager(command) {
110 error!(
111 target: module_path!(),
112 "Channel communication to relay manager when {} failed ({:?})", action_description, e
113 );
114 return; // Don't wait for a response if the send-operation failed
115 }
116
117 if let Err(e) = balling_channels.receive_from_relay_manager() {
118 error!(
119 target: module_path!(),
120 "Receiving answer from relay manager when {} failed ({:?})", action_description, e
121 );
122 }
123 }
124}
125
126impl MineralInjectionTrait for MineralInjection {
127 /// Switches on one of the peristaltic pumps for a defined period, or until a `Quit` command is received.
128 ///
129 /// This implementation for `inject_mineral` controls the specified `mineral_pump`.
130 /// It sends a `SwitchOn` command to the relay manager, then enters a loop
131 /// to keep the pump active for `dosing_duration_millis`. During this period,
132 /// it continuously checks for a `Quit` command from the signal handler,
133 /// allowing for immediate termination. Once the duration is met or a `Quit`
134 /// command is received, it sends a `SwitchOff` command to the pump.
135 ///
136 /// # Arguments
137 /// * `balling_channels` - A mutable reference to the `BallingChannels` struct.
138 /// * `mineral_pump` - The `AquariumDevice` variant identifying which pump to actuate.
139 /// * `dosing_duration_millis` - The target duration (in milliseconds) for which the pump should run.
140 ///
141 /// # Returns
142 /// A tuple `(bool, u32)`:
143 /// - The `bool` is `true` if a `Quit` command was received during injection; otherwise `false`.
144 /// - The `u32` is the actual duration (in milliseconds) the pump ran before stopping or being interrupted.
145 fn inject_mineral(
146 &mut self,
147 balling_channels: &mut BallingChannels,
148 mineral_pump: AquariumDevice,
149 dosing_duration_millis: u32,
150 ) -> (bool, u32) {
151 let spin_sleeper = SpinSleeper::default();
152 let sleep_interval_millis: u32 = 1;
153 let sleep_duration = Duration::from_millis(sleep_interval_millis as u64);
154 let mut quit_command_received: bool;
155
156 // --- Switch On ---
157 self.send_and_confirm(
158 balling_channels,
159 InternalCommand::SwitchOn(mineral_pump.clone()),
160 "switching on dosing pump",
161 );
162
163 let instant_start_of_injection = Instant::now();
164 let mut dosing_counter: u32 = 0;
165
166 loop {
167 // check if there is a quit request
168 (quit_command_received, _, _) = self.process_external_request(
169 &mut balling_channels.rx_balling_from_signal_handler,
170 None,
171 );
172 if quit_command_received {
173 break; // stop dosing
174 }
175
176 spin_sleeper.sleep(sleep_duration);
177
178 dosing_counter = match Instant::now()
179 .duration_since(instant_start_of_injection)
180 .as_millis()
181 .try_into() as Result<u32, _>
182 {
183 Ok(val) => val,
184 Err(e) => {
185 error!(
186 target: module_path!(), "Error converting u128 to u32: {e}"
187 );
188 break; // abort dosing - dosing counter keeps current value
189 }
190 };
191
192 // check if target dosing duration has been reached
193 if dosing_counter > dosing_duration_millis {
194 break; // stop dosing
195 }
196 }
197
198 // --- Switch Off (Guaranteed Cleanup) ---
199 self.send_and_confirm(
200 balling_channels,
201 InternalCommand::SwitchOff(mineral_pump),
202 "switching off dosing pump",
203 );
204
205 (quit_command_received, dosing_counter)
206 }
207}
208
209#[cfg(test)]
210pub mod tests {
211 use crate::launch::channels::{AquaReceiver, AquaSender, Channels};
212 use crate::mineral::mineral_injection::{MineralInjection, MineralInjectionTrait};
213 use crate::utilities::channel_content::{ActuatorState, AquariumDevice, InternalCommand};
214 use all_asserts::{assert_ge, assert_le};
215 use spin_sleep::SpinSleeper;
216 use std::thread::scope;
217 use std::time::Duration;
218
219 // Simulates the Relay Manager's behavior for Mineral Injection tests.
220 //
221 // This helper function acts as a mock for the Relay Manager. It waits to receive
222 // either a `SwitchOn` or `SwitchOff` command from the `MineralInjection` test object.
223 // Upon receiving a command, it sends a `true` acknowledgment back to the sender
224 // and returns the received command. This is used to verify that the
225 // `MineralInjection` module sends the correct commands.
226 //
227 // Arguments:
228 // * `tx_relay_manager_to_mineral_injection`: The sender channel for this mock to send
229 // acknowledgments back to the `MineralInjection` test object.
230 // * `rx_relay_manager_from_mineral_injection`: The receiver channel for this mock to get
231 // actuation commands from the `MineralInjection` test object.
232 //
233 // Returns:
234 // The `InternalCommand` that was received from the `MineralInjection` test object.
235 //
236 // Panics:
237 // This function will panic if:
238 // - It fails to receive a command from the `MineralInjection` test object.
239 // - It receives an `InternalCommand` that is neither `SwitchOn` nor `SwitchOff`.
240 // - It fails to send the acknowledgment back.
241 fn test_mineral_injection_receive_actuation_command_send_confirmation(
242 tx_relay_manager_to_mineral_injection: &mut AquaSender<bool>,
243 rx_relay_manager_from_mineral_injection: &mut AquaReceiver<InternalCommand>,
244 ) -> InternalCommand {
245 let command: InternalCommand;
246
247 // receive command from the test object
248 match rx_relay_manager_from_mineral_injection.recv() {
249 Ok(c) => match c {
250 InternalCommand::SwitchOn(_) => command = c,
251 InternalCommand::SwitchOff(_) => command = c,
252 _ => {
253 panic!("MineralInjection: Received invalid command from the test object:");
254 }
255 },
256 Err(e) => {
257 panic!(
258 "MineralInjection: Error when receiving actuation command from the test object: {e:?}"
259 );
260 }
261 }
262
263 // send confirmation back
264 match tx_relay_manager_to_mineral_injection.send(true) {
265 Ok(_) => {}
266 Err(e) => {
267 panic!("MineralInjection: Error when sending back actuation command confirmation to test object: {e:?}");
268 }
269 }
270
271 command
272 }
273
274 // Helper function to record the actuation state of the devices.
275 fn update_actuator_states(
276 command: InternalCommand,
277 peristaltic_pump1_actuation_state: &mut ActuatorState,
278 peristaltic_pump2_actuation_state: &mut ActuatorState,
279 peristaltic_pump3_actuation_state: &mut ActuatorState,
280 peristaltic_pump4_actuation_state: &mut ActuatorState,
281 ) {
282 // First, determine the new state and the target device from the command.
283 let (device, new_state) = match command {
284 InternalCommand::SwitchOn(device) => (device, ActuatorState::On),
285 InternalCommand::SwitchOff(device) => (device, ActuatorState::Off),
286 // If it's any other command, we don't need to do anything.
287 _ => return,
288 };
289
290 // Now, update the state of the correct pump in a single match block.
291 match device {
292 AquariumDevice::PeristalticPump1 => {
293 *peristaltic_pump1_actuation_state = new_state;
294 }
295 AquariumDevice::PeristalticPump2 => {
296 *peristaltic_pump2_actuation_state = new_state;
297 }
298 AquariumDevice::PeristalticPump3 => {
299 *peristaltic_pump3_actuation_state = new_state;
300 }
301 AquariumDevice::PeristalticPump4 => {
302 *peristaltic_pump4_actuation_state = new_state;
303 }
304 // Ignore commands for any other devices.
305 _ => {}
306 }
307 }
308
309 fn mineral_injection_test_environment(
310 mut tx_relay_manager_to_mineral_injection: AquaSender<bool>,
311 mut rx_relay_manager_from_mineral_injection: AquaReceiver<InternalCommand>,
312 ) {
313 let mut peristaltic_pump1_actuation_state = ActuatorState::Off;
314 let mut peristaltic_pump2_actuation_state = ActuatorState::Off;
315 let mut peristaltic_pump3_actuation_state = ActuatorState::Off;
316 let mut peristaltic_pump4_actuation_state = ActuatorState::Off;
317
318 // allow switching on peristaltic pump
319 let command = test_mineral_injection_receive_actuation_command_send_confirmation(
320 &mut tx_relay_manager_to_mineral_injection,
321 &mut rx_relay_manager_from_mineral_injection,
322 );
323 update_actuator_states(
324 command,
325 &mut peristaltic_pump1_actuation_state,
326 &mut peristaltic_pump2_actuation_state,
327 &mut peristaltic_pump3_actuation_state,
328 &mut peristaltic_pump4_actuation_state,
329 );
330
331 // allow switching off peristaltic pump
332 let command = test_mineral_injection_receive_actuation_command_send_confirmation(
333 &mut tx_relay_manager_to_mineral_injection,
334 &mut rx_relay_manager_from_mineral_injection,
335 );
336 update_actuator_states(
337 command,
338 &mut peristaltic_pump1_actuation_state,
339 &mut peristaltic_pump2_actuation_state,
340 &mut peristaltic_pump3_actuation_state,
341 &mut peristaltic_pump4_actuation_state,
342 );
343 assert_eq!(peristaltic_pump1_actuation_state, ActuatorState::Off);
344 assert_eq!(peristaltic_pump2_actuation_state, ActuatorState::Off);
345 assert_eq!(peristaltic_pump3_actuation_state, ActuatorState::Off);
346 assert_eq!(peristaltic_pump4_actuation_state, ActuatorState::Off);
347 }
348
349 // This test case executes the mineral injection without interruption from the signal handler.
350 #[test]
351 pub fn test_mineral_dosing_happy_case() {
352 let mut channels = Channels::new_for_test();
353
354 let mut mineral_injection = MineralInjection::new();
355
356 let target_dosing_duration_millis = 500;
357
358 scope(|scope| {
359 // thread for test environment
360 scope.spawn(move || {
361 mineral_injection_test_environment(
362 channels.relay_manager.tx_relay_manager_to_balling,
363 channels.relay_manager.rx_relay_manager_from_balling,
364 );
365 });
366
367 // thread for the test object
368 scope.spawn(move || {
369 let (quit_command_received, actual_dosing_duration_millis) = mineral_injection
370 .inject_mineral(
371 &mut channels.balling,
372 AquariumDevice::PeristalticPump1,
373 target_dosing_duration_millis,
374 );
375 assert_eq!(quit_command_received, false);
376 assert_ge!(
377 actual_dosing_duration_millis,
378 target_dosing_duration_millis - 5
379 );
380 assert_le!(
381 actual_dosing_duration_millis,
382 target_dosing_duration_millis + 5
383 );
384 });
385 });
386 }
387
388 // This test case executes the mineral injection with interruption from the signal handler.
389 #[test]
390 pub fn test_mineral_dosing_interrupted() {
391 let mut channels = Channels::new_for_test();
392
393 let mut mineral_injection = MineralInjection::new();
394
395 let target_dosing_duration_millis = 500;
396
397 let spin_sleeper = SpinSleeper::default();
398 let sleep_interval_millis: u32 = 100;
399 let sleep_duration = Duration::from_millis(sleep_interval_millis as u64);
400 scope(|scope| {
401 // thread for signal handler
402 scope.spawn(move || {
403 spin_sleeper.sleep(sleep_duration);
404 let _ = channels
405 .signal_handler
406 .send_to_balling(InternalCommand::Quit);
407 });
408
409 // thread for the rest of test environment
410 scope.spawn(move || {
411 mineral_injection_test_environment(
412 channels.relay_manager.tx_relay_manager_to_balling,
413 channels.relay_manager.rx_relay_manager_from_balling,
414 );
415 });
416
417 // thread for the test object
418 scope.spawn(move || {
419 let (quit_command_received, actual_dosing_duration_millis) = mineral_injection
420 .inject_mineral(
421 &mut channels.balling,
422 AquariumDevice::PeristalticPump1,
423 target_dosing_duration_millis,
424 );
425
426 let lower_bound_float: f32 = sleep_interval_millis as f32 * 0.9;
427 let lower_bound: u32 = lower_bound_float.round() as u32;
428
429 assert_ge!(actual_dosing_duration_millis, lower_bound);
430 assert_le!(actual_dosing_duration_millis, sleep_interval_millis + 15);
431 assert_eq!(quit_command_received, true);
432 });
433 });
434 }
435}