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}