v0.12.0-preety_code
This commit is contained in:
+139
-43
@@ -1,10 +1,13 @@
|
||||
use std::time::Duration;
|
||||
use crate::models::HomeAssistantSettings;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
use crate::models::HomeAssistantSettings;
|
||||
|
||||
fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSettings) -> Result<reqwest::Client> {
|
||||
fn request_client(
|
||||
default_client: &reqwest::Client,
|
||||
settings: &HomeAssistantSettings,
|
||||
) -> Result<reqwest::Client> {
|
||||
if !settings.allow_invalid_tls {
|
||||
return Ok(default_client.clone());
|
||||
}
|
||||
@@ -17,11 +20,17 @@ fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSett
|
||||
.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())
|
||||
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)
|
||||
.unwrap_or_else(|| settings.default_entity_id.trim());
|
||||
if requested.is_empty() { return None; }
|
||||
if requested.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Aliases are presentation-only. Accepting an alias here is a defensive
|
||||
// compatibility path for settings saved by older UI revisions or manual edits;
|
||||
@@ -29,8 +38,11 @@ pub fn resolve_entity_id(settings: &HomeAssistantSettings, entity_override: Opti
|
||||
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)) {
|
||||
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())
|
||||
@@ -42,38 +54,71 @@ pub async fn read_temperature(
|
||||
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") }
|
||||
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") }
|
||||
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")?;
|
||||
base = base
|
||||
.join(&path)
|
||||
.context("cannot build Home Assistant API URL")?;
|
||||
|
||||
let client = request_client(default_client, settings)?;
|
||||
let response = client.get(base)
|
||||
let response = client
|
||||
.get(base)
|
||||
.bearer_auth(settings.token.trim())
|
||||
.header("Accept", "application/json")
|
||||
.send().await.context("Home Assistant request failed")?;
|
||||
.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>())
|
||||
bail!(
|
||||
"Home Assistant returned {status}: {}",
|
||||
body.chars().take(200).collect::<String>()
|
||||
)
|
||||
}
|
||||
let payload: Value = response.json().await.context("invalid Home Assistant JSON")?;
|
||||
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)
|
||||
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 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)
|
||||
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");
|
||||
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;
|
||||
}
|
||||
@@ -103,9 +148,18 @@ mod tests {
|
||||
#[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).as_deref(), Some("sensor.salon_temperature"));
|
||||
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).as_deref(),
|
||||
Some("sensor.salon_temperature")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,20 +170,36 @@ pub async fn read_entity(
|
||||
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") }
|
||||
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")?;
|
||||
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")?;
|
||||
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>())
|
||||
bail!(
|
||||
"Home Assistant returned {status}: {}",
|
||||
body.chars().take(200).collect::<String>()
|
||||
)
|
||||
}
|
||||
response.json().await.context("invalid Home Assistant JSON")
|
||||
}
|
||||
@@ -143,10 +213,13 @@ pub async fn read_state(
|
||||
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"))
|
||||
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(
|
||||
@@ -157,23 +230,46 @@ pub async fn call_service(
|
||||
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") }
|
||||
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")?;
|
||||
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")?;
|
||||
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>())
|
||||
bail!(
|
||||
"Home Assistant returned {status}: {}",
|
||||
body.chars().take(200).collect::<String>()
|
||||
)
|
||||
}
|
||||
match response.json::<Value>().await {
|
||||
Ok(value) => Ok(value),
|
||||
|
||||
Reference in New Issue
Block a user