use crate::models::HomeAssistantSettings; use anyhow::{anyhow, bail, Context, Result}; use serde_json::Value; use std::time::Duration; use url::Url; fn request_client( default_client: &reqwest::Client, settings: &HomeAssistantSettings, ) -> Result { if !settings.allow_invalid_tls { return Ok(default_client.clone()); } reqwest::Client::builder() .timeout(Duration::from_secs(10)) .user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION"))) .danger_accept_invalid_certs(true) .danger_accept_invalid_hostnames(true) .build() .context("cannot build Home Assistant HTTPS client") } pub fn resolve_entity_id( settings: &HomeAssistantSettings, entity_override: Option<&str>, ) -> Option { let requested = entity_override .filter(|value| !value.trim().is_empty()) .map(str::trim)?; // Aliases are presentation-only. Accepting an alias here is a defensive // compatibility path for settings saved by older UI revisions or manual edits; // the actual Home Assistant request always uses the original entity_id key. if settings.sensor_aliases.contains_key(requested) { return Some(requested.to_string()); } if let Some((entity_id, _)) = settings .sensor_aliases .iter() .find(|(_, alias)| alias.trim().eq_ignore_ascii_case(requested)) { return Some(entity_id.clone()); } Some(requested.to_string()) } pub async fn test_connection( default_client: &reqwest::Client, settings: &HomeAssistantSettings, ) -> Result<()> { if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } base = base.join("api/").context("cannot build Home Assistant API URL")?; let response = request_client(default_client, settings)? .get(base) .bearer_auth(settings.token.trim()) .header("Accept", "application/json") .send() .await .context("Home Assistant request failed")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); bail!( "Home Assistant returned {status}: {}", body.chars().take(200).collect::() ) } Ok(()) } pub async fn read_temperature( default_client: &reqwest::Client, settings: &HomeAssistantSettings, entity_override: Option<&str>, stale_after_seconds: Option, ) -> Result { if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } let entity = resolve_entity_id(settings, entity_override) .ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?; let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } let path = format!("api/states/{}", entity.trim_start_matches('/')); base = base .join(&path) .context("cannot build Home Assistant API URL")?; let client = request_client(default_client, settings)?; let response = client .get(base) .bearer_auth(settings.token.trim()) .header("Accept", "application/json") .send() .await .context("Home Assistant request failed")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); bail!( "Home Assistant returned {status}: {}", body.chars().take(200).collect::() ) } let payload: Value = response .json() .await .context("invalid Home Assistant JSON")?; if let Some(limit) = stale_after_seconds.filter(|value| *value > 0) { let updated = payload .get("last_updated") .and_then(Value::as_str) .ok_or_else(|| anyhow!("Home Assistant last_updated is missing"))?; let updated = chrono::DateTime::parse_from_rfc3339(updated) .context("invalid Home Assistant last_updated")? .with_timezone(&chrono::Utc); let age = chrono::Utc::now() .signed_duration_since(updated) .num_seconds() .max(0) as u64; if age > limit { bail!("Home Assistant sensor is stale: {age}s old (limit {limit}s)") } } let state = payload .get("state") .and_then(Value::as_str) .ok_or_else(|| anyhow!("Home Assistant state is missing"))?; let mut temperature: f64 = state .parse() .context("Home Assistant state is not a number")?; let unit = payload .pointer("/attributes/unit_of_measurement") .and_then(Value::as_str) .unwrap_or("°C"); if unit.eq_ignore_ascii_case("°F") || unit.eq_ignore_ascii_case("F") { temperature = (temperature - 32.0) * 5.0 / 9.0; } Ok((temperature * 10.0).round() / 10.0) } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; fn settings() -> HomeAssistantSettings { let mut sensor_aliases = BTreeMap::new(); sensor_aliases.insert("sensor.gabinet_temperature".into(), "Gabinet".into()); HomeAssistantSettings { url: "http://homeassistant.local:8123".into(), token: "token".into(), outdoor_entity_id: "sensor.zewnatrz_temperature".into(), sensor_stale_after_seconds: 300, allow_invalid_tls: false, sensor_aliases, flow_inputs: Vec::new(), } } #[test] fn aliases_never_replace_real_home_assistant_entity_ids() { let settings = settings(); assert_eq!( resolve_entity_id(&settings, Some("sensor.gabinet_temperature")).as_deref(), Some("sensor.gabinet_temperature") ); assert_eq!( resolve_entity_id(&settings, Some("Gabinet")).as_deref(), Some("sensor.gabinet_temperature") ); assert_eq!(resolve_entity_id(&settings, None), None); } } /// Read a complete Home Assistant entity document for Flow conditions. Keeping this helper /// centralized means state and attribute blocks share the same URL validation, TLS and auth path. pub async fn read_entity( default_client: &reqwest::Client, settings: &HomeAssistantSettings, entity_override: Option<&str>, ) -> Result { if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } let entity = resolve_entity_id(settings, entity_override) .ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?; let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } base = base .join(&format!("api/states/{}", entity.trim_start_matches('/'))) .context("cannot build Home Assistant API URL")?; let client = request_client(default_client, settings)?; let response = client .get(base) .bearer_auth(settings.token.trim()) .header("Accept", "application/json") .send() .await .context("Home Assistant request failed")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); bail!( "Home Assistant returned {status}: {}", body.chars().take(200).collect::() ) } response.json().await.context("invalid Home Assistant JSON") } /// Read the Home Assistant state registry. Callers must filter the result before exposing it. pub async fn list_entities( default_client: &reqwest::Client, settings: &HomeAssistantSettings, ) -> Result> { if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } base = base.join("api/states").context("cannot build Home Assistant API URL")?; let response = request_client(default_client, settings)? .get(base) .bearer_auth(settings.token.trim()) .header("Accept", "application/json") .send() .await .context("Home Assistant state registry request failed")?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); bail!( "Home Assistant returned {status}: {}", body.chars().take(200).collect::() ) } response .json::>() .await .context("invalid Home Assistant state registry JSON") } /// Read the raw Home Assistant state for Flow conditions. Unlike `read_temperature`, this /// intentionally keeps the state as text so binary_sensor, switch, input_boolean and custom /// entities can participate in visual automations. pub async fn read_state( default_client: &reqwest::Client, settings: &HomeAssistantSettings, entity_override: Option<&str>, ) -> Result { let payload = read_entity(default_client, settings, entity_override).await?; payload .get("state") .and_then(Value::as_str) .map(str::to_string) .ok_or_else(|| anyhow!("Home Assistant state is missing")) } /// Call a Home Assistant service from a Flow action. The domain/service pair is explicit /// and the payload is sent as JSON. Entity targeting is normalized through entity_id. pub async fn call_service( default_client: &reqwest::Client, settings: &HomeAssistantSettings, domain: &str, service: &str, entity_id: Option<&str>, data: &Value, ) -> Result { if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } if domain.trim().is_empty() || service.trim().is_empty() { bail!("Home Assistant domain/service is required") } let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } base = base .join(&format!( "api/services/{}/{}", domain.trim(), service.trim() )) .context("cannot build Home Assistant service URL")?; let mut payload = data.as_object().cloned().unwrap_or_default(); if let Some(entity) = entity_id.map(str::trim).filter(|v| !v.is_empty()) { payload.insert("entity_id".into(), Value::String(entity.to_string())); } let client = request_client(default_client, settings)?; let response = client .post(base) .bearer_auth(settings.token.trim()) .header("Accept", "application/json") .json(&Value::Object(payload)) .send() .await .context("Home Assistant service request failed")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); bail!( "Home Assistant returned {status}: {}", body.chars().take(200).collect::() ) } match response.json::().await { Ok(value) => Ok(value), Err(_) => Ok(Value::Null), } }