Files
gree-controller/src/home_assistant.rs
T
2026-09-15 18:47:20 +02:00

341 lines
12 KiB
Rust

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<reqwest::Client> {
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<String> {
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::<String>()
)
}
Ok(())
}
pub async fn read_temperature(
default_client: &reqwest::Client,
settings: &HomeAssistantSettings,
entity_override: Option<&str>,
stale_after_seconds: Option<u64>,
) -> Result<f64> {
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::<String>()
)
}
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<Value> {
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::<String>()
)
}
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<Vec<Value>> {
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::<String>()
)
}
response
.json::<Vec<Value>>()
.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<String> {
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<Value> {
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::<String>()
)
}
match response.json::<Value>().await {
Ok(value) => Ok(value),
Err(_) => Ok(Value::Null),
}
}