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