use crate::models::HomeAssistantSettings; use anyhow::{anyhow, bail, Context, Result}; use serde_json::Value; use std::{ env, net::{IpAddr, Ipv4Addr}, time::Duration, }; use url::Url; const SUPERVISOR_AUTH_ENV: &str = "GREE_CONTROLLER_HA_AUTH"; const SUPERVISOR_AUTH_MODE: &str = "supervisor"; const SUPERVISOR_TOKEN_ENV: &str = "SUPERVISOR_TOKEN"; const SUPERVISOR_BASE_URL: &str = "http://supervisor/core/"; pub fn supervisor_detected() -> bool { env::var(SUPERVISOR_AUTH_ENV) .map(|value| value.trim().eq_ignore_ascii_case(SUPERVISOR_AUTH_MODE)) .unwrap_or(false) } pub fn supervisor_token_detected() -> bool { supervisor_detected() && env::var(SUPERVISOR_TOKEN_ENV) .map(|token| !token.trim().is_empty()) .unwrap_or(false) } pub fn is_supervisor_ingress_peer(peer: IpAddr) -> bool { supervisor_token_detected() && peer == IpAddr::V4(Ipv4Addr::new(172, 30, 32, 2)) } pub fn uses_supervisor_auth(settings: &HomeAssistantSettings) -> bool { supervisor_detected() && !settings.manual_auth_override } pub fn auth_mode(settings: &HomeAssistantSettings) -> &'static str { if uses_supervisor_auth(settings) { SUPERVISOR_AUTH_MODE } else { "manual" } } pub fn effective_url(settings: &HomeAssistantSettings) -> String { if uses_supervisor_auth(settings) { SUPERVISOR_BASE_URL.to_string() } else { settings.url.clone() } } pub fn token_configured(settings: &HomeAssistantSettings) -> bool { if uses_supervisor_auth(settings) { supervisor_token_detected() } else { !settings.token.trim().is_empty() } } fn connection(settings: &HomeAssistantSettings) -> Result<(Url, String)> { let (raw_url, token) = if uses_supervisor_auth(settings) { let token = env::var(SUPERVISOR_TOKEN_ENV) .context("Home Assistant Supervisor token is not available")?; (SUPERVISOR_BASE_URL.to_string(), token) } else { (settings.url.clone(), settings.token.clone()) }; if raw_url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if token.trim().is_empty() { if uses_supervisor_auth(settings) { bail!("Home Assistant Supervisor token is not available") } bail!("Home Assistant token is not configured") } let base = Url::parse(raw_url.trim()).context("invalid Home Assistant URL")?; if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } Ok((base, token)) } 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"))) .tls_danger_accept_invalid_certs(true) .tls_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> { let (mut base, token) = connection(settings)?; base = base .join("api/") .context("cannot build Home Assistant API URL")?; let response = request_client(default_client, settings)? .get(base) .bearer_auth(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 entities = list_entities(default_client, settings).await?; let sample = entities .iter() .find(|entity| { entity .get("entity_id") .and_then(Value::as_str) .map(|id| id.starts_with("sensor.")) .unwrap_or(false) && entity .get("state") .and_then(Value::as_str) .map(|state| !matches!(state, "" | "unknown" | "unavailable")) .unwrap_or(false) }) .or_else(|| { entities.iter().find(|entity| { entity .get("state") .and_then(Value::as_str) .map(|state| !matches!(state, "" | "unknown" | "unavailable")) .unwrap_or(false) }) }) .map(|entity| { let attributes = entity.get("attributes").and_then(Value::as_object); serde_json::json!({ "entity_id": entity.get("entity_id").and_then(Value::as_str).unwrap_or_default(), "name": attributes.and_then(|value| value.get("friendly_name")).and_then(Value::as_str).unwrap_or_default(), "state": entity.get("state").and_then(Value::as_str).unwrap_or_default(), "unit": attributes.and_then(|value| value.get("unit_of_measurement")).and_then(Value::as_str).unwrap_or_default(), }) }); Ok(sample) } pub async fn read_temperature( default_client: &reqwest::Client, settings: &HomeAssistantSettings, entity_override: Option<&str>, stale_after_seconds: Option, ) -> Result { let (mut base, token) = connection(settings)?; let entity = resolve_entity_id(settings, entity_override) .ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?; 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(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, manual_auth_override: 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 { let (mut base, token) = connection(settings)?; let entity = resolve_entity_id(settings, entity_override) .ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?; 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(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> { let (mut base, token) = connection(settings)?; base = base .join("api/states") .context("cannot build Home Assistant API URL")?; let response = request_client(default_client, settings)? .get(base) .bearer_auth(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 { let (mut base, token) = connection(settings)?; if domain.trim().is_empty() || service.trim().is_empty() { bail!("Home Assistant domain/service is required") } 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(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), } }