| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- //! Home Assistant REST API client for `power on`/`power off`.
- //!
- //! Talks to HA via `POST /api/services/<domain>/<service>` and uses
- //! `Authorization: Bearer <token>`. 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::{Cluster, 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.
- #[derive(Clone)]
- 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<Self> {
- 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/<domain>/<service>` 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/<domain>/<service>` 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
- }
- /// GET `/api/states/<entity_id>` and return the state string.
- /// Returns `None` if the entity doesn't exist or isn't reachable.
- /// Logs a warning when the HTTP request or JSON parsing fails so
- /// callers (e.g. `entity_is_on`) can distinguish "entity is off"
- /// from "HA is unreachable" via the log.
- pub async fn get_state(&self, entity_id: &str) -> Option<String> {
- let url = format!("{}/api/states/{}", self.base_url, entity_id);
- let resp = match self.http.get(&url).bearer_auth(&self.token).send().await {
- Ok(r) => r,
- Err(e) => {
- warn!("HA GET {} failed: {}", url, e);
- return None;
- }
- };
- if !resp.status().is_success() {
- warn!("HA GET {} returned {}", url, resp.status());
- return None;
- }
- let body: serde_json::Value = match resp.json().await {
- Ok(v) => v,
- Err(e) => {
- warn!("HA GET {} JSON parse failed: {}", url, e);
- return None;
- }
- };
- body.get("state").and_then(|s| s.as_str()).map(String::from)
- }
- }
- /// HA states for a cluster's `extra_entities` (parallel GETs).
- #[derive(Clone, Debug, Default, PartialEq)]
- pub struct EntityStates {
- /// A *blocking* entity reports anything other than "on" — including
- /// HA being unreachable (unknown is treated as off, same as the
- /// `health` command has always done). Blocking-off takes the whole
- /// cluster down.
- pub blocking_off: bool,
- /// Non-blocking entities (`blocks = false`) that are not "on" —
- /// tracked and displayed, but they never affect up/down.
- pub soft_off: Vec<String>,
- }
- /// Probe the cluster's `extra_entities`. No HA config and no `[mikrotik]`
- /// backend for an entity → that entity is "nothing to check", not off
- /// (legacy semantics). Mikrotik devices (exact name match from
- /// [`crate::config`]) are probed through the RouterOS API instead of HA:
- /// logged in = on, unreachable/rejected login = off (a dead router takes
- /// the cluster down like any other blocking switch).
- pub async fn extra_entities_states(config: &Config, cluster: &Cluster) -> EntityStates {
- if cluster.extra_entities.is_empty() {
- return EntityStates::default();
- }
- let ha_client: Option<Client> = config
- .home_assistant
- .as_ref()
- .and_then(|ha| Client::new(ha).ok());
- let has_ha = ha_client.is_some();
- let mut handles = Vec::new();
- for e in &cluster.extra_entities {
- let id = e.entity().to_string();
- let is_mikrotik = config
- .mikrotik
- .as_ref()
- .is_some_and(|m| m.devices.iter().any(|d| d.name == id.as_str()));
- // A non-mikrotik entity without an HA backend is "nothing to check",
- // not off — only entities with a configured backend flip the verdict.
- if !is_mikrotik && !has_ha {
- continue;
- }
- let config = config.clone();
- let client = ha_client.clone();
- let blocks = e.blocks();
- handles.push(crate::locale::spawn_with_locale(async move {
- (
- id.clone(),
- blocks,
- entity_state(&config, client.as_ref(), &id).await,
- )
- }));
- }
- let mut st = EntityStates::default();
- for h in futures::future::join_all(handles).await {
- let Ok((id, blocks, state)) = h else {
- continue;
- };
- let on = matches!(&state, Some(s) if s == "on");
- if !on {
- if blocks {
- st.blocking_off = true;
- } else {
- st.soft_off.push(id);
- }
- }
- }
- st
- }
- /// The state of one entity through the correct backend: a `[mikrotik]`
- /// device is probed with a RouterOS API login (logged in = "on";
- /// unreachable or rejected login = "off", like a switch that does not
- /// answer), everything else goes through HA REST.
- /// Returns `None` only when the entity has no configured backend at all
- /// (unknown — callers keep the "nothing to check ≠ off" semantics).
- pub async fn entity_state(
- config: &Config,
- client: Option<&Client>,
- entity_id: &str,
- ) -> Option<String> {
- if let Some(dev) = config
- .mikrotik
- .as_ref()
- .and_then(|m| m.devices.iter().find(|d| d.name == entity_id))
- {
- return match crate::mikrotik::ping_device(dev).await {
- Ok(()) => Some("on".to_string()),
- Err(e) => {
- warn!("RouterOS ping {entity_id} failed: {e}");
- Some("off".to_string())
- }
- };
- }
- let client = client?;
- client
- .get_state(entity_id)
- .await
- .map(|s| if s == "on" { "on".into() } else { "off".into() })
- }
- /// 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::*;
- #[tokio::test]
- async fn mikrotik_entity_unreachable_is_blocking_off() {
- // A `[mikrotik]` device referenced from `extra_entities` is probed
- // through the RouterOS API: unreachable (closed port) = off, and a
- // blocking entity takes the cluster down — the r18-class "dead
- // entity silently skipped" failure mode must not exist.
- let toml = r#"
- ssh_user = "u"
- [[clusters]]
- name = "c1"
- nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0 }]
- extra_entities = ["rt"]
- [mikrotik]
- [[mikrotik.devices]]
- name = "rt"
- address = "127.0.0.1"
- username = "x"
- password = "y"
- api_port = 1
- "#;
- let config: crate::config::Config = toml::from_str(toml).unwrap();
- let cluster = &config.clusters[0];
- let st = extra_entities_states(&config, cluster).await;
- assert!(
- st.blocking_off,
- "unreachable mikrotik device must be blocking-off"
- );
- }
- #[tokio::test]
- async fn mikrotik_device_without_backend_not_reported_off() {
- // No [mikrotik] section, no HA → the entity has no backend at all:
- // "nothing to check", not off (legacy semantics).
- let toml = r#"
- ssh_user = "u"
- [[clusters]]
- name = "c1"
- nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0 }]
- extra_entities = ["rt"]
- "#;
- let config: crate::config::Config = toml::from_str(toml).unwrap();
- let cluster = &config.clusters[0];
- let st = extra_entities_states(&config, cluster).await;
- assert!(!st.blocking_off);
- assert!(st.soft_off.is_empty());
- }
- #[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());
- }
- }
|