415 lines
14 KiB
Rust
415 lines
14 KiB
Rust
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<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<Option<Value>> {
|
|
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::<String>()
|
|
)
|
|
}
|
|
|
|
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<u64>,
|
|
) -> Result<f64> {
|
|
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::<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,
|
|
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<Value> {
|
|
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::<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>> {
|
|
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::<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> {
|
|
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::<String>()
|
|
)
|
|
}
|
|
match response.json::<Value>().await {
|
|
Ok(value) => Ok(value),
|
|
Err(_) => Ok(Value::Null),
|
|
}
|
|
}
|