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}