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
15macro_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
32pub const DEFAULT_BASE_URL: &str = "https://sauna-app-19.klafs.com";
34
35const USER_AGENT: &str = "KlafsSaunaApp/1.0";
37const AUTH_COOKIE_NAME: &str = ".ASPXAUTH";
38
39#[derive(Debug, Clone)]
41pub struct ClientConfig {
42 pub base_url: String,
44 pub debug: DebugConfig,
46 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 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
71pub 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 pub fn new() -> Result<Self> {
111 Self::with_config(ClientConfig::default())
112 }
113
114 pub fn with_config(config: ClientConfig) -> Result<Self> {
116 let cookie_jar = Arc::new(Jar::default());
117
118 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 pub fn debugger(&self) -> &HttpDebugger {
143 &self.debugger
144 }
145
146 pub fn enable_debug(&self) {
148 self.debugger.enable();
149 }
150
151 pub fn disable_debug(&self) {
153 self.debugger.disable();
154 }
155
156 #[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 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 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 let login_url = format!("{}/Account/Login", self.base_url);
225
226 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 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 if status.is_success() || status.is_redirection() {
292 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 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 #[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 #[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 #[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 Self::validate_sauna_id(sauna_id)?;
451
452 Self::validate_pin(pin)?;
454
455 let (time_selected, sel_hour, sel_min) = match schedule {
456 Some((hour, minute)) => {
457 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 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 (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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 #[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 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 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 if let Some(temp) = sauna_temperature {
1067 self.set_temperature(sauna_id, temp).await?;
1068 }
1069
1070 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 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 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 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 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 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 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 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 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 #[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 assert!(KlafsClient::validate_pin("123").is_err());
1566 assert!(KlafsClient::validate_pin("12345").is_err());
1568 assert!(KlafsClient::validate_pin("abcd").is_err());
1570 assert!(KlafsClient::validate_pin("12a4").is_err());
1571 assert!(KlafsClient::validate_pin("").is_err());
1573 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}