klafs_api/
client.rs

1use std::sync::Arc;
2
3use reqwest::cookie::{CookieStore, Jar};
4use reqwest::{Client, Url};
5use scraper::{Html, Selector};
6use tracing::{debug, info, instrument, warn};
7
8use crate::debug::{DebugConfig, HttpDebugger, Timer};
9use crate::error::{KlafsError, Result};
10use crate::models::{
11    LightChangeRequest, LightType, PowerControlRequest, SaunaInfo, SaunaMode, SaunaStatus,
12    SetHumidityRequest, SetModeRequest, SetSelectedTimeRequest, SetTemperatureRequest,
13};
14
15// ─────────────────────────────────────────────────────────────────────────────
16// Validation Helpers
17// ─────────────────────────────────────────────────────────────────────────────
18
19macro_rules! validate_range {
20    ($value:expr, $min:expr, $max:expr, $name:expr) => {
21        if !($min..=$max).contains(&$value) {
22            return Err(KlafsError::InvalidParameter {
23                message: format!(
24                    "{} must be between {} and {}, got {}",
25                    $name, $min, $max, $value
26                ),
27            });
28        }
29    };
30}
31
32/// Default base URL for the Klafs API
33pub const DEFAULT_BASE_URL: &str = "https://sauna-app-19.klafs.com";
34
35/// User agent to use for requests (mimics the mobile app)
36const USER_AGENT: &str = "KlafsSaunaApp/1.0";
37const AUTH_COOKIE_NAME: &str = ".ASPXAUTH";
38
39/// Configuration for the Klafs client
40#[derive(Debug, Clone)]
41pub struct ClientConfig {
42    /// Base URL for the API (can be overridden for testing)
43    pub base_url: String,
44    /// Debug configuration
45    pub debug: DebugConfig,
46    /// Request timeout in seconds
47    pub timeout_secs: u64,
48}
49
50impl Default for ClientConfig {
51    fn default() -> Self {
52        Self {
53            base_url: DEFAULT_BASE_URL.to_string(),
54            debug: DebugConfig::default(),
55            timeout_secs: 30,
56        }
57    }
58}
59
60impl ClientConfig {
61    /// Create a config for testing with a custom base URL
62    pub fn for_testing(base_url: &str) -> Self {
63        Self {
64            base_url: base_url.to_string(),
65            debug: DebugConfig::enabled(),
66            timeout_secs: 5,
67        }
68    }
69}
70
71/// Klafs API client
72///
73/// Handles authentication and maintains session state for communicating
74/// with the Klafs sauna control API.
75///
76/// # Example
77///
78/// ```no_run
79/// use klafs_api::KlafsClient;
80///
81/// #[tokio::main]
82/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
83///     let client = KlafsClient::new()?;
84///     client.login("user@example.com", "password").await?;
85///
86///     let status = client.get_status("sauna-uuid").await?;
87///     println!("Temperature: {}°C", status.current_temperature);
88///     Ok(())
89/// }
90/// ```
91pub struct KlafsClient {
92    client: Client,
93    cookie_jar: Arc<Jar>,
94    base_url: String,
95    verification_token: std::sync::RwLock<Option<String>>,
96    debugger: Arc<HttpDebugger>,
97}
98
99impl std::fmt::Debug for KlafsClient {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("KlafsClient")
102            .field("base_url", &self.base_url)
103            .field("is_logged_in", &self.is_logged_in())
104            .finish()
105    }
106}
107
108impl KlafsClient {
109    /// Create a new Klafs client with default configuration
110    pub fn new() -> Result<Self> {
111        Self::with_config(ClientConfig::default())
112    }
113
114    /// Create a new Klafs client with custom configuration
115    pub fn with_config(config: ClientConfig) -> Result<Self> {
116        let cookie_jar = Arc::new(Jar::default());
117
118        // Build default headers - X-Requested-With is required for ASP.NET AJAX requests
119        let mut default_headers = reqwest::header::HeaderMap::new();
120        default_headers.insert(
121            "X-Requested-With",
122            reqwest::header::HeaderValue::from_static("XMLHttpRequest"),
123        );
124
125        let client = Client::builder()
126            .cookie_provider(cookie_jar.clone())
127            .user_agent(USER_AGENT)
128            .default_headers(default_headers)
129            .timeout(std::time::Duration::from_secs(config.timeout_secs))
130            .build()?;
131
132        Ok(Self {
133            client,
134            cookie_jar,
135            base_url: config.base_url,
136            verification_token: std::sync::RwLock::new(None),
137            debugger: Arc::new(HttpDebugger::new(config.debug)),
138        })
139    }
140
141    /// Get the debugger for accessing traffic logs
142    pub fn debugger(&self) -> &HttpDebugger {
143        &self.debugger
144    }
145
146    /// Enable debug logging at runtime
147    pub fn enable_debug(&self) {
148        self.debugger.enable();
149    }
150
151    /// Disable debug logging at runtime
152    pub fn disable_debug(&self) {
153        self.debugger.disable();
154    }
155
156    /// Login to the Klafs API
157    ///
158    /// This authenticates with the Klafs server and establishes a session.
159    /// The session cookie is stored automatically for subsequent requests.
160    ///
161    /// # Arguments
162    ///
163    /// * `username` - Email address for the Klafs account
164    /// * `password` - Password for the Klafs account
165    ///
166    /// # Errors
167    ///
168    /// Returns `KlafsError::AuthenticationFailed` if credentials are invalid.
169    /// Returns `KlafsError::AccountLocked` if too many failed attempts.
170    ///
171    /// # Warning
172    ///
173    /// Klafs locks accounts after 3 failed login attempts!
174    #[instrument(skip(self, password), fields(username = %username))]
175    pub async fn login(&self, username: &str, password: &str) -> Result<()> {
176        info!("Logging in as {}", username);
177        let timer = Timer::start();
178
179        // First, get the login page to extract any CSRF tokens
180        let login_page_url = format!("{}/Account/Login", self.base_url);
181
182        let request_id = self
183            .debugger
184            .log_request(
185                "GET",
186                &login_page_url,
187                &reqwest::header::HeaderMap::new(),
188                None,
189            )
190            .await;
191
192        let login_page_response = self.client.get(&login_page_url).send().await?;
193
194        let status_code = login_page_response.status();
195        let headers = login_page_response.headers().clone();
196        let login_page_html = login_page_response.text().await?;
197
198        self.debugger
199            .log_response(
200                &request_id,
201                status_code.as_u16(),
202                &headers,
203                Some(&login_page_html),
204                timer.elapsed_ms(),
205            )
206            .await;
207
208        if !status_code.is_success() {
209            return Err(KlafsError::ApiError {
210                status_code: status_code.as_u16(),
211                message: "Failed to load login page".to_string(),
212            });
213        }
214
215        // Extract the verification token from the login form (optional - KLAFS may not require it)
216        let token = self.extract_verification_token(&login_page_html).ok();
217        if token.is_some() {
218            debug!("Extracted verification token");
219        } else {
220            debug!("No verification token found in login form (may not be required)");
221        }
222
223        // Submit the login form
224        let login_url = format!("{}/Account/Login", self.base_url);
225
226        // Build form parameters
227        let mut form_params: Vec<(&str, &str)> = vec![
228            ("UserName", username),
229            ("Password", password),
230            ("RememberMe", "false"),
231        ];
232        if let Some(ref t) = token {
233            form_params.push(("__RequestVerificationToken", t.as_str()));
234        }
235
236        let form_body = form_params
237            .iter()
238            .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
239            .collect::<Vec<_>>()
240            .join("&");
241
242        let timer = Timer::start();
243        let request_id = self
244            .debugger
245            .log_request(
246                "POST",
247                &login_url,
248                &reqwest::header::HeaderMap::new(),
249                Some(&form_body),
250            )
251            .await;
252
253        let response = self
254            .client
255            .post(&login_url)
256            .form(&form_params)
257            .send()
258            .await?;
259
260        let status = response.status();
261        let headers = response.headers().clone();
262        let response_text = response.text().await?;
263
264        self.debugger
265            .log_response(
266                &request_id,
267                status.as_u16(),
268                &headers,
269                Some(&response_text),
270                timer.elapsed_ms(),
271            )
272            .await;
273
274        // Check for error indicators in response
275        if response_text.contains("Sicherheitskontrolle")
276            || response_text.contains("security check")
277        {
278            warn!("Account may be locked due to security check");
279            return Err(KlafsError::AccountLocked);
280        }
281
282        if response_text.contains("Invalid login") || response_text.contains("Ungültige Anmeldung")
283        {
284            return Err(KlafsError::AuthenticationFailed {
285                message: "Invalid username or password".to_string(),
286            });
287        }
288
289        // Check if we got redirected to the dashboard (successful login)
290        // or stayed on the login page (failed login)
291        if status.is_success() || status.is_redirection() {
292            // Try to extract a new verification token for subsequent requests
293            if let Ok(new_token) = self.extract_verification_token(&response_text) {
294                let mut token_guard = self.verification_token.write().unwrap();
295                *token_guard = Some(new_token);
296            }
297
298            // Verify we actually got a session cookie
299            if !self.has_auth_cookie(&headers) {
300                return Err(KlafsError::AuthenticationFailed {
301                    message: "No authentication cookie received".to_string(),
302                });
303            }
304
305            info!("Login successful");
306            Ok(())
307        } else {
308            Err(KlafsError::ApiError {
309                status_code: status.as_u16(),
310                message: format!("Login failed with status {}", status),
311            })
312        }
313    }
314
315    /// Get the current status of a sauna
316    #[instrument(skip(self), fields(sauna_id = %sauna_id))]
317    pub async fn get_status(&self, sauna_id: &str) -> Result<SaunaStatus> {
318        Self::validate_sauna_id(sauna_id)?;
319        debug!("Getting status for sauna {}", sauna_id);
320        let timer = Timer::start();
321
322        let url = format!("{}/SaunaApp/GetData?id={}", self.base_url, sauna_id);
323
324        let request_id = self
325            .debugger
326            .log_request("GET", &url, &reqwest::header::HeaderMap::new(), None)
327            .await;
328
329        let response = self.client.get(&url).send().await?;
330
331        let status = response.status();
332        let headers = response.headers().clone();
333
334        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
335            return Err(KlafsError::SessionExpired);
336        }
337
338        let response_text = response.text().await?;
339
340        self.debugger
341            .log_response(
342                &request_id,
343                status.as_u16(),
344                &headers,
345                Some(&response_text),
346                timer.elapsed_ms(),
347            )
348            .await;
349
350        if !status.is_success() {
351            return Err(KlafsError::ApiError {
352                status_code: status.as_u16(),
353                message: response_text,
354            });
355        }
356
357        let sauna_status: SaunaStatus = serde_json::from_str(&response_text)?;
358
359        if sauna_status.login_required {
360            return Err(KlafsError::SessionExpired);
361        }
362
363        if !sauna_status.success {
364            let message = if !sauna_status.error_message.is_empty() {
365                sauna_status.error_message.clone()
366            } else if !sauna_status.error_message_header.is_empty() {
367                sauna_status.error_message_header.clone()
368            } else if let Some(status_message) = sauna_status.status_message.as_ref() {
369                status_message.clone()
370            } else {
371                "Unknown error".to_string()
372            };
373            return Err(KlafsError::ApiError {
374                status_code: status.as_u16(),
375                message,
376            });
377        }
378
379        debug!(
380            "Sauna {} status: connected={}, powered={}",
381            sauna_id, sauna_status.is_connected, sauna_status.is_powered_on
382        );
383
384        Ok(sauna_status)
385    }
386
387    /// List all saunas registered to the account
388    #[instrument(skip(self))]
389    pub async fn list_saunas(&self) -> Result<Vec<SaunaInfo>> {
390        debug!("Fetching list of saunas");
391        let timer = Timer::start();
392
393        let url = format!("{}/SaunaApp/ChangeSettings", self.base_url);
394
395        let request_id = self
396            .debugger
397            .log_request("GET", &url, &reqwest::header::HeaderMap::new(), None)
398            .await;
399
400        let response = self.client.get(&url).send().await?;
401
402        let status = response.status();
403        let headers = response.headers().clone();
404
405        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
406            return Err(KlafsError::SessionExpired);
407        }
408
409        let html = response.text().await?;
410
411        self.debugger
412            .log_response(
413                &request_id,
414                status.as_u16(),
415                &headers,
416                Some(&html),
417                timer.elapsed_ms(),
418            )
419            .await;
420
421        if !status.is_success() {
422            return Err(KlafsError::ApiError {
423                status_code: status.as_u16(),
424                message: html,
425            });
426        }
427
428        let saunas = self.extract_saunas_from_html(&html)?;
429
430        info!("Found {} sauna(s)", saunas.len());
431
432        Ok(saunas)
433    }
434
435    /// Power on the sauna immediately or at a scheduled time
436    ///
437    /// # Arguments
438    ///
439    /// * `sauna_id` - UUID of the sauna
440    /// * `pin` - PIN code for power control
441    /// * `schedule` - Optional (hour, minute) to schedule start instead of immediate
442    #[instrument(skip(self, pin), fields(sauna_id = %sauna_id))]
443    pub async fn power_on(
444        &self,
445        sauna_id: &str,
446        pin: &str,
447        schedule: Option<(i32, i32)>,
448    ) -> Result<()> {
449        // Validate sauna ID format
450        Self::validate_sauna_id(sauna_id)?;
451
452        // Validate PIN format
453        Self::validate_pin(pin)?;
454
455        let (time_selected, sel_hour, sel_min) = match schedule {
456            Some((hour, minute)) => {
457                // Validate schedule time
458                Self::validate_hour(hour)?;
459                Self::validate_minute(minute)?;
460                info!(
461                    "Scheduling sauna {} to start at {:02}:{:02}",
462                    sauna_id, hour, minute
463                );
464                // First set the scheduled time via SetSelectedTime endpoint
465                self.set_selected_time(sauna_id, Some((hour, minute)))
466                    .await?;
467                (true, hour, minute)
468            }
469            None => {
470                info!("Powering on sauna {} immediately", sauna_id);
471                // API requires all fields - use 0 for immediate start
472                (false, 0, 0)
473            }
474        };
475
476        let timer = Timer::start();
477        let url = format!("{}/SaunaApp/StartCabin", self.base_url);
478
479        let request = PowerControlRequest {
480            id: sauna_id.to_string(),
481            pin: pin.to_string(),
482            time_selected,
483            sel_hour,
484            sel_min,
485        };
486
487        let body = serde_json::to_string(&request)?;
488        let request_id = self
489            .debugger
490            .log_request(
491                "POST",
492                &url,
493                &reqwest::header::HeaderMap::new(),
494                Some(&body),
495            )
496            .await;
497
498        let response = self.client.post(&url).json(&request).send().await?;
499
500        let status = response.status();
501        let headers = response.headers().clone();
502        let response_text = response.text().await?;
503
504        self.debugger
505            .log_response(
506                &request_id,
507                status.as_u16(),
508                &headers,
509                Some(&response_text),
510                timer.elapsed_ms(),
511            )
512            .await;
513
514        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
515            return Err(KlafsError::SessionExpired);
516        }
517
518        // Check for PIN errors
519        if response_text.contains("PIN") && response_text.contains("invalid") {
520            return Err(KlafsError::InvalidPin);
521        }
522
523        self.check_response_status(status, &response_text)?;
524
525        match schedule {
526            Some((hour, minute)) => {
527                info!("Sauna {} scheduled for {:02}:{:02}", sauna_id, hour, minute)
528            }
529            None => info!("Sauna {} powered on", sauna_id),
530        }
531        Ok(())
532    }
533
534    /// Power off the sauna
535    ///
536    /// Note: PIN is not required for power off
537    ///
538    /// # Arguments
539    ///
540    /// * `sauna_id` - UUID of the sauna
541    #[instrument(skip(self), fields(sauna_id = %sauna_id))]
542    pub async fn power_off(&self, sauna_id: &str) -> Result<()> {
543        Self::validate_sauna_id(sauna_id)?;
544        info!("Powering off sauna {}", sauna_id);
545        let timer = Timer::start();
546
547        let url = format!("{}/SaunaApp/StopCabin", self.base_url);
548
549        // StopCabin uses a different request format - just the sauna ID
550        let request = serde_json::json!({
551            "id": sauna_id
552        });
553
554        let body = serde_json::to_string(&request)?;
555        let request_id = self
556            .debugger
557            .log_request(
558                "POST",
559                &url,
560                &reqwest::header::HeaderMap::new(),
561                Some(&body),
562            )
563            .await;
564
565        let response = self.client.post(&url).json(&request).send().await?;
566
567        let status = response.status();
568        let headers = response.headers().clone();
569        let response_text = response.text().await?;
570
571        self.debugger
572            .log_response(
573                &request_id,
574                status.as_u16(),
575                &headers,
576                Some(&response_text),
577                timer.elapsed_ms(),
578            )
579            .await;
580
581        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
582            return Err(KlafsError::SessionExpired);
583        }
584
585        if !status.is_success() {
586            return Err(KlafsError::ApiError {
587                status_code: status.as_u16(),
588                message: response_text,
589            });
590        }
591
592        info!("Sauna {} powered off", sauna_id);
593        Ok(())
594    }
595
596    /// Set the operating mode
597    ///
598    /// # Arguments
599    ///
600    /// * `sauna_id` - UUID of the sauna
601    /// * `mode` - The mode to set (Sauna, Sanarium, or Infrared)
602    #[instrument(skip(self), fields(sauna_id = %sauna_id, mode = ?mode))]
603    pub async fn set_mode(&self, sauna_id: &str, mode: SaunaMode) -> Result<()> {
604        Self::validate_sauna_id(sauna_id)?;
605        info!("Setting mode to {:?} for sauna {}", mode, sauna_id);
606        let timer = Timer::start();
607
608        let url = format!("{}/SaunaApp/SetMode", self.base_url);
609
610        let request = SetModeRequest {
611            id: sauna_id.to_string(),
612            selected_mode: mode.into(),
613        };
614
615        let body = serde_json::to_string(&request)?;
616        let request_id = self
617            .debugger
618            .log_request(
619                "POST",
620                &url,
621                &reqwest::header::HeaderMap::new(),
622                Some(&body),
623            )
624            .await;
625
626        let response = self.client.post(&url).json(&request).send().await?;
627
628        let status = response.status();
629        let headers = response.headers().clone();
630        let response_text = response.text().await?;
631
632        self.debugger
633            .log_response(
634                &request_id,
635                status.as_u16(),
636                &headers,
637                Some(&response_text),
638                timer.elapsed_ms(),
639            )
640            .await;
641
642        self.check_response_status(status, &response_text)?;
643
644        info!("Mode set to {:?}", mode);
645        Ok(())
646    }
647
648    /// Set the target temperature
649    ///
650    /// # Arguments
651    ///
652    /// * `sauna_id` - UUID of the sauna
653    /// * `temperature` - Target temperature in °C
654    ///   - Sauna mode: 10-100°C
655    ///   - Sanarium mode: 40-75°C
656    #[instrument(skip(self), fields(sauna_id = %sauna_id, temperature = %temperature))]
657    pub async fn set_temperature(&self, sauna_id: &str, temperature: i32) -> Result<()> {
658        Self::validate_sauna_id(sauna_id)?;
659        Self::validate_temperature(temperature)?;
660        let status = self.get_status(sauna_id).await?;
661        if status.sanarium_selected {
662            Self::validate_sanarium_temperature(temperature)?;
663        }
664
665        info!(
666            "Setting temperature to {}°C for sauna {}",
667            temperature, sauna_id
668        );
669        let timer = Timer::start();
670
671        let url = format!("{}/SaunaApp/ChangeTemperature", self.base_url);
672
673        let request = SetTemperatureRequest {
674            id: sauna_id.to_string(),
675            temperature,
676        };
677
678        let body = serde_json::to_string(&request)?;
679        let request_id = self
680            .debugger
681            .log_request(
682                "POST",
683                &url,
684                &reqwest::header::HeaderMap::new(),
685                Some(&body),
686            )
687            .await;
688
689        let response = self.client.post(&url).json(&request).send().await?;
690
691        let status = response.status();
692        let headers = response.headers().clone();
693        let response_text = response.text().await?;
694
695        self.debugger
696            .log_response(
697                &request_id,
698                status.as_u16(),
699                &headers,
700                Some(&response_text),
701                timer.elapsed_ms(),
702            )
703            .await;
704
705        self.check_response_status(status, &response_text)?;
706
707        info!("Temperature set to {}°C", temperature);
708        Ok(())
709    }
710
711    /// Set the humidity level (Sanarium mode only)
712    ///
713    /// # Arguments
714    ///
715    /// * `sauna_id` - UUID of the sauna
716    /// * `level` - Humidity level (1-10)
717    #[instrument(skip(self), fields(sauna_id = %sauna_id, level = %level))]
718    pub async fn set_humidity(&self, sauna_id: &str, level: i32) -> Result<()> {
719        Self::validate_sauna_id(sauna_id)?;
720        Self::validate_humidity_level(level)?;
721
722        // Check that sauna is in Sanarium mode (humidity only works in Sanarium)
723        let status = self.get_status(sauna_id).await?;
724        if !status.sanarium_selected {
725            return Err(KlafsError::InvalidParameter {
726                message:
727                    "Humidity can only be set in Sanarium mode. Use 'set-mode sanarium' first."
728                        .to_string(),
729            });
730        }
731
732        info!("Setting humidity level to {} for sauna {}", level, sauna_id);
733        let timer = Timer::start();
734
735        let url = format!("{}/SaunaApp/ChangeHumLevel", self.base_url);
736
737        let request = SetHumidityRequest {
738            id: sauna_id.to_string(),
739            level,
740        };
741
742        let body = serde_json::to_string(&request)?;
743        let request_id = self
744            .debugger
745            .log_request(
746                "POST",
747                &url,
748                &reqwest::header::HeaderMap::new(),
749                Some(&body),
750            )
751            .await;
752
753        let response = self.client.post(&url).json(&request).send().await?;
754
755        let status = response.status();
756        let headers = response.headers().clone();
757        let response_text = response.text().await?;
758
759        self.debugger
760            .log_response(
761                &request_id,
762                status.as_u16(),
763                &headers,
764                Some(&response_text),
765                timer.elapsed_ms(),
766            )
767            .await;
768
769        self.check_response_status(status, &response_text)?;
770
771        info!("Humidity level set to {}", level);
772        Ok(())
773    }
774
775    /// Set the scheduled start time
776    ///
777    /// # Arguments
778    ///
779    /// * `sauna_id` - UUID of the sauna
780    /// * `hour` - Start hour (0-23)
781    /// * `minute` - Start minute (0-59)
782    #[instrument(skip(self), fields(sauna_id = %sauna_id, hour = %hour, minute = %minute))]
783    pub async fn set_start_time(&self, sauna_id: &str, hour: i32, minute: i32) -> Result<()> {
784        self.set_selected_time(sauna_id, Some((hour, minute))).await
785    }
786
787    /// Set or clear the scheduled start time without starting the sauna
788    ///
789    /// This uses the SetSelectedTime endpoint to configure scheduling
790    /// without immediately powering on the sauna.
791    ///
792    /// # Arguments
793    ///
794    /// * `sauna_id` - UUID of the sauna
795    /// * `time` - `Some((hour, minute))` to set schedule, `None` to clear
796    #[instrument(skip(self), fields(sauna_id = %sauna_id))]
797    pub async fn set_selected_time(&self, sauna_id: &str, time: Option<(i32, i32)>) -> Result<()> {
798        Self::validate_sauna_id(sauna_id)?;
799
800        let (time_set, hours, minutes) = match time {
801            Some((hour, minute)) => {
802                Self::validate_hour(hour)?;
803                Self::validate_minute(minute)?;
804                info!(
805                    "Setting scheduled time to {:02}:{:02} for sauna {}",
806                    hour, minute, sauna_id
807                );
808                (true, hour, minute)
809            }
810            None => {
811                info!("Clearing scheduled time for sauna {}", sauna_id);
812                (false, 0, 0)
813            }
814        };
815
816        let timer = Timer::start();
817        let url = format!("{}/SaunaApp/SetSelectedTime", self.base_url);
818
819        let request = SetSelectedTimeRequest {
820            id: sauna_id.to_string(),
821            time_set,
822            hours,
823            minutes,
824        };
825
826        let body = serde_json::to_string(&request)?;
827        let request_id = self
828            .debugger
829            .log_request(
830                "POST",
831                &url,
832                &reqwest::header::HeaderMap::new(),
833                Some(&body),
834            )
835            .await;
836
837        let response = self.client.post(&url).json(&request).send().await?;
838
839        let status = response.status();
840        let headers = response.headers().clone();
841        let response_text = response.text().await?;
842
843        self.debugger
844            .log_response(
845                &request_id,
846                status.as_u16(),
847                &headers,
848                Some(&response_text),
849                timer.elapsed_ms(),
850            )
851            .await;
852
853        self.check_response_status(status, &response_text)?;
854
855        match time {
856            Some((hour, minute)) => info!("Scheduled time set to {:02}:{:02}", hour, minute),
857            None => info!("Scheduled time cleared"),
858        }
859        Ok(())
860    }
861
862    /// Control the main cabin light
863    ///
864    /// # Arguments
865    ///
866    /// * `sauna_id` - UUID of the sauna
867    /// * `on` - Whether to turn the light on or off
868    /// * `brightness` - Brightness level (1-10), only used when turning on
869    #[instrument(skip(self), fields(sauna_id = %sauna_id))]
870    pub async fn set_light(&self, sauna_id: &str, on: bool, brightness: Option<i32>) -> Result<()> {
871        self.set_light_internal(sauna_id, LightType::Main, on, brightness, None)
872            .await
873    }
874
875    /// Control the sunset light
876    ///
877    /// # Arguments
878    ///
879    /// * `sauna_id` - UUID of the sauna
880    /// * `on` - Whether to turn the light on or off
881    /// * `brightness` - Brightness level (1-10), only used when turning on
882    #[instrument(skip(self), fields(sauna_id = %sauna_id))]
883    pub async fn set_sunset(
884        &self,
885        sauna_id: &str,
886        on: bool,
887        brightness: Option<i32>,
888    ) -> Result<()> {
889        self.set_light_internal(sauna_id, LightType::Sunset, on, brightness, None)
890            .await
891    }
892
893    /// Internal method to control any light type
894    async fn set_light_internal(
895        &self,
896        sauna_id: &str,
897        light_type: LightType,
898        on: bool,
899        brightness: Option<i32>,
900        color: Option<i32>,
901    ) -> Result<()> {
902        Self::validate_sauna_id(sauna_id)?;
903
904        // Validate brightness if provided
905        if let Some(b) = brightness {
906            if !(1..=10).contains(&b) {
907                return Err(KlafsError::InvalidParameter {
908                    message: format!("Brightness must be between 1 and 10, got {}", b),
909                });
910            }
911        }
912
913        let light_name = match light_type {
914            LightType::Main => "main light",
915            LightType::Color => "color light",
916            LightType::Sunset => "sunset",
917        };
918
919        info!(
920            "Setting {} to {} for sauna {}",
921            light_name,
922            if on { "on" } else { "off" },
923            sauna_id
924        );
925
926        let timer = Timer::start();
927        let url = format!("{}/SaunaApp/LightChange", self.base_url);
928
929        let request = LightChangeRequest {
930            id: sauna_id.to_string(),
931            light_id: light_type.into(),
932            on_off: on,
933            brightness: brightness.unwrap_or(10),
934            color: color.unwrap_or(0),
935        };
936
937        let body = serde_json::to_string(&request)?;
938        let request_id = self
939            .debugger
940            .log_request(
941                "POST",
942                &url,
943                &reqwest::header::HeaderMap::new(),
944                Some(&body),
945            )
946            .await;
947
948        let response = self.client.post(&url).json(&request).send().await?;
949
950        let status = response.status();
951        let headers = response.headers().clone();
952        let response_text = response.text().await?;
953
954        self.debugger
955            .log_response(
956                &request_id,
957                status.as_u16(),
958                &headers,
959                Some(&response_text),
960                timer.elapsed_ms(),
961            )
962            .await;
963
964        self.check_response_status(status, &response_text)?;
965
966        info!(
967            "{} turned {} successfully",
968            light_name,
969            if on { "on" } else { "off" }
970        );
971        Ok(())
972    }
973
974    // NOTE: SetBathingTime API endpoint exists but doesn't actually work.
975    // The API accepts the request and returns success, but the sauna doesn't
976    // change its bathing time. This appears to be a server-side bug.
977
978    /// Configure multiple settings in a single API call
979    ///
980    /// This uses the PostConfigChange endpoint to set multiple parameters at once.
981    /// Only the parameters that are `Some` will be included in the request.
982    ///
983    /// # Arguments
984    ///
985    /// * `sauna_id` - UUID of the sauna
986    /// * `sauna_temperature` - Target temperature for sauna mode (10-100°C)
987    /// * `sanarium_temperature` - Target temperature for sanarium mode (40-75°C)
988    /// * `humidity_level` - Humidity level for sanarium mode (1-10)
989    /// * `hour` - Scheduled start hour (0-23)
990    /// * `minute` - Scheduled start minute (0-59)
991    #[instrument(skip(self), fields(sauna_id = %sauna_id))]
992    pub async fn configure(
993        &self,
994        sauna_id: &str,
995        sauna_temperature: Option<i32>,
996        sanarium_temperature: Option<i32>,
997        humidity_level: Option<i32>,
998        hour: Option<i32>,
999        minute: Option<i32>,
1000    ) -> Result<()> {
1001        Self::validate_sauna_id(sauna_id)?;
1002
1003        // Validate parameters if provided
1004        if let Some(temp) = sauna_temperature {
1005            Self::validate_temperature(temp)?;
1006        }
1007        if let Some(temp) = sanarium_temperature {
1008            Self::validate_sanarium_temperature(temp)?;
1009        }
1010        if let Some(level) = humidity_level {
1011            Self::validate_humidity_level(level)?;
1012        }
1013        if let Some(h) = hour {
1014            Self::validate_hour(h)?;
1015        }
1016        if let Some(m) = minute {
1017            Self::validate_minute(m)?;
1018        }
1019
1020        // Build info message
1021        let mut changes = Vec::new();
1022        if let Some(t) = sauna_temperature {
1023            changes.push(format!("sauna_temp={}°C", t));
1024        }
1025        if let Some(t) = sanarium_temperature {
1026            changes.push(format!("sanarium_temp={}°C", t));
1027        }
1028        if let Some(l) = humidity_level {
1029            changes.push(format!("humidity={}", l));
1030        }
1031        if hour.is_some() || minute.is_some() {
1032            changes.push(format!(
1033                "time={:02}:{:02}",
1034                hour.unwrap_or(0),
1035                minute.unwrap_or(0)
1036            ));
1037        }
1038
1039        if changes.is_empty() {
1040            return Err(KlafsError::InvalidParameter {
1041                message: "No configuration changes specified".to_string(),
1042            });
1043        }
1044
1045        if sauna_temperature.is_some() && sanarium_temperature.is_some() {
1046            return Err(KlafsError::InvalidParameter {
1047                message: "Cannot set both sauna and sanarium temperatures in one call. Set the mode and call configure twice."
1048                    .to_string(),
1049            });
1050        }
1051
1052        if sanarium_temperature.is_some() {
1053            let status = self.get_status(sauna_id).await?;
1054            if !status.sanarium_selected {
1055                return Err(KlafsError::InvalidParameter {
1056                    message: "Sanarium temperature can only be set when Sanarium mode is selected."
1057                        .to_string(),
1058                });
1059            }
1060        }
1061
1062        info!("Configuring sauna {}: {}", sauna_id, changes.join(", "));
1063
1064        // Apply changes using individual endpoints
1065        // Temperature change
1066        if let Some(temp) = sauna_temperature {
1067            self.set_temperature(sauna_id, temp).await?;
1068        }
1069
1070        // Humidity change (only works in Sanarium mode - set_humidity checks this)
1071        if let Some(level) = humidity_level {
1072            self.set_humidity(sauna_id, level).await?;
1073        }
1074
1075        if let Some(temp) = sanarium_temperature {
1076            self.set_temperature(sauna_id, temp).await?;
1077        }
1078
1079        // Time change
1080        if hour.is_some() || minute.is_some() {
1081            let h = hour.unwrap_or(0);
1082            let m = minute.unwrap_or(0);
1083            self.set_selected_time(sauna_id, Some((h, m))).await?;
1084        }
1085
1086        info!("Configuration applied successfully");
1087        Ok(())
1088    }
1089
1090    /// Check if the client has an active session
1091    pub fn is_logged_in(&self) -> bool {
1092        let base_url: Url = self.base_url.parse().expect("Invalid base URL");
1093        self.cookie_jar.cookies(&base_url).is_some()
1094    }
1095
1096    /// Helper to check response status and convert to errors
1097    fn check_response_status(
1098        &self,
1099        status: reqwest::StatusCode,
1100        response_text: &str,
1101    ) -> Result<()> {
1102        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
1103            return Err(KlafsError::SessionExpired);
1104        }
1105
1106        if !status.is_success() {
1107            return Err(KlafsError::ApiError {
1108                status_code: status.as_u16(),
1109                message: response_text.to_string(),
1110            });
1111        }
1112
1113        self.check_api_success(status, response_text)?;
1114
1115        Ok(())
1116    }
1117
1118    fn check_api_success(&self, status: reqwest::StatusCode, response_text: &str) -> Result<()> {
1119        let json = match serde_json::from_str::<serde_json::Value>(response_text) {
1120            Ok(json) => json,
1121            Err(_) => return Ok(()),
1122        };
1123
1124        if json
1125            .get("loginRequired")
1126            .and_then(|v| v.as_bool())
1127            .unwrap_or(false)
1128        {
1129            return Err(KlafsError::SessionExpired);
1130        }
1131
1132        let success = json
1133            .get("success")
1134            .and_then(|v| v.as_bool())
1135            .or_else(|| json.get("Success").and_then(|v| v.as_bool()));
1136
1137        if success == Some(false) {
1138            return Err(KlafsError::ApiError {
1139                status_code: status.as_u16(),
1140                message: Self::extract_error_message(&json),
1141            });
1142        }
1143
1144        Ok(())
1145    }
1146
1147    fn extract_error_message(json: &serde_json::Value) -> String {
1148        let keys = [
1149            "errorMessage",
1150            "ErrorMessage",
1151            "message",
1152            "Message",
1153            "errorMessageHeader",
1154            "ErrorMessageHeader",
1155        ];
1156
1157        for key in keys {
1158            if let Some(value) = json.get(key).and_then(|v| v.as_str()) {
1159                if !value.is_empty() {
1160                    return value.to_string();
1161                }
1162            }
1163        }
1164
1165        "Unknown error".to_string()
1166    }
1167
1168    fn has_auth_cookie(&self, headers: &reqwest::header::HeaderMap) -> bool {
1169        let header_has_cookie = headers
1170            .get_all(reqwest::header::SET_COOKIE)
1171            .iter()
1172            .any(|value| {
1173                value
1174                    .to_str()
1175                    .map(|s| s.contains(AUTH_COOKIE_NAME))
1176                    .unwrap_or(false)
1177            });
1178
1179        if header_has_cookie {
1180            return true;
1181        }
1182
1183        let base_url: Url = self.base_url.parse().expect("Invalid base URL");
1184        self.cookie_jar
1185            .cookies(&base_url)
1186            .and_then(|cookies| cookies.to_str().ok().map(|s| s.contains(AUTH_COOKIE_NAME)))
1187            .unwrap_or(false)
1188    }
1189
1190    /// Extract sauna information from the ChangeSettings HTML page
1191    fn extract_saunas_from_html(&self, html: &str) -> Result<Vec<SaunaInfo>> {
1192        let document = Html::parse_document(html);
1193        let mut saunas = Vec::new();
1194
1195        // Look for table rows with sauna data
1196        let row_selector = Selector::parse("tr.iw-sauna-webgrid-row-style").unwrap();
1197
1198        for row in document.select(&row_selector) {
1199            let id = row
1200                .value()
1201                .attr("data-sauna-id")
1202                .or_else(|| row.value().attr("data-id"))
1203                .map(|s| s.to_string());
1204
1205            let id = id.or_else(|| {
1206                let text = row.text().collect::<String>();
1207                Self::extract_guid(&text)
1208            });
1209
1210            let id = id.or_else(|| {
1211                let input_selector = Selector::parse("input[type='hidden']").unwrap();
1212                row.select(&input_selector)
1213                    .find_map(|input| input.value().attr("value"))
1214                    .and_then(|v| {
1215                        if Self::is_guid(v) {
1216                            Some(v.to_string())
1217                        } else {
1218                            None
1219                        }
1220                    })
1221            });
1222
1223            let name = Self::extract_sauna_name(&row);
1224
1225            if let (Some(id), Some(name)) = (id, name) {
1226                debug!("Found sauna: {} ({})", name, id);
1227                saunas.push(SaunaInfo { id, name });
1228            }
1229        }
1230
1231        // Fallback: try alternative selectors
1232        if saunas.is_empty() {
1233            let alt_selector = Selector::parse("[data-sauna-id], [data-saunaid]").unwrap();
1234            for element in document.select(&alt_selector) {
1235                let id = element
1236                    .value()
1237                    .attr("data-sauna-id")
1238                    .or_else(|| element.value().attr("data-saunaid"))
1239                    .map(|s| s.to_string());
1240
1241                let name = element.text().collect::<String>().trim().to_string();
1242                let name = if name.is_empty() {
1243                    element.value().attr("title").map(|s| s.to_string())
1244                } else {
1245                    Some(name)
1246                };
1247
1248                if let (Some(id), Some(name)) = (id, name) {
1249                    if !name.is_empty() {
1250                        saunas.push(SaunaInfo { id, name });
1251                    }
1252                }
1253            }
1254        }
1255
1256        Ok(saunas)
1257    }
1258
1259    fn extract_sauna_name(row: &scraper::ElementRef) -> Option<String> {
1260        let label_selectors = [
1261            "td.sauna-name",
1262            "td:first-child",
1263            ".sauna-label",
1264            "label",
1265            "span.name",
1266        ];
1267
1268        for selector_str in label_selectors {
1269            if let Ok(selector) = Selector::parse(selector_str) {
1270                if let Some(element) = row.select(&selector).next() {
1271                    let text = element.text().collect::<String>();
1272                    let text = text.trim();
1273                    if !text.is_empty() && !Self::is_guid(text) {
1274                        return Some(text.to_string());
1275                    }
1276                }
1277            }
1278        }
1279
1280        let all_text = row.text().collect::<Vec<_>>();
1281        for text in all_text {
1282            let text = text.trim();
1283            if !text.is_empty() && !Self::is_guid(text) && text.len() > 2 {
1284                return Some(text.to_string());
1285            }
1286        }
1287
1288        None
1289    }
1290
1291    fn is_guid(s: &str) -> bool {
1292        uuid::Uuid::parse_str(s.trim()).is_ok()
1293    }
1294
1295    /// Validate that a sauna ID is a valid UUID format
1296    fn validate_sauna_id(sauna_id: &str) -> Result<()> {
1297        if !Self::is_guid(sauna_id) {
1298            return Err(KlafsError::InvalidParameter {
1299                message: format!(
1300                    "Invalid sauna ID format '{}'. Expected a UUID (e.g., 364cc9db-86f1-49d1-86cd-f6ef9b20a490)",
1301                    sauna_id
1302                ),
1303            });
1304        }
1305        Ok(())
1306    }
1307
1308    /// Validate PIN format (must be exactly 4 digits)
1309    fn validate_pin(pin: &str) -> Result<()> {
1310        if pin.len() != 4 || !pin.chars().all(|c| c.is_ascii_digit()) {
1311            return Err(KlafsError::InvalidParameter {
1312                message: "PIN must be exactly 4 digits".to_string(),
1313            });
1314        }
1315        Ok(())
1316    }
1317
1318    fn validate_temperature(temperature: i32) -> Result<()> {
1319        validate_range!(temperature, 10, 100, "Temperature (°C)");
1320        Ok(())
1321    }
1322
1323    fn validate_sanarium_temperature(temperature: i32) -> Result<()> {
1324        validate_range!(temperature, 40, 75, "Sanarium temperature (°C)");
1325        Ok(())
1326    }
1327
1328    fn validate_humidity_level(level: i32) -> Result<()> {
1329        validate_range!(level, 1, 10, "Humidity level");
1330        Ok(())
1331    }
1332
1333    fn validate_hour(hour: i32) -> Result<()> {
1334        validate_range!(hour, 0, 23, "Hour");
1335        Ok(())
1336    }
1337
1338    fn validate_minute(minute: i32) -> Result<()> {
1339        validate_range!(minute, 0, 59, "Minute");
1340        Ok(())
1341    }
1342
1343    fn extract_guid(text: &str) -> Option<String> {
1344        let guid_pattern = regex_lite::Regex::new(
1345            r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
1346        )
1347        .ok()?;
1348
1349        guid_pattern.find(text).map(|m| m.as_str().to_lowercase())
1350    }
1351
1352    fn extract_verification_token(&self, html: &str) -> Result<String> {
1353        let document = Html::parse_document(html);
1354
1355        let input_selector = Selector::parse("input").unwrap();
1356        for element in document.select(&input_selector) {
1357            let name_or_id = element
1358                .value()
1359                .attr("name")
1360                .or_else(|| element.value().attr("id"));
1361            if let Some(name) = name_or_id {
1362                if name.to_lowercase().contains("requestverificationtoken") {
1363                    if let Some(value) = element.value().attr("value") {
1364                        return Ok(value.to_string());
1365                    }
1366                }
1367            }
1368        }
1369
1370        let meta_selector = Selector::parse("meta").unwrap();
1371        for element in document.select(&meta_selector) {
1372            let name_or_id = element
1373                .value()
1374                .attr("name")
1375                .or_else(|| element.value().attr("id"));
1376            if let Some(name) = name_or_id {
1377                if name.to_lowercase().contains("requestverificationtoken") {
1378                    if let Some(value) = element.value().attr("content") {
1379                        return Ok(value.to_string());
1380                    }
1381                }
1382            }
1383        }
1384
1385        let patterns = [
1386            r#"(?i)name=["']__requestverificationtoken["'][^>]*value=["']([^"']+)["']"#,
1387            r#"(?i)content=["']([^"']+)["'][^>]*name=["']__requestverificationtoken["']"#,
1388            r#"(?i)__requestverificationtoken["']\s*[:=]\s*["']([^"']+)["']"#,
1389        ];
1390
1391        for pattern in patterns {
1392            if let Ok(regex) = regex_lite::Regex::new(pattern) {
1393                if let Some(captures) = regex.captures(html) {
1394                    if let Some(value) = captures.get(1) {
1395                        return Ok(value.as_str().to_string());
1396                    }
1397                }
1398            }
1399        }
1400
1401        Err(KlafsError::VerificationTokenNotFound)
1402    }
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407    use super::*;
1408
1409    #[test]
1410    fn test_client_creation() {
1411        let client = KlafsClient::new().unwrap();
1412        assert!(!client.is_logged_in());
1413    }
1414
1415    #[test]
1416    fn test_client_with_config() {
1417        let config = ClientConfig::for_testing("http://localhost:8080");
1418        let client = KlafsClient::with_config(config).unwrap();
1419        assert!(!client.is_logged_in());
1420        assert!(client.debugger().is_enabled());
1421    }
1422
1423    #[test]
1424    fn test_extract_verification_token() {
1425        let client = KlafsClient::new().unwrap();
1426
1427        let html = r#"
1428            <html>
1429                <body>
1430                    <form>
1431                        <input name="__RequestVerificationToken" type="hidden" value="test-token-123" />
1432                    </form>
1433                </body>
1434            </html>
1435        "#;
1436
1437        let token = client.extract_verification_token(html).unwrap();
1438        assert_eq!(token, "test-token-123");
1439    }
1440
1441    #[test]
1442    fn test_extract_verification_token_not_found() {
1443        let client = KlafsClient::new().unwrap();
1444        let html = "<html><body>No token here</body></html>";
1445
1446        let result = client.extract_verification_token(html);
1447        assert!(matches!(result, Err(KlafsError::VerificationTokenNotFound)));
1448    }
1449
1450    #[test]
1451    fn test_extract_verification_token_from_script() {
1452        let client = KlafsClient::new().unwrap();
1453        let html = r#"
1454            <html>
1455                <head>
1456                    <script>
1457                        window.config = {"__RequestVerificationToken":"script-token-456"};
1458                    </script>
1459                </head>
1460            </html>
1461        "#;
1462
1463        let token = client.extract_verification_token(html).unwrap();
1464        assert_eq!(token, "script-token-456");
1465    }
1466
1467    #[test]
1468    fn test_extract_verification_token_from_id() {
1469        let client = KlafsClient::new().unwrap();
1470        let html = r#"
1471            <html>
1472                <body>
1473                    <input id="__RequestVerificationToken" type="hidden" value="id-token-789" />
1474                </body>
1475            </html>
1476        "#;
1477
1478        let token = client.extract_verification_token(html).unwrap();
1479        assert_eq!(token, "id-token-789");
1480    }
1481
1482    #[test]
1483    fn test_is_guid() {
1484        assert!(KlafsClient::is_guid("364cc9db-86f1-49d1-86cd-f6ef9b20a490"));
1485        assert!(KlafsClient::is_guid("00000000-0000-0000-0000-000000000000"));
1486        assert!(KlafsClient::is_guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"));
1487
1488        assert!(!KlafsClient::is_guid("not-a-guid"));
1489        assert!(!KlafsClient::is_guid("364cc9db-86f1-49d1-86cd"));
1490        assert!(!KlafsClient::is_guid(
1491            "364cc9db-86f1-49d1-86cd-f6ef9b20a490-extra"
1492        ));
1493        assert!(!KlafsClient::is_guid(
1494            "364cc9db_86f1_49d1_86cd_f6ef9b20a490"
1495        ));
1496    }
1497
1498    #[test]
1499    fn test_extract_guid() {
1500        assert_eq!(
1501            KlafsClient::extract_guid("Sauna ID: 364cc9db-86f1-49d1-86cd-f6ef9b20a490"),
1502            Some("364cc9db-86f1-49d1-86cd-f6ef9b20a490".to_string())
1503        );
1504
1505        assert_eq!(KlafsClient::extract_guid("No GUID here"), None);
1506
1507        let text = "First: 11111111-1111-1111-1111-111111111111, Second: 22222222-2222-2222-2222-222222222222";
1508        assert_eq!(
1509            KlafsClient::extract_guid(text),
1510            Some("11111111-1111-1111-1111-111111111111".to_string())
1511        );
1512    }
1513
1514    #[test]
1515    fn test_extract_saunas_from_html() {
1516        let client = KlafsClient::new().unwrap();
1517
1518        let html = r#"
1519            <html>
1520                <body>
1521                    <table>
1522                        <tr class="iw-sauna-webgrid-row-style" data-sauna-id="364cc9db-86f1-49d1-86cd-f6ef9b20a490">
1523                            <td class="sauna-name">My Sauna</td>
1524                        </tr>
1525                    </table>
1526                </body>
1527            </html>
1528        "#;
1529
1530        let saunas = client.extract_saunas_from_html(html).unwrap();
1531        assert_eq!(saunas.len(), 1);
1532        assert_eq!(saunas[0].id, "364cc9db-86f1-49d1-86cd-f6ef9b20a490");
1533        assert_eq!(saunas[0].name, "My Sauna");
1534    }
1535
1536    // ===== Validation Unit Tests =====
1537
1538    #[test]
1539    fn test_validate_sauna_id_valid() {
1540        assert!(KlafsClient::validate_sauna_id("364cc9db-86f1-49d1-86cd-f6ef9b20a490").is_ok());
1541        assert!(KlafsClient::validate_sauna_id("00000000-0000-0000-0000-000000000000").is_ok());
1542        assert!(KlafsClient::validate_sauna_id("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE").is_ok());
1543    }
1544
1545    #[test]
1546    fn test_validate_sauna_id_invalid() {
1547        assert!(KlafsClient::validate_sauna_id("").is_err());
1548        assert!(KlafsClient::validate_sauna_id("not-a-uuid").is_err());
1549        assert!(KlafsClient::validate_sauna_id("364cc9db-86f1-49d1").is_err());
1550        assert!(
1551            KlafsClient::validate_sauna_id("364cc9db-86f1-49d1-86cd-f6ef9b20a490-extra").is_err()
1552        );
1553    }
1554
1555    #[test]
1556    fn test_validate_pin_valid() {
1557        assert!(KlafsClient::validate_pin("1234").is_ok());
1558        assert!(KlafsClient::validate_pin("0000").is_ok());
1559        assert!(KlafsClient::validate_pin("9999").is_ok());
1560    }
1561
1562    #[test]
1563    fn test_validate_pin_invalid() {
1564        // Too short
1565        assert!(KlafsClient::validate_pin("123").is_err());
1566        // Too long
1567        assert!(KlafsClient::validate_pin("12345").is_err());
1568        // Non-numeric
1569        assert!(KlafsClient::validate_pin("abcd").is_err());
1570        assert!(KlafsClient::validate_pin("12a4").is_err());
1571        // Empty
1572        assert!(KlafsClient::validate_pin("").is_err());
1573        // Special characters
1574        assert!(KlafsClient::validate_pin("12-4").is_err());
1575    }
1576
1577    #[test]
1578    fn test_validate_temperature_valid() {
1579        assert!(KlafsClient::validate_temperature(10).is_ok());
1580        assert!(KlafsClient::validate_temperature(50).is_ok());
1581        assert!(KlafsClient::validate_temperature(100).is_ok());
1582    }
1583
1584    #[test]
1585    fn test_validate_temperature_invalid() {
1586        assert!(KlafsClient::validate_temperature(9).is_err());
1587        assert!(KlafsClient::validate_temperature(101).is_err());
1588        assert!(KlafsClient::validate_temperature(0).is_err());
1589        assert!(KlafsClient::validate_temperature(-10).is_err());
1590        assert!(KlafsClient::validate_temperature(150).is_err());
1591    }
1592
1593    #[test]
1594    fn test_validate_sanarium_temperature_valid() {
1595        assert!(KlafsClient::validate_sanarium_temperature(40).is_ok());
1596        assert!(KlafsClient::validate_sanarium_temperature(60).is_ok());
1597        assert!(KlafsClient::validate_sanarium_temperature(75).is_ok());
1598    }
1599
1600    #[test]
1601    fn test_validate_sanarium_temperature_invalid() {
1602        assert!(KlafsClient::validate_sanarium_temperature(39).is_err());
1603        assert!(KlafsClient::validate_sanarium_temperature(76).is_err());
1604        assert!(KlafsClient::validate_sanarium_temperature(10).is_err());
1605        assert!(KlafsClient::validate_sanarium_temperature(100).is_err());
1606    }
1607
1608    #[test]
1609    fn test_validate_humidity_level_valid() {
1610        assert!(KlafsClient::validate_humidity_level(1).is_ok());
1611        assert!(KlafsClient::validate_humidity_level(5).is_ok());
1612        assert!(KlafsClient::validate_humidity_level(10).is_ok());
1613    }
1614
1615    #[test]
1616    fn test_validate_humidity_level_invalid() {
1617        assert!(KlafsClient::validate_humidity_level(0).is_err());
1618        assert!(KlafsClient::validate_humidity_level(11).is_err());
1619        assert!(KlafsClient::validate_humidity_level(-1).is_err());
1620        assert!(KlafsClient::validate_humidity_level(100).is_err());
1621    }
1622
1623    #[test]
1624    fn test_validate_hour_valid() {
1625        assert!(KlafsClient::validate_hour(0).is_ok());
1626        assert!(KlafsClient::validate_hour(12).is_ok());
1627        assert!(KlafsClient::validate_hour(23).is_ok());
1628    }
1629
1630    #[test]
1631    fn test_validate_hour_invalid() {
1632        assert!(KlafsClient::validate_hour(-1).is_err());
1633        assert!(KlafsClient::validate_hour(24).is_err());
1634        assert!(KlafsClient::validate_hour(100).is_err());
1635    }
1636
1637    #[test]
1638    fn test_validate_minute_valid() {
1639        assert!(KlafsClient::validate_minute(0).is_ok());
1640        assert!(KlafsClient::validate_minute(30).is_ok());
1641        assert!(KlafsClient::validate_minute(59).is_ok());
1642    }
1643
1644    #[test]
1645    fn test_validate_minute_invalid() {
1646        assert!(KlafsClient::validate_minute(-1).is_err());
1647        assert!(KlafsClient::validate_minute(60).is_err());
1648        assert!(KlafsClient::validate_minute(100).is_err());
1649    }
1650}