//! Home Assistant REST API client for `power on`/`power off`. //! //! Talks to HA via `POST /api/services//` and uses //! `Authorization: Bearer `. The HA domain for a switch is the //! part before the first dot in the entity_id (e.g. `switch.dgx1` → //! domain `switch`, service `turn_off` or `turn_on`). use anyhow::{Context, Result, anyhow}; use serde_json::json; use std::time::Duration; use tracing::{debug, info, warn}; use crate::config::HomeAssistantConfig; /// Thin async wrapper around the HA REST API. /// /// `base_url` is the HA root URL (`http://homeassistant.local:8123`), /// `token` is the long-lived access token used as bearer auth. pub struct Client { base_url: String, token: String, http: reqwest::Client, } impl Client { /// Construct a new client. The reqwest client is built once and /// reused so connection pooling works across the parallel calls in /// the `power` subcommand. pub fn new(cfg: &HomeAssistantConfig) -> Result { let base_url = cfg.url.trim_end_matches('/').to_string(); if base_url.is_empty() { anyhow::bail!("[home_assistant].url must be set"); } let http = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .context("building reqwest client")?; Ok(Self { base_url, token: cfg.token.clone(), http, }) } /// Build `https://.../api/services//` for the given /// `entity_id`. Domain is the prefix before the first `.`; pass an /// empty fallback for ambiguous input. fn endpoint(entity_id: &str, service: &str) -> Result<(String, String)> { let (domain, entity) = entity_id.split_once('.').ok_or_else(|| { anyhow!( "entity_id '{}' has no domain (e.g. 'switch.foo')", entity_id ) })?; if entity.is_empty() { anyhow::bail!("entity_id '{}' has empty entity name", entity_id); } Ok(( domain.to_string(), format!("/api/services/{}/{}", domain, service), )) } /// POST `/api/services//` with `{"entity_id": ...}`. async fn call_service(&self, entity_id: &str, service: &str) -> Result<()> { let (_domain, path) = Self::endpoint(entity_id, service)?; let url = format!("{}{}", self.base_url, path); debug!("HA POST {}", url); let resp = self .http .post(&url) .bearer_auth(&self.token) .json(&json!({ "entity_id": entity_id })) .send() .await .with_context(|| format!("calling HA at {}", url))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); anyhow::bail!( "HA {} {} returned {}: {}", service, entity_id, status, body.trim() ); } Ok(()) } /// `switch.turn_off` for the given entity. pub async fn turn_off(&self, entity_id: &str) -> Result<()> { info!("HA turn_off {}", entity_id); self.call_service(entity_id, "turn_off").await } /// `switch.turn_on` for the given entity. pub async fn turn_on(&self, entity_id: &str) -> Result<()> { info!("HA turn_on {}", entity_id); self.call_service(entity_id, "turn_on").await } } /// Parse `switch.dgx1` into `("switch", "dgx1")` so callers can build /// their own endpoints if needed. Exposed for the `power` subcommand /// and for tests. pub fn split_entity(entity_id: &str) -> Result<(&str, &str)> { let (domain, entity) = entity_id.split_once('.').ok_or_else(|| { anyhow!( "entity_id '{}' has no domain (e.g. 'switch.foo')", entity_id ) })?; if domain.is_empty() || entity.is_empty() { anyhow::bail!("entity_id '{}' has empty domain or entity name", entity_id); } Ok((domain, entity)) } /// Emit a deprecation-style warning if a HA token looks suspiciously /// short. HA tokens are typically 100+ chars; anything shorter is /// almost certainly a placeholder. pub fn warn_if_token_looks_short(token: &str) { if token.len() < 20 { warn!( "[home_assistant].token looks suspiciously short ({} chars); \ did you paste a placeholder?", token.len() ); } } #[cfg(test)] mod tests { use super::*; #[test] fn split_entity_basic() { assert_eq!(split_entity("switch.dgx1").unwrap(), ("switch", "dgx1")); assert_eq!( split_entity("light.living_room").unwrap(), ("light", "living_room") ); } #[test] fn split_entity_no_domain_errors() { assert!(split_entity("dgx1").is_err()); } #[test] fn split_entity_empty_entity_errors() { assert!(split_entity("switch.").is_err()); } #[test] fn split_entity_empty_domain_errors() { assert!(split_entity(".thing").is_err()); } #[test] fn endpoint_extracts_domain() { let (_d, path) = Client::endpoint("switch.dgx1", "turn_off").unwrap(); assert_eq!(path, "/api/services/switch/turn_off"); let (_d, path) = Client::endpoint("light.kitchen", "turn_on").unwrap(); assert_eq!(path, "/api/services/light/turn_on"); } #[test] fn endpoint_rejects_no_domain() { assert!(Client::endpoint("plain", "turn_off").is_err()); } }