use std::sync::Arc;
use reqwest::cookie::{CookieStore, Jar};
use reqwest::{Client, Url};
use scraper::{Html, Selector};
use tracing::{debug, info, instrument, warn};
use crate::debug::{DebugConfig, HttpDebugger, Timer};
use crate::error::{KlafsError, Result};
use crate::models::{
LightChangeRequest, LightType, PowerControlRequest, SaunaInfo, SaunaMode, SaunaStatus,
SetHumidityRequest, SetModeRequest, SetSelectedTimeRequest, SetTemperatureRequest,
};
macro_rules! validate_range {
($value:expr, $min:expr, $max:expr, $name:expr) => {
if !($min..=$max).contains(&$value) {
return Err(KlafsError::InvalidParameter {
message: format!(
"{} must be between {} and {}, got {}",
$name, $min, $max, $value
),
});
}
};
}
pub const DEFAULT_BASE_URL: &str = "https://sauna-app-19.klafs.com";
const USER_AGENT: &str = "KlafsSaunaApp/1.0";
const AUTH_COOKIE_NAME: &str = ".ASPXAUTH";
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub base_url: String,
pub debug: DebugConfig,
pub timeout_secs: u64,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_string(),
debug: DebugConfig::default(),
timeout_secs: 30,
}
}
}
impl ClientConfig {
pub fn for_testing(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
debug: DebugConfig::enabled(),
timeout_secs: 5,
}
}
}
pub struct KlafsClient {
client: Client,
cookie_jar: Arc<Jar>,
base_url: String,
verification_token: std::sync::RwLock<Option<String>>,
debugger: Arc<HttpDebugger>,
}
impl std::fmt::Debug for KlafsClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KlafsClient")
.field("base_url", &self.base_url)
.field("is_logged_in", &self.is_logged_in())
.finish()
}
}
impl KlafsClient {
pub fn new() -> Result<Self> {
Self::with_config(ClientConfig::default())
}
pub fn with_config(config: ClientConfig) -> Result<Self> {
let cookie_jar = Arc::new(Jar::default());
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
"X-Requested-With",
reqwest::header::HeaderValue::from_static("XMLHttpRequest"),
);
let client = Client::builder()
.cookie_provider(cookie_jar.clone())
.user_agent(USER_AGENT)
.default_headers(default_headers)
.timeout(std::time::Duration::from_secs(config.timeout_secs))
.build()?;
Ok(Self {
client,
cookie_jar,
base_url: config.base_url,
verification_token: std::sync::RwLock::new(None),
debugger: Arc::new(HttpDebugger::new(config.debug)),
})
}
pub fn debugger(&self) -> &HttpDebugger {
&self.debugger
}
pub fn enable_debug(&self) {
self.debugger.enable();
}
pub fn disable_debug(&self) {
self.debugger.disable();
}
#[instrument(skip(self, password), fields(username = %username))]
pub async fn login(&self, username: &str, password: &str) -> Result<()> {
info!("Logging in as {}", username);
let timer = Timer::start();
let login_page_url = format!("{}/Account/Login", self.base_url);
let request_id = self
.debugger
.log_request(
"GET",
&login_page_url,
&reqwest::header::HeaderMap::new(),
None,
)
.await;
let login_page_response = self.client.get(&login_page_url).send().await?;
let status_code = login_page_response.status();
let headers = login_page_response.headers().clone();
let login_page_html = login_page_response.text().await?;
self.debugger
.log_response(
&request_id,
status_code.as_u16(),
&headers,
Some(&login_page_html),
timer.elapsed_ms(),
)
.await;
if !status_code.is_success() {
return Err(KlafsError::ApiError {
status_code: status_code.as_u16(),
message: "Failed to load login page".to_string(),
});
}
let token = self.extract_verification_token(&login_page_html).ok();
if token.is_some() {
debug!("Extracted verification token");
} else {
debug!("No verification token found in login form (may not be required)");
}
let login_url = format!("{}/Account/Login", self.base_url);
let mut form_params: Vec<(&str, &str)> = vec![
("UserName", username),
("Password", password),
("RememberMe", "false"),
];
if let Some(ref t) = token {
form_params.push(("__RequestVerificationToken", t.as_str()));
}
let form_body = form_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
let timer = Timer::start();
let request_id = self
.debugger
.log_request(
"POST",
&login_url,
&reqwest::header::HeaderMap::new(),
Some(&form_body),
)
.await;
let response = self
.client
.post(&login_url)
.form(&form_params)
.send()
.await?;
let status = response.status();
let headers = response.headers().clone();
let response_text = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&response_text),
timer.elapsed_ms(),
)
.await;
if response_text.contains("Sicherheitskontrolle")
|| response_text.contains("security check")
{
warn!("Account may be locked due to security check");
return Err(KlafsError::AccountLocked);
}
if response_text.contains("Invalid login") || response_text.contains("Ungültige Anmeldung")
{
return Err(KlafsError::AuthenticationFailed {
message: "Invalid username or password".to_string(),
});
}
if status.is_success() || status.is_redirection() {
if let Ok(new_token) = self.extract_verification_token(&response_text) {
let mut token_guard = self.verification_token.write().unwrap();
*token_guard = Some(new_token);
}
if !self.has_auth_cookie(&headers) {
return Err(KlafsError::AuthenticationFailed {
message: "No authentication cookie received".to_string(),
});
}
info!("Login successful");
Ok(())
} else {
Err(KlafsError::ApiError {
status_code: status.as_u16(),
message: format!("Login failed with status {}", status),
})
}
}
#[instrument(skip(self), fields(sauna_id = %sauna_id))]
pub async fn get_status(&self, sauna_id: &str) -> Result<SaunaStatus> {
Self::validate_sauna_id(sauna_id)?;
debug!("Getting status for sauna {}", sauna_id);
let timer = Timer::start();
let url = format!("{}/SaunaApp/GetData?id={}", self.base_url, sauna_id);
let request_id = self
.debugger
.log_request("GET", &url, &reqwest::header::HeaderMap::new(), None)
.await;
let response = self.client.get(&url).send().await?;
let status = response.status();
let headers = response.headers().clone();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(KlafsError::SessionExpired);
}
let response_text = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&response_text),
timer.elapsed_ms(),
)
.await;
if !status.is_success() {
return Err(KlafsError::ApiError {
status_code: status.as_u16(),
message: response_text,
});
}
let sauna_status: SaunaStatus = serde_json::from_str(&response_text)?;
if sauna_status.login_required {
return Err(KlafsError::SessionExpired);
}
if !sauna_status.success {
let message = if !sauna_status.error_message.is_empty() {
sauna_status.error_message.clone()
} else if !sauna_status.error_message_header.is_empty() {
sauna_status.error_message_header.clone()
} else if let Some(status_message) = sauna_status.status_message.as_ref() {
status_message.clone()
} else {
"Unknown error".to_string()
};
return Err(KlafsError::ApiError {
status_code: status.as_u16(),
message,
});
}
debug!(
"Sauna {} status: connected={}, powered={}",
sauna_id, sauna_status.is_connected, sauna_status.is_powered_on
);
Ok(sauna_status)
}
#[instrument(skip(self))]
pub async fn list_saunas(&self) -> Result<Vec<SaunaInfo>> {
debug!("Fetching list of saunas");
let timer = Timer::start();
let url = format!("{}/SaunaApp/ChangeSettings", self.base_url);
let request_id = self
.debugger
.log_request("GET", &url, &reqwest::header::HeaderMap::new(), None)
.await;
let response = self.client.get(&url).send().await?;
let status = response.status();
let headers = response.headers().clone();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(KlafsError::SessionExpired);
}
let html = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&html),
timer.elapsed_ms(),
)
.await;
if !status.is_success() {
return Err(KlafsError::ApiError {
status_code: status.as_u16(),
message: html,
});
}
let saunas = self.extract_saunas_from_html(&html)?;
info!("Found {} sauna(s)", saunas.len());
Ok(saunas)
}
#[instrument(skip(self, pin), fields(sauna_id = %sauna_id))]
pub async fn power_on(
&self,
sauna_id: &str,
pin: &str,
schedule: Option<(i32, i32)>,
) -> Result<()> {
Self::validate_sauna_id(sauna_id)?;
Self::validate_pin(pin)?;
let (time_selected, sel_hour, sel_min) = match schedule {
Some((hour, minute)) => {
Self::validate_hour(hour)?;
Self::validate_minute(minute)?;
info!(
"Scheduling sauna {} to start at {:02}:{:02}",
sauna_id, hour, minute
);
self.set_selected_time(sauna_id, Some((hour, minute)))
.await?;
(true, hour, minute)
}
None => {
info!("Powering on sauna {} immediately", sauna_id);
(false, 0, 0)
}
};
let timer = Timer::start();
let url = format!("{}/SaunaApp/StartCabin", self.base_url);
let request = PowerControlRequest {
id: sauna_id.to_string(),
pin: pin.to_string(),
time_selected,
sel_hour,
sel_min,
};
let body = serde_json::to_string(&request)?;
let request_id = self
.debugger
.log_request(
"POST",
&url,
&reqwest::header::HeaderMap::new(),
Some(&body),
)
.await;
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
let headers = response.headers().clone();
let response_text = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&response_text),
timer.elapsed_ms(),
)
.await;
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(KlafsError::SessionExpired);
}
if response_text.contains("PIN") && response_text.contains("invalid") {
return Err(KlafsError::InvalidPin);
}
self.check_response_status(status, &response_text)?;
match schedule {
Some((hour, minute)) => {
info!("Sauna {} scheduled for {:02}:{:02}", sauna_id, hour, minute)
}
None => info!("Sauna {} powered on", sauna_id),
}
Ok(())
}
#[instrument(skip(self), fields(sauna_id = %sauna_id))]
pub async fn power_off(&self, sauna_id: &str) -> Result<()> {
Self::validate_sauna_id(sauna_id)?;
info!("Powering off sauna {}", sauna_id);
let timer = Timer::start();
let url = format!("{}/SaunaApp/StopCabin", self.base_url);
let request = serde_json::json!({
"id": sauna_id
});
let body = serde_json::to_string(&request)?;
let request_id = self
.debugger
.log_request(
"POST",
&url,
&reqwest::header::HeaderMap::new(),
Some(&body),
)
.await;
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
let headers = response.headers().clone();
let response_text = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&response_text),
timer.elapsed_ms(),
)
.await;
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(KlafsError::SessionExpired);
}
if !status.is_success() {
return Err(KlafsError::ApiError {
status_code: status.as_u16(),
message: response_text,
});
}
info!("Sauna {} powered off", sauna_id);
Ok(())
}
#[instrument(skip(self), fields(sauna_id = %sauna_id, mode = ?mode))]
pub async fn set_mode(&self, sauna_id: &str, mode: SaunaMode) -> Result<()> {
Self::validate_sauna_id(sauna_id)?;
info!("Setting mode to {:?} for sauna {}", mode, sauna_id);
let timer = Timer::start();
let url = format!("{}/SaunaApp/SetMode", self.base_url);
let request = SetModeRequest {
id: sauna_id.to_string(),
selected_mode: mode.into(),
};
let body = serde_json::to_string(&request)?;
let request_id = self
.debugger
.log_request(
"POST",
&url,
&reqwest::header::HeaderMap::new(),
Some(&body),
)
.await;
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
let headers = response.headers().clone();
let response_text = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&response_text),
timer.elapsed_ms(),
)
.await;
self.check_response_status(status, &response_text)?;
info!("Mode set to {:?}", mode);
Ok(())
}
#[instrument(skip(self), fields(sauna_id = %sauna_id, temperature = %temperature))]
pub async fn set_temperature(&self, sauna_id: &str, temperature: i32) -> Result<()> {
Self::validate_sauna_id(sauna_id)?;
Self::validate_temperature(temperature)?;
let status = self.get_status(sauna_id).await?;
if status.sanarium_selected {
Self::validate_sanarium_temperature(temperature)?;
}
info!(
"Setting temperature to {}°C for sauna {}",
temperature, sauna_id
);
let timer = Timer::start();
let url = format!("{}/SaunaApp/ChangeTemperature", self.base_url);
let request = SetTemperatureRequest {
id: sauna_id.to_string(),
temperature,
};
let body = serde_json::to_string(&request)?;
let request_id = self
.debugger
.log_request(
"POST",
&url,
&reqwest::header::HeaderMap::new(),
Some(&body),
)
.await;
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
let headers = response.headers().clone();
let response_text = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&response_text),
timer.elapsed_ms(),
)
.await;
self.check_response_status(status, &response_text)?;
info!("Temperature set to {}°C", temperature);
Ok(())
}
#[instrument(skip(self), fields(sauna_id = %sauna_id, level = %level))]
pub async fn set_humidity(&self, sauna_id: &str, level: i32) -> Result<()> {
Self::validate_sauna_id(sauna_id)?;
Self::validate_humidity_level(level)?;
let status = self.get_status(sauna_id).await?;
if !status.sanarium_selected {
return Err(KlafsError::InvalidParameter {
message:
"Humidity can only be set in Sanarium mode. Use 'set-mode sanarium' first."
.to_string(),
});
}
info!("Setting humidity level to {} for sauna {}", level, sauna_id);
let timer = Timer::start();
let url = format!("{}/SaunaApp/ChangeHumLevel", self.base_url);
let request = SetHumidityRequest {
id: sauna_id.to_string(),
level,
};
let body = serde_json::to_string(&request)?;
let request_id = self
.debugger
.log_request(
"POST",
&url,
&reqwest::header::HeaderMap::new(),
Some(&body),
)
.await;
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
let headers = response.headers().clone();
let response_text = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&response_text),
timer.elapsed_ms(),
)
.await;
self.check_response_status(status, &response_text)?;
info!("Humidity level set to {}", level);
Ok(())
}
#[instrument(skip(self), fields(sauna_id = %sauna_id, hour = %hour, minute = %minute))]
pub async fn set_start_time(&self, sauna_id: &str, hour: i32, minute: i32) -> Result<()> {
self.set_selected_time(sauna_id, Some((hour, minute))).await
}
#[instrument(skip(self), fields(sauna_id = %sauna_id))]
pub async fn set_selected_time(&self, sauna_id: &str, time: Option<(i32, i32)>) -> Result<()> {
Self::validate_sauna_id(sauna_id)?;
let (time_set, hours, minutes) = match time {
Some((hour, minute)) => {
Self::validate_hour(hour)?;
Self::validate_minute(minute)?;
info!(
"Setting scheduled time to {:02}:{:02} for sauna {}",
hour, minute, sauna_id
);
(true, hour, minute)
}
None => {
info!("Clearing scheduled time for sauna {}", sauna_id);
(false, 0, 0)
}
};
let timer = Timer::start();
let url = format!("{}/SaunaApp/SetSelectedTime", self.base_url);
let request = SetSelectedTimeRequest {
id: sauna_id.to_string(),
time_set,
hours,
minutes,
};
let body = serde_json::to_string(&request)?;
let request_id = self
.debugger
.log_request(
"POST",
&url,
&reqwest::header::HeaderMap::new(),
Some(&body),
)
.await;
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
let headers = response.headers().clone();
let response_text = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&response_text),
timer.elapsed_ms(),
)
.await;
self.check_response_status(status, &response_text)?;
match time {
Some((hour, minute)) => info!("Scheduled time set to {:02}:{:02}", hour, minute),
None => info!("Scheduled time cleared"),
}
Ok(())
}
#[instrument(skip(self), fields(sauna_id = %sauna_id))]
pub async fn set_light(&self, sauna_id: &str, on: bool, brightness: Option<i32>) -> Result<()> {
self.set_light_internal(sauna_id, LightType::Main, on, brightness, None)
.await
}
#[instrument(skip(self), fields(sauna_id = %sauna_id))]
pub async fn set_sunset(
&self,
sauna_id: &str,
on: bool,
brightness: Option<i32>,
) -> Result<()> {
self.set_light_internal(sauna_id, LightType::Sunset, on, brightness, None)
.await
}
async fn set_light_internal(
&self,
sauna_id: &str,
light_type: LightType,
on: bool,
brightness: Option<i32>,
color: Option<i32>,
) -> Result<()> {
Self::validate_sauna_id(sauna_id)?;
if let Some(b) = brightness {
if !(1..=10).contains(&b) {
return Err(KlafsError::InvalidParameter {
message: format!("Brightness must be between 1 and 10, got {}", b),
});
}
}
let light_name = match light_type {
LightType::Main => "main light",
LightType::Color => "color light",
LightType::Sunset => "sunset",
};
info!(
"Setting {} to {} for sauna {}",
light_name,
if on { "on" } else { "off" },
sauna_id
);
let timer = Timer::start();
let url = format!("{}/SaunaApp/LightChange", self.base_url);
let request = LightChangeRequest {
id: sauna_id.to_string(),
light_id: light_type.into(),
on_off: on,
brightness: brightness.unwrap_or(10),
color: color.unwrap_or(0),
};
let body = serde_json::to_string(&request)?;
let request_id = self
.debugger
.log_request(
"POST",
&url,
&reqwest::header::HeaderMap::new(),
Some(&body),
)
.await;
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
let headers = response.headers().clone();
let response_text = response.text().await?;
self.debugger
.log_response(
&request_id,
status.as_u16(),
&headers,
Some(&response_text),
timer.elapsed_ms(),
)
.await;
self.check_response_status(status, &response_text)?;
info!(
"{} turned {} successfully",
light_name,
if on { "on" } else { "off" }
);
Ok(())
}
#[instrument(skip(self), fields(sauna_id = %sauna_id))]
pub async fn configure(
&self,
sauna_id: &str,
sauna_temperature: Option<i32>,
sanarium_temperature: Option<i32>,
humidity_level: Option<i32>,
hour: Option<i32>,
minute: Option<i32>,
) -> Result<()> {
Self::validate_sauna_id(sauna_id)?;
if let Some(temp) = sauna_temperature {
Self::validate_temperature(temp)?;
}
if let Some(temp) = sanarium_temperature {
Self::validate_sanarium_temperature(temp)?;
}
if let Some(level) = humidity_level {
Self::validate_humidity_level(level)?;
}
if let Some(h) = hour {
Self::validate_hour(h)?;
}
if let Some(m) = minute {
Self::validate_minute(m)?;
}
let mut changes = Vec::new();
if let Some(t) = sauna_temperature {
changes.push(format!("sauna_temp={}°C", t));
}
if let Some(t) = sanarium_temperature {
changes.push(format!("sanarium_temp={}°C", t));
}
if let Some(l) = humidity_level {
changes.push(format!("humidity={}", l));
}
if hour.is_some() || minute.is_some() {
changes.push(format!(
"time={:02}:{:02}",
hour.unwrap_or(0),
minute.unwrap_or(0)
));
}
if changes.is_empty() {
return Err(KlafsError::InvalidParameter {
message: "No configuration changes specified".to_string(),
});
}
if sauna_temperature.is_some() && sanarium_temperature.is_some() {
return Err(KlafsError::InvalidParameter {
message: "Cannot set both sauna and sanarium temperatures in one call. Set the mode and call configure twice."
.to_string(),
});
}
if sanarium_temperature.is_some() {
let status = self.get_status(sauna_id).await?;
if !status.sanarium_selected {
return Err(KlafsError::InvalidParameter {
message: "Sanarium temperature can only be set when Sanarium mode is selected."
.to_string(),
});
}
}
info!("Configuring sauna {}: {}", sauna_id, changes.join(", "));
if let Some(temp) = sauna_temperature {
self.set_temperature(sauna_id, temp).await?;
}
if let Some(level) = humidity_level {
self.set_humidity(sauna_id, level).await?;
}
if let Some(temp) = sanarium_temperature {
self.set_temperature(sauna_id, temp).await?;
}
if hour.is_some() || minute.is_some() {
let h = hour.unwrap_or(0);
let m = minute.unwrap_or(0);
self.set_selected_time(sauna_id, Some((h, m))).await?;
}
info!("Configuration applied successfully");
Ok(())
}
pub fn is_logged_in(&self) -> bool {
let base_url: Url = self.base_url.parse().expect("Invalid base URL");
self.cookie_jar.cookies(&base_url).is_some()
}
fn check_response_status(
&self,
status: reqwest::StatusCode,
response_text: &str,
) -> Result<()> {
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(KlafsError::SessionExpired);
}
if !status.is_success() {
return Err(KlafsError::ApiError {
status_code: status.as_u16(),
message: response_text.to_string(),
});
}
self.check_api_success(status, response_text)?;
Ok(())
}
fn check_api_success(&self, status: reqwest::StatusCode, response_text: &str) -> Result<()> {
let json = match serde_json::from_str::<serde_json::Value>(response_text) {
Ok(json) => json,
Err(_) => return Ok(()),
};
if json
.get("loginRequired")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Err(KlafsError::SessionExpired);
}
let success = json
.get("success")
.and_then(|v| v.as_bool())
.or_else(|| json.get("Success").and_then(|v| v.as_bool()));
if success == Some(false) {
return Err(KlafsError::ApiError {
status_code: status.as_u16(),
message: Self::extract_error_message(&json),
});
}
Ok(())
}
fn extract_error_message(json: &serde_json::Value) -> String {
let keys = [
"errorMessage",
"ErrorMessage",
"message",
"Message",
"errorMessageHeader",
"ErrorMessageHeader",
];
for key in keys {
if let Some(value) = json.get(key).and_then(|v| v.as_str()) {
if !value.is_empty() {
return value.to_string();
}
}
}
"Unknown error".to_string()
}
fn has_auth_cookie(&self, headers: &reqwest::header::HeaderMap) -> bool {
let header_has_cookie = headers
.get_all(reqwest::header::SET_COOKIE)
.iter()
.any(|value| {
value
.to_str()
.map(|s| s.contains(AUTH_COOKIE_NAME))
.unwrap_or(false)
});
if header_has_cookie {
return true;
}
let base_url: Url = self.base_url.parse().expect("Invalid base URL");
self.cookie_jar
.cookies(&base_url)
.and_then(|cookies| cookies.to_str().ok().map(|s| s.contains(AUTH_COOKIE_NAME)))
.unwrap_or(false)
}
fn extract_saunas_from_html(&self, html: &str) -> Result<Vec<SaunaInfo>> {
let document = Html::parse_document(html);
let mut saunas = Vec::new();
let row_selector = Selector::parse("tr.iw-sauna-webgrid-row-style").unwrap();
for row in document.select(&row_selector) {
let id = row
.value()
.attr("data-sauna-id")
.or_else(|| row.value().attr("data-id"))
.map(|s| s.to_string());
let id = id.or_else(|| {
let text = row.text().collect::<String>();
Self::extract_guid(&text)
});
let id = id.or_else(|| {
let input_selector = Selector::parse("input[type='hidden']").unwrap();
row.select(&input_selector)
.find_map(|input| input.value().attr("value"))
.and_then(|v| {
if Self::is_guid(v) {
Some(v.to_string())
} else {
None
}
})
});
let name = Self::extract_sauna_name(&row);
if let (Some(id), Some(name)) = (id, name) {
debug!("Found sauna: {} ({})", name, id);
saunas.push(SaunaInfo { id, name });
}
}
if saunas.is_empty() {
let alt_selector = Selector::parse("[data-sauna-id], [data-saunaid]").unwrap();
for element in document.select(&alt_selector) {
let id = element
.value()
.attr("data-sauna-id")
.or_else(|| element.value().attr("data-saunaid"))
.map(|s| s.to_string());
let name = element.text().collect::<String>().trim().to_string();
let name = if name.is_empty() {
element.value().attr("title").map(|s| s.to_string())
} else {
Some(name)
};
if let (Some(id), Some(name)) = (id, name) {
if !name.is_empty() {
saunas.push(SaunaInfo { id, name });
}
}
}
}
Ok(saunas)
}
fn extract_sauna_name(row: &scraper::ElementRef) -> Option<String> {
let label_selectors = [
"td.sauna-name",
"td:first-child",
".sauna-label",
"label",
"span.name",
];
for selector_str in label_selectors {
if let Ok(selector) = Selector::parse(selector_str) {
if let Some(element) = row.select(&selector).next() {
let text = element.text().collect::<String>();
let text = text.trim();
if !text.is_empty() && !Self::is_guid(text) {
return Some(text.to_string());
}
}
}
}
let all_text = row.text().collect::<Vec<_>>();
for text in all_text {
let text = text.trim();
if !text.is_empty() && !Self::is_guid(text) && text.len() > 2 {
return Some(text.to_string());
}
}
None
}
fn is_guid(s: &str) -> bool {
uuid::Uuid::parse_str(s.trim()).is_ok()
}
fn validate_sauna_id(sauna_id: &str) -> Result<()> {
if !Self::is_guid(sauna_id) {
return Err(KlafsError::InvalidParameter {
message: format!(
"Invalid sauna ID format '{}'. Expected a UUID (e.g., 364cc9db-86f1-49d1-86cd-f6ef9b20a490)",
sauna_id
),
});
}
Ok(())
}
fn validate_pin(pin: &str) -> Result<()> {
if pin.len() != 4 || !pin.chars().all(|c| c.is_ascii_digit()) {
return Err(KlafsError::InvalidParameter {
message: "PIN must be exactly 4 digits".to_string(),
});
}
Ok(())
}
fn validate_temperature(temperature: i32) -> Result<()> {
validate_range!(temperature, 10, 100, "Temperature (°C)");
Ok(())
}
fn validate_sanarium_temperature(temperature: i32) -> Result<()> {
validate_range!(temperature, 40, 75, "Sanarium temperature (°C)");
Ok(())
}
fn validate_humidity_level(level: i32) -> Result<()> {
validate_range!(level, 1, 10, "Humidity level");
Ok(())
}
fn validate_hour(hour: i32) -> Result<()> {
validate_range!(hour, 0, 23, "Hour");
Ok(())
}
fn validate_minute(minute: i32) -> Result<()> {
validate_range!(minute, 0, 59, "Minute");
Ok(())
}
fn extract_guid(text: &str) -> Option<String> {
let guid_pattern = regex_lite::Regex::new(
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}",
)
.ok()?;
guid_pattern.find(text).map(|m| m.as_str().to_lowercase())
}
fn extract_verification_token(&self, html: &str) -> Result<String> {
let document = Html::parse_document(html);
let input_selector = Selector::parse("input").unwrap();
for element in document.select(&input_selector) {
let name_or_id = element
.value()
.attr("name")
.or_else(|| element.value().attr("id"));
if let Some(name) = name_or_id {
if name.to_lowercase().contains("requestverificationtoken") {
if let Some(value) = element.value().attr("value") {
return Ok(value.to_string());
}
}
}
}
let meta_selector = Selector::parse("meta").unwrap();
for element in document.select(&meta_selector) {
let name_or_id = element
.value()
.attr("name")
.or_else(|| element.value().attr("id"));
if let Some(name) = name_or_id {
if name.to_lowercase().contains("requestverificationtoken") {
if let Some(value) = element.value().attr("content") {
return Ok(value.to_string());
}
}
}
}
let patterns = [
r#"(?i)name=["']__requestverificationtoken["'][^>]*value=["']([^"']+)["']"#,
r#"(?i)content=["']([^"']+)["'][^>]*name=["']__requestverificationtoken["']"#,
r#"(?i)__requestverificationtoken["']\s*[:=]\s*["']([^"']+)["']"#,
];
for pattern in patterns {
if let Ok(regex) = regex_lite::Regex::new(pattern) {
if let Some(captures) = regex.captures(html) {
if let Some(value) = captures.get(1) {
return Ok(value.as_str().to_string());
}
}
}
}
Err(KlafsError::VerificationTokenNotFound)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = KlafsClient::new().unwrap();
assert!(!client.is_logged_in());
}
#[test]
fn test_client_with_config() {
let config = ClientConfig::for_testing("http://localhost:8080");
let client = KlafsClient::with_config(config).unwrap();
assert!(!client.is_logged_in());
assert!(client.debugger().is_enabled());
}
#[test]
fn test_extract_verification_token() {
let client = KlafsClient::new().unwrap();
let html = r#"
<html>
<body>
<form>
<input name="__RequestVerificationToken" type="hidden" value="test-token-123" />
</form>
</body>
</html>
"#;
let token = client.extract_verification_token(html).unwrap();
assert_eq!(token, "test-token-123");
}
#[test]
fn test_extract_verification_token_not_found() {
let client = KlafsClient::new().unwrap();
let html = "<html><body>No token here</body></html>";
let result = client.extract_verification_token(html);
assert!(matches!(result, Err(KlafsError::VerificationTokenNotFound)));
}
#[test]
fn test_extract_verification_token_from_script() {
let client = KlafsClient::new().unwrap();
let html = r#"
<html>
<head>
<script>
window.config = {"__RequestVerificationToken":"script-token-456"};
</script>
</head>
</html>
"#;
let token = client.extract_verification_token(html).unwrap();
assert_eq!(token, "script-token-456");
}
#[test]
fn test_extract_verification_token_from_id() {
let client = KlafsClient::new().unwrap();
let html = r#"
<html>
<body>
<input id="__RequestVerificationToken" type="hidden" value="id-token-789" />
</body>
</html>
"#;
let token = client.extract_verification_token(html).unwrap();
assert_eq!(token, "id-token-789");
}
#[test]
fn test_is_guid() {
assert!(KlafsClient::is_guid("364cc9db-86f1-49d1-86cd-f6ef9b20a490"));
assert!(KlafsClient::is_guid("00000000-0000-0000-0000-000000000000"));
assert!(KlafsClient::is_guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"));
assert!(!KlafsClient::is_guid("not-a-guid"));
assert!(!KlafsClient::is_guid("364cc9db-86f1-49d1-86cd"));
assert!(!KlafsClient::is_guid(
"364cc9db-86f1-49d1-86cd-f6ef9b20a490-extra"
));
assert!(!KlafsClient::is_guid(
"364cc9db_86f1_49d1_86cd_f6ef9b20a490"
));
}
#[test]
fn test_extract_guid() {
assert_eq!(
KlafsClient::extract_guid("Sauna ID: 364cc9db-86f1-49d1-86cd-f6ef9b20a490"),
Some("364cc9db-86f1-49d1-86cd-f6ef9b20a490".to_string())
);
assert_eq!(KlafsClient::extract_guid("No GUID here"), None);
let text = "First: 11111111-1111-1111-1111-111111111111, Second: 22222222-2222-2222-2222-222222222222";
assert_eq!(
KlafsClient::extract_guid(text),
Some("11111111-1111-1111-1111-111111111111".to_string())
);
}
#[test]
fn test_extract_saunas_from_html() {
let client = KlafsClient::new().unwrap();
let html = r#"
<html>
<body>
<table>
<tr class="iw-sauna-webgrid-row-style" data-sauna-id="364cc9db-86f1-49d1-86cd-f6ef9b20a490">
<td class="sauna-name">My Sauna</td>
</tr>
</table>
</body>
</html>
"#;
let saunas = client.extract_saunas_from_html(html).unwrap();
assert_eq!(saunas.len(), 1);
assert_eq!(saunas[0].id, "364cc9db-86f1-49d1-86cd-f6ef9b20a490");
assert_eq!(saunas[0].name, "My Sauna");
}
#[test]
fn test_validate_sauna_id_valid() {
assert!(KlafsClient::validate_sauna_id("364cc9db-86f1-49d1-86cd-f6ef9b20a490").is_ok());
assert!(KlafsClient::validate_sauna_id("00000000-0000-0000-0000-000000000000").is_ok());
assert!(KlafsClient::validate_sauna_id("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE").is_ok());
}
#[test]
fn test_validate_sauna_id_invalid() {
assert!(KlafsClient::validate_sauna_id("").is_err());
assert!(KlafsClient::validate_sauna_id("not-a-uuid").is_err());
assert!(KlafsClient::validate_sauna_id("364cc9db-86f1-49d1").is_err());
assert!(
KlafsClient::validate_sauna_id("364cc9db-86f1-49d1-86cd-f6ef9b20a490-extra").is_err()
);
}
#[test]
fn test_validate_pin_valid() {
assert!(KlafsClient::validate_pin("1234").is_ok());
assert!(KlafsClient::validate_pin("0000").is_ok());
assert!(KlafsClient::validate_pin("9999").is_ok());
}
#[test]
fn test_validate_pin_invalid() {
assert!(KlafsClient::validate_pin("123").is_err());
assert!(KlafsClient::validate_pin("12345").is_err());
assert!(KlafsClient::validate_pin("abcd").is_err());
assert!(KlafsClient::validate_pin("12a4").is_err());
assert!(KlafsClient::validate_pin("").is_err());
assert!(KlafsClient::validate_pin("12-4").is_err());
}
#[test]
fn test_validate_temperature_valid() {
assert!(KlafsClient::validate_temperature(10).is_ok());
assert!(KlafsClient::validate_temperature(50).is_ok());
assert!(KlafsClient::validate_temperature(100).is_ok());
}
#[test]
fn test_validate_temperature_invalid() {
assert!(KlafsClient::validate_temperature(9).is_err());
assert!(KlafsClient::validate_temperature(101).is_err());
assert!(KlafsClient::validate_temperature(0).is_err());
assert!(KlafsClient::validate_temperature(-10).is_err());
assert!(KlafsClient::validate_temperature(150).is_err());
}
#[test]
fn test_validate_sanarium_temperature_valid() {
assert!(KlafsClient::validate_sanarium_temperature(40).is_ok());
assert!(KlafsClient::validate_sanarium_temperature(60).is_ok());
assert!(KlafsClient::validate_sanarium_temperature(75).is_ok());
}
#[test]
fn test_validate_sanarium_temperature_invalid() {
assert!(KlafsClient::validate_sanarium_temperature(39).is_err());
assert!(KlafsClient::validate_sanarium_temperature(76).is_err());
assert!(KlafsClient::validate_sanarium_temperature(10).is_err());
assert!(KlafsClient::validate_sanarium_temperature(100).is_err());
}
#[test]
fn test_validate_humidity_level_valid() {
assert!(KlafsClient::validate_humidity_level(1).is_ok());
assert!(KlafsClient::validate_humidity_level(5).is_ok());
assert!(KlafsClient::validate_humidity_level(10).is_ok());
}
#[test]
fn test_validate_humidity_level_invalid() {
assert!(KlafsClient::validate_humidity_level(0).is_err());
assert!(KlafsClient::validate_humidity_level(11).is_err());
assert!(KlafsClient::validate_humidity_level(-1).is_err());
assert!(KlafsClient::validate_humidity_level(100).is_err());
}
#[test]
fn test_validate_hour_valid() {
assert!(KlafsClient::validate_hour(0).is_ok());
assert!(KlafsClient::validate_hour(12).is_ok());
assert!(KlafsClient::validate_hour(23).is_ok());
}
#[test]
fn test_validate_hour_invalid() {
assert!(KlafsClient::validate_hour(-1).is_err());
assert!(KlafsClient::validate_hour(24).is_err());
assert!(KlafsClient::validate_hour(100).is_err());
}
#[test]
fn test_validate_minute_valid() {
assert!(KlafsClient::validate_minute(0).is_ok());
assert!(KlafsClient::validate_minute(30).is_ok());
assert!(KlafsClient::validate_minute(59).is_ok());
}
#[test]
fn test_validate_minute_invalid() {
assert!(KlafsClient::validate_minute(-1).is_err());
assert!(KlafsClient::validate_minute(60).is_err());
assert!(KlafsClient::validate_minute(100).is_err());
}
}