aquarium_control/database/
sql_interface_ventilation_setvals.rs

1/* Copyright 2025 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//! Manages database interactions for the ventilation system's user-configurable set points.
11//!
12//! This module provides a dedicated interface, `SqlInterfaceVentilationSetVals`, for handling
13//! all SQL operations related to the `ventilationsetvals` table. It encapsulates logic
14//! for reading the ventilation on/off temperature thresholds and implements the
15//! `ThermalSetValueUpdaterTrait` to periodically poll the database for changes.
16//!
17//! ## Key Components
18//!
19//! - **`SqlInterfaceVentilationSetVals` Struct**: The primary struct that holds a database
20//!   connection and manages the periodic update logic.
21//!
22//! - **`VentilationSetVals` Struct**: A simple data structure representing the on/off
23//!   temperature thresholds for the ventilation fans.
24//!
25//! - **`new()` Constructor**: A critical entry point that performs "fail-fast"
26//!   validation at initialization. It checks the database to ensure:
27//!   - The `ventilationsetvals` table contains no `NULL` values.
28//!   - The table contains at most one row, as there should only be a single set of
29//!     ventilation parameters for the system.
30//!
31//! - **`get_ventilation_set_values()`**: The core data-fetching method. It retrieves the
32//!   ventilation thresholds and performs a critical consistency check to ensure the
33//!   `ventilation_switch_on_temperature` is not lower than the `ventilation_switch_off_temperature`.
34//!
35//! - **`ThermalSetValueUpdaterTrait` Implementation**: The `update_set_value` method
36//!   allows this struct to be used by a thermal controller to periodically and
37//!   efficiently check for updated set points from the database without querying on
38//!   every single loop iteration.
39//!
40//! ## Design and Purpose
41//!
42//! The main goal of this module is to provide a robust, encapsulated, and safe
43//! interface for the ventilation subsystem's configuration data.
44//!
45//! - **Encapsulation**: All SQL queries and logic specific to ventilation set points are
46//!   contained within this module, separating concerns.
47//!
48//! - **Data Integrity**: The constructor's validation logic and the consistency checks
49//!   in `get_ventilation_set_values` enforce data integrity rules, preventing runtime
50//!   errors caused by invalid database states.
51
52use mysql::PooledConn;
53use std::time::{Duration, Instant};
54
55#[cfg(test)]
56use mysql::params;
57
58use crate::database::sql_interface::SqlInterface;
59use crate::database::sql_interface_error::SqlInterfaceError;
60use crate::database::sql_query_strings;
61use crate::database::sql_query_strings::{
62    SQL_QUERY_CHECK_VENTILATION_SETVALS_COUNT, SQL_QUERY_CHECK_VENTILATION_SETVALS_NULL,
63};
64use crate::database::thermal_set_value_updater_trait::ThermalSetValueUpdaterTrait;
65use crate::thermal::ventilation_config::VentilationConfig;
66use mysql::prelude::Queryable;
67
68/// Contains the ventilation set values which can be set by the user.
69#[derive(PartialEq, Debug)]
70pub struct VentilationSetVals {
71    /// the temperature at which the ventilation should be switched on 100%
72    pub ventilation_switch_on_temperature: f32,
73
74    /// the temperature at which the ventilation should be switched off 0%
75    pub ventilation_switch_off_temperature: f32,
76}
77
78#[derive(Debug)]
79pub struct SqlInterfaceVentilationSetVals {
80    /// Connection to the database
81    pub conn: PooledConn,
82
83    /// recording of time when the database has been polled for the last time
84    update_time: Option<Instant>,
85
86    /// target update duration
87    update_duration: Duration,
88}
89
90impl SqlInterfaceVentilationSetVals {
91    /// Creates a new `SqlInterfaceVentilationSetVals` instance.
92    ///
93    /// This constructor initializes the SQL interface for ventilation set values. It performs
94    /// several pre-flight checks to ensure data integrity, such as verifying that the
95    /// `ventilationsetvals` table contains no NULL values and has at most one row.
96    ///
97    /// # Arguments
98    /// * `conn` - An active, pooled database connection.
99    /// * `config` - A reference to the ventilation configuration to determine the update interval.
100    ///
101    /// # Returns
102    /// A `Result` containing a new `SqlInterfaceVentilationSetVals` instance on success.
103    ///
104    /// # Errors
105    /// This function will return an error if:
106    /// - Any of the initial database queries to get table counts fail (`DatabaseCheckVentilationSetValsFailure`).
107    /// - Any of the retrieved counts are negative, indicating a database issue (`DatabaseVentilationSetValTableNegativeValue`).
108    /// - The `ventilationsetvals` table contains entries with `NULL` values (`DatabaseVentilationSetValTableContainsNull`).
109    /// - The `ventilationsetvals` table contains more than one row (`DatabaseVentilationSetValTableContainsTooManyRows`).
110    pub fn new(
111        mut conn: PooledConn,
112        config: &VentilationConfig,
113    ) -> Result<Self, SqlInterfaceError> {
114        let count_null_values = SqlInterface::get_single_integer_from_database(
115            &mut conn,
116            SQL_QUERY_CHECK_VENTILATION_SETVALS_NULL,
117        )
118        .map_err(
119            |e| SqlInterfaceError::DatabaseCheckVentilationSetValsFailure {
120                location: module_path!().to_string(),
121                source: Box::new(e),
122            },
123        )?;
124
125        let count_rows = SqlInterface::get_single_integer_from_database(
126            &mut conn,
127            SQL_QUERY_CHECK_VENTILATION_SETVALS_COUNT,
128        )
129        .map_err(
130            |e| SqlInterfaceError::DatabaseCheckVentilationSetValsFailure {
131                location: module_path!().to_string(),
132                source: Box::new(e),
133            },
134        )?;
135
136        // check the query results
137        if count_null_values < 0 || count_rows < 0 {
138            return Err(
139                SqlInterfaceError::DatabaseVentilationSetValTableNegativeValue(
140                    module_path!().to_string(),
141                    count_null_values,
142                    count_rows,
143                ),
144            );
145        }
146
147        if count_null_values > 0 {
148            return Err(
149                SqlInterfaceError::DatabaseVentilationSetValTableContainsNull(
150                    module_path!().to_string(),
151                    count_null_values,
152                ),
153            );
154        }
155
156        if count_rows > 1 {
157            return Err(
158                SqlInterfaceError::DatabaseVentilationSetValTableContainsTooManyRows(
159                    module_path!().to_string(),
160                    count_rows.cast_unsigned(),
161                ),
162            );
163        }
164
165        Ok(SqlInterfaceVentilationSetVals {
166            conn,
167            update_time: None,
168            update_duration: Duration::from_secs(config.set_value_check_interval),
169        })
170    }
171
172    #[allow(non_snake_case)]
173    /// Retrieves the ventilation set point values (switch-on and switch-off temperatures) from the database.
174    ///
175    /// This function queries the database for the configured ventilation thresholds. It expects
176    /// either no entries (if values haven't been set yet) or exactly one entry.
177    ///
178    /// It performs the following checks:
179    /// 1. **Query Execution: ** Attempts to fetch ventilation set values from the database using
180    ///    `SQL_QUERY_READ_VENTILATION_STATS`.
181    /// 2. **Empty Result: ** If the query returns no entries, it indicates that the set points
182    ///    have not yet been configured in the database, and `Ok(None)` is returned.
183    /// 3. **Multiple Results: ** If the query returns more than one entry, it signifies a data
184    ///    inconsistency, and an `Err` is returned, as only a single set of ventilation values
185    ///    is expected.
186    /// 4. **Consistency Check: ** Validates that the `ventilation_switch_on_temperature` is not
187    ///    greater than the `ventilation_switch_off_temperature`. If this crucial control
188    ///    logic consistency is violated, an `Err` is returned.
189    ///
190    /// # Returns
191    /// - `Ok(Some(VentilationSetVals))`: If a single, consistent set of ventilation set values
192    ///   is successfully retrieved.
193    /// - `Ok(None)`: If the database query returns no entries, indicating that values
194    ///   have not yet been configured.
195    /// - `Err(SqlInterfaceError::VentilationSetValsReadError)`: If there's an error during
196    ///   database interaction, if multiple entries are found, or if the retrieved values
197    ///   fail the consistency check (e.g., `switch_on_temperature` is higher than `switch_off_temperature`).
198    ///
199    /// # Errors
200    /// This function returns an `SqlInterfaceError::VentilationSetValsReadError` in the following cases:
201    /// - The underlying database query fails.
202    /// - More than one `VentilationSetVals` entry is found in the database.
203    /// - The `ventilation_switch_on_temperature` is found to be greater than the
204    ///   `ventilation_switch_off_temperature`, which is a critical configuration inconsistency.
205    pub fn get_ventilation_set_values(
206        &mut self,
207    ) -> Result<Option<VentilationSetVals>, SqlInterfaceError> {
208        let sql_ventilation_setvals_array = self
209            .conn
210            .query_map(
211                sql_query_strings::SQL_QUERY_READ_VENTILATION_SETVALS,
212                |(ventilationSwitchOffTemp, ventilationSwitchOnTemp)| VentilationSetVals {
213                    ventilation_switch_off_temperature: ventilationSwitchOffTemp,
214                    ventilation_switch_on_temperature: ventilationSwitchOnTemp,
215                },
216            )
217            .map_err(|e| SqlInterfaceError::VentilationSetValsRequestFailure {
218                location: module_path!().to_string(),
219                query: sql_query_strings::SQL_QUERY_READ_VENTILATION_SETVALS.to_string(),
220                source: e,
221            })?;
222
223        // return none if the query result is empty (user may have not set any values yet)
224        if sql_ventilation_setvals_array.is_empty() {
225            return Ok(None);
226        }
227
228        // return error if the query result contains more than one entry
229        if sql_ventilation_setvals_array.len() > 1 {
230            return Err(SqlInterfaceError::VentilationSetValsNoSingleResponse(
231                module_path!().to_string(),
232                sql_query_strings::SQL_QUERY_READ_VENTILATION_SETVALS.to_string(),
233            ));
234        }
235
236        // get the first element of the array which has size one
237        let ventilation_set_vals = &sql_ventilation_setvals_array[0];
238
239        // consistency check
240        if ventilation_set_vals.ventilation_switch_on_temperature
241            < ventilation_set_vals.ventilation_switch_off_temperature
242        {
243            return Err(SqlInterfaceError::VentilationSetValsInvalid(
244                module_path!().to_string(),
245                ventilation_set_vals.ventilation_switch_on_temperature,
246                ventilation_set_vals.ventilation_switch_off_temperature,
247            ));
248        }
249
250        Ok(Some(VentilationSetVals {
251            ventilation_switch_off_temperature: ventilation_set_vals
252                .ventilation_switch_off_temperature,
253            ventilation_switch_on_temperature: ventilation_set_vals
254                .ventilation_switch_on_temperature,
255        }))
256    }
257
258    #[cfg(test)]
259    // Inserts a set of ventilation values into the database for testing.
260    //
261    // This helper function is used exclusively in test environments to
262    // pre-populate the `ventilationsetvals` table with specific data.
263    //
264    // # Arguments
265    // * `ventilation_set_vals` - A reference to the `VentilationSetVals` to be inserted.
266    //
267    // # Returns
268    // An empty `Result` (`Ok(())`) if the values were successfully inserted.
269    //
270    // # Errors
271    // Returns `SqlInterfaceError::InsertVentilationSetValuesFailure` if the `INSERT`
272    // query fails. This error should be updated to include the source `mysql::Error`
273    // for better diagnostics in test failures.
274    pub fn insert_ventilation_set_values(
275        &mut self,
276        ventilation_set_vals: &VentilationSetVals,
277    ) -> Result<(), SqlInterfaceError> {
278        match self.conn.exec_drop::<_, _>(
279            sql_query_strings::SQL_QUERY_WRITE_VENTILATION_SETVALS, params!{
280                "ventilation_switch_off_temp" => ventilation_set_vals.ventilation_switch_off_temperature,
281                "ventilation_switch_on_temp" => ventilation_set_vals.ventilation_switch_on_temperature,
282             },
283        ) {
284            Ok(_) => Ok(()),
285            Err(e) => {
286                println!("{e:?}");
287                Err(SqlInterfaceError::InsertVentilationSetValuesFailure {
288                    location: module_path!().to_string(),
289                    query: sql_query_strings::SQL_QUERY_WRITE_VENTILATION_SETVALS.to_string(),
290                    source: e,
291                })
292            }
293        }
294    }
295}
296
297impl ThermalSetValueUpdaterTrait for SqlInterfaceVentilationSetVals {
298    /// Periodically updates ventilation set point values from the database.
299    ///
300    /// This method checks if a configured interval (`update_duration`) has passed since
301    /// the last database check. If it has, it queries the database for the latest
302    /// `VentilationSetVals` and updates the provided mutable references. If no values are
303    /// found in the database, the existing values are kept unchanged.
304    ///
305    /// # Arguments
306    /// * `ventilation_switch_off_temperature` - A mutable reference to the current switch-off temperature.
307    /// * `ventilation_switch_on_temperature` - A mutable reference to the current switch-on temperature.
308    ///
309    /// # Returns
310    /// An empty `Result` (`Ok(())`) on success. Success includes cases where the update
311    /// was skipped due to the time interval or when no new values were found in the database.
312    ///
313    /// # Errors
314    /// Returns `SqlInterfaceError::VentilationSetValsUpdateFailure` if the underlying call to
315    /// `get_ventilation_set_values` fails. This can happen due to a database connection issue,
316    /// data inconsistency (e.g., multiple rows), or invalid set values.
317    fn update_set_value(
318        &mut self,
319        ventilation_switch_off_temperature: &mut f32,
320        ventilation_switch_on_temperature: &mut f32,
321    ) -> Result<(), SqlInterfaceError> {
322        if let Some(last_update) = self.update_time {
323            if last_update.elapsed() < self.update_duration {
324                // Not enough time has passed, so skip the update.
325                return Ok(());
326            }
327        }
328
329        // Either enough time has passed or this is the first update.
330        // Update the time before reading the values from the database.
331        self.update_time = Some(Instant::now());
332
333        match self.get_ventilation_set_values() {
334            Ok(Some(ventilation_set_vals)) => {
335                *ventilation_switch_off_temperature =
336                    ventilation_set_vals.ventilation_switch_off_temperature;
337                *ventilation_switch_on_temperature =
338                    ventilation_set_vals.ventilation_switch_on_temperature;
339                Ok(())
340            }
341            Ok(None) => Ok(()),
342            Err(e) => Err(SqlInterfaceError::VentilationSetValsUpdateFailure {
343                location: module_path!().to_string(),
344                source: Box::new(e),
345            }),
346        }
347    }
348}
349
350#[cfg(test)]
351pub mod tests {
352    use crate::database::sql_interface_ventilation_setvals::{
353        SqlInterfaceVentilationSetVals, VentilationSetVals,
354    };
355    use crate::database::{
356        sql_interface::SqlInterface, sql_interface_error::SqlInterfaceError, sql_query_strings,
357    };
358    use crate::utilities::config::{read_config_file_with_test_database, ConfigData};
359
360    #[test]
361    // This test case verifies the validation logic within the `SqlInterfaceVentilationSetVals::new()` constructor.
362    // It covers the following scenarios:
363    // 1. Happy Path: Initialization succeeds when the table is empty or has one valid row.
364    // 2. Failure on Row Limit: Fails when `heatingsetvals` contains more than one row.
365    // Test case uses test database #64.
366    fn test_sql_interface_ventilation_setvals_new() {
367        // --- Common Setup ---
368        let config: ConfigData = read_config_file_with_test_database(
369            "/config/aquarium_control_test_generic.toml".to_string(),
370            64,
371        );
372        println!("Testing with database {}", config.sql_interface.db_name);
373        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface.clone())
374            .expect("Initialization of SQL interface for test failed.");
375
376        // --- Test Case 1: Happy Path (empty table) ---
377        println!("* Testing new() with an empty table (Happy Path)...");
378        SqlInterface::truncate_table(
379            &mut sql_interface,
380            sql_query_strings::SQL_TABLE_VENTILATION_SETVALS.to_string(),
381        )
382        .expect("Test setup failed: Could not truncate table.");
383
384        let result = SqlInterfaceVentilationSetVals::new(
385            sql_interface.get_connection().unwrap(),
386            &config.ventilation,
387        );
388        assert!(
389            result.is_ok(),
390            "Expected new() to succeed with an empty table, but it failed: {:?}",
391            result.err()
392        );
393        println!("* Succeeded: Happy path initialization is successful on empty table.");
394
395        // --- Test Case 2: Failure on too many rows ---
396        println!("* Testing new() with more than one row in the table...");
397        SqlInterface::truncate_table(
398            &mut sql_interface,
399            sql_query_strings::SQL_TABLE_VENTILATION_SETVALS.to_string(),
400        )
401        .expect("Test setup failed: Could not truncate table.");
402        let conn = sql_interface.get_connection().unwrap();
403        let valid_vals = VentilationSetVals {
404            ventilation_switch_off_temperature: 25.0,
405            ventilation_switch_on_temperature: 24.0,
406        };
407        let mut temp_interface = SqlInterfaceVentilationSetVals {
408            conn,
409            update_time: None,
410            update_duration: Default::default(),
411        };
412        temp_interface
413            .insert_ventilation_set_values(&valid_vals)
414            .expect("Test setup failed: Could not insert first row.");
415        temp_interface
416            .insert_ventilation_set_values(&valid_vals)
417            .expect("Test setup failed: Could not insert second row.");
418
419        let result = SqlInterfaceVentilationSetVals::new(
420            sql_interface.get_connection().unwrap(),
421            &config.ventilation,
422        );
423        assert!(
424            matches!(
425                result,
426                Err(SqlInterfaceError::DatabaseVentilationSetValTableContainsTooManyRows(_, _))
427            ),
428            "Expected row limit error, but got {:?}",
429            result
430        );
431        println!("* Succeeded: Initialization fails if table exceeds row limit.");
432    }
433
434    #[test]
435    // Test case includes all checks for this table combined in one function to limit the number of additional databases.
436    // Test case uses the test database #51.
437    pub fn test_sql_interface_ventilation_setvals() {
438        let config: ConfigData = read_config_file_with_test_database(
439            "/config/aquarium_control_test_generic.toml".to_string(),
440            51,
441        );
442        println!("Testing with database {}", config.sql_interface.db_name);
443        // *** test reading ventilation set values **************************************************
444        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
445            .expect("Initialization of SQL interface for test failed.");
446        match SqlInterface::truncate_table(
447            &mut sql_interface,
448            sql_query_strings::SQL_TABLE_VENTILATION_SETVALS.to_string(),
449        ) {
450            Ok(_) => {}
451            Err(e) => panic!("Could not prepare test case: {e:?}"),
452        }
453
454        // instantiation of the test object
455        let mut sql_interface_ventilation_setvals = SqlInterfaceVentilationSetVals::new(
456            sql_interface.get_connection().unwrap(),
457            &config.ventilation,
458        )
459        .unwrap();
460
461        // check if reading an empty table returns None
462        match sql_interface_ventilation_setvals.get_ventilation_set_values() {
463            Ok(c) => {
464                assert_eq!(c.is_some(), false);
465            }
466            Err(_) => {
467                panic!("Call to get_ventilation_set_values failed.");
468            }
469        }
470        println!("* reading empty ventilation set values succeeded.");
471
472        // define valid value combination
473        let valid_ventilation_set_vals = VentilationSetVals {
474            ventilation_switch_on_temperature: 25.0,
475            ventilation_switch_off_temperature: 20.0,
476        };
477
478        // insert valid values
479        assert!(matches!(
480            sql_interface_ventilation_setvals
481                .insert_ventilation_set_values(&valid_ventilation_set_vals),
482            Ok(())
483        ));
484        println!("* inserting valid ventilation set values as precondition succeeded.");
485
486        match sql_interface_ventilation_setvals.get_ventilation_set_values() {
487            Ok(c) => {
488                assert_eq!(c.is_some(), true);
489                let ventilation_set_vals = c.unwrap();
490                assert_eq!(
491                    ventilation_set_vals.ventilation_switch_off_temperature,
492                    valid_ventilation_set_vals.ventilation_switch_off_temperature
493                );
494                assert_eq!(
495                    ventilation_set_vals.ventilation_switch_on_temperature,
496                    valid_ventilation_set_vals.ventilation_switch_on_temperature
497                );
498            }
499            Err(_) => {
500                panic!("Call to get_ventilation_set_values failed.");
501            }
502        }
503        println!("* reading valid ventilation set values succeeded.");
504
505        match SqlInterface::truncate_table(
506            &mut sql_interface,
507            sql_query_strings::SQL_TABLE_VENTILATION_SETVALS.to_string(),
508        ) {
509            Ok(_) => {}
510            Err(e) => panic!("Could not prepare test case: {e:?}"),
511        }
512
513        // define invalid value combination
514        let invalid_ventilation_set_vals = VentilationSetVals {
515            ventilation_switch_on_temperature: 21.0,
516            ventilation_switch_off_temperature: 26.0,
517        };
518
519        // insert invalid values
520        assert!(matches!(
521            sql_interface_ventilation_setvals
522                .insert_ventilation_set_values(&invalid_ventilation_set_vals),
523            Ok(())
524        ));
525        println!("* inserting invalid ventilation set values as precondition succeeded.");
526
527        // check if reading an invalid value combination returns an error
528        assert!(matches!(
529            sql_interface_ventilation_setvals.get_ventilation_set_values(),
530            Err(SqlInterfaceError::VentilationSetValsInvalid(_, _, _))
531        ));
532        println!("* reading  invalid ventilation set values succeeded.");
533
534        // test if too many rows are detected
535        match SqlInterface::truncate_table(
536            &mut sql_interface,
537            sql_query_strings::SQL_TABLE_VENTILATION_SETVALS.to_string(),
538        ) {
539            Ok(_) => {}
540            Err(e) => panic!("Could not prepare test case: {e:?}"),
541        }
542        assert!(matches!(
543            sql_interface_ventilation_setvals
544                .insert_ventilation_set_values(&valid_ventilation_set_vals),
545            Ok(())
546        ));
547        assert!(matches!(
548            sql_interface_ventilation_setvals
549                .insert_ventilation_set_values(&valid_ventilation_set_vals),
550            Ok(())
551        ));
552        let test_result = SqlInterfaceVentilationSetVals::new(
553            sql_interface.get_connection().unwrap(),
554            &config.ventilation,
555        );
556        assert!(matches!(
557            test_result,
558            Err(SqlInterfaceError::DatabaseVentilationSetValTableContainsTooManyRows(_, _))
559        ));
560        println!("* detecting too many rows succeeded.");
561
562        //***************************************************************************************
563    }
564}