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}