home_assistant.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. //! Home Assistant REST API client for `power on`/`power off`.
  2. //!
  3. //! Talks to HA via `POST /api/services/<domain>/<service>` and uses
  4. //! `Authorization: Bearer <token>`. The HA domain for a switch is the
  5. //! part before the first dot in the entity_id (e.g. `switch.dgx1` →
  6. //! domain `switch`, service `turn_off` or `turn_on`).
  7. use anyhow::{Context, Result, anyhow};
  8. use serde_json::json;
  9. use std::sync::OnceLock;
  10. use std::time::Duration;
  11. use tracing::{debug, info, warn};
  12. use crate::config::{Cluster, Config, HomeAssistantConfig};
  13. use crate::entity_cache as liveness;
  14. /// Process-wide shared HA client, built once. The fleet probe rebuilds HA
  15. /// state every 30 s across several clusters — a fresh `Client` per call
  16. /// re-parses the system CA bundle (rustls) and allocates ~1.7 MB of TLS
  17. /// setup each time, ~2 GB/h of churn in the long-running dashboard.
  18. static SHARED_CLIENT: OnceLock<Option<Client>> = OnceLock::new();
  19. /// Entity states are cached because the dashboard probes every 30 s (and a
  20. /// switch must not be asked to authenticate on every tick). HA state changes
  21. /// within 2 min; RouterOS devices are checked at most every 5 min.
  22. const HA_TTL: Duration = Duration::from_secs(120);
  23. const MIKROTIK_TTL: Duration = Duration::from_secs(300);
  24. /// The shared client for long-lived callers (the web dashboard's probe
  25. /// loop); CLI paths (one-shot `power`) may keep using `Client::new`.
  26. pub fn shared_client(config: &Config) -> Option<Client> {
  27. SHARED_CLIENT
  28. .get_or_init(|| {
  29. config
  30. .home_assistant
  31. .as_ref()
  32. .and_then(|ha| Client::new(ha).ok())
  33. })
  34. .clone()
  35. }
  36. /// Thin async wrapper around the HA REST API.
  37. ///
  38. /// `base_url` is the HA root URL (`http://homeassistant.local:8123`),
  39. /// `token` is the long-lived access token used as bearer auth.
  40. #[derive(Clone)]
  41. pub struct Client {
  42. base_url: String,
  43. token: String,
  44. http: reqwest::Client,
  45. }
  46. impl Client {
  47. /// Construct a new client. The reqwest client is built once and
  48. /// reused so connection pooling works across the parallel calls in
  49. /// the `power` subcommand.
  50. pub fn new(cfg: &HomeAssistantConfig) -> Result<Self> {
  51. let base_url = cfg.url.trim_end_matches('/').to_string();
  52. if base_url.is_empty() {
  53. anyhow::bail!("[home_assistant].url must be set");
  54. }
  55. let http = reqwest::Client::builder()
  56. .timeout(Duration::from_secs(10))
  57. .build()
  58. .context("building reqwest client")?;
  59. Ok(Self {
  60. base_url,
  61. token: cfg.token.clone(),
  62. http,
  63. })
  64. }
  65. /// Build `https://.../api/services/<domain>/<service>` for the given
  66. /// `entity_id`. Domain is the prefix before the first `.`; pass an
  67. /// empty fallback for ambiguous input.
  68. fn endpoint(entity_id: &str, service: &str) -> Result<(String, String)> {
  69. let (domain, entity) = entity_id.split_once('.').ok_or_else(|| {
  70. anyhow!(
  71. "entity_id '{}' has no domain (e.g. 'switch.foo')",
  72. entity_id
  73. )
  74. })?;
  75. if entity.is_empty() {
  76. anyhow::bail!("entity_id '{}' has empty entity name", entity_id);
  77. }
  78. Ok((
  79. domain.to_string(),
  80. format!("/api/services/{}/{}", domain, service),
  81. ))
  82. }
  83. /// POST `/api/services/<domain>/<service>` with `{"entity_id": ...}`.
  84. async fn call_service(&self, entity_id: &str, service: &str) -> Result<()> {
  85. let (_domain, path) = Self::endpoint(entity_id, service)?;
  86. let url = format!("{}{}", self.base_url, path);
  87. debug!("HA POST {}", url);
  88. let resp = self
  89. .http
  90. .post(&url)
  91. .bearer_auth(&self.token)
  92. .json(&json!({ "entity_id": entity_id }))
  93. .send()
  94. .await
  95. .with_context(|| format!("calling HA at {}", url))?;
  96. let status = resp.status();
  97. if !status.is_success() {
  98. let body = resp.text().await.unwrap_or_default();
  99. anyhow::bail!(
  100. "HA {} {} returned {}: {}",
  101. service,
  102. entity_id,
  103. status,
  104. body.trim()
  105. );
  106. }
  107. liveness::invalidate(&format!("ha:{}", entity_id));
  108. Ok(())
  109. }
  110. /// `switch.turn_off` for the given entity.
  111. pub async fn turn_off(&self, entity_id: &str) -> Result<()> {
  112. info!("HA turn_off {}", entity_id);
  113. self.call_service(entity_id, "turn_off").await
  114. }
  115. /// `switch.turn_on` for the given entity.
  116. pub async fn turn_on(&self, entity_id: &str) -> Result<()> {
  117. info!("HA turn_on {}", entity_id);
  118. self.call_service(entity_id, "turn_on").await
  119. }
  120. /// GET `/api/states/<entity_id>` and return the state string.
  121. /// Returns `None` if the entity doesn't exist or isn't reachable.
  122. /// Logs a warning when the HTTP request or JSON parsing fails so
  123. /// callers (e.g. `entity_is_on`) can distinguish "entity is off"
  124. /// from "HA is unreachable" via the log.
  125. pub async fn get_state(&self, entity_id: &str) -> Option<String> {
  126. let url = format!("{}/api/states/{}", self.base_url, entity_id);
  127. let resp = match self.http.get(&url).bearer_auth(&self.token).send().await {
  128. Ok(r) => r,
  129. Err(e) => {
  130. warn!("HA GET {} failed: {}", url, e);
  131. return None;
  132. }
  133. };
  134. if !resp.status().is_success() {
  135. warn!("HA GET {} returned {}", url, resp.status());
  136. return None;
  137. }
  138. let body: serde_json::Value = match resp.json().await {
  139. Ok(v) => v,
  140. Err(e) => {
  141. warn!("HA GET {} JSON parse failed: {}", url, e);
  142. return None;
  143. }
  144. };
  145. body.get("state").and_then(|s| s.as_str()).map(String::from)
  146. }
  147. }
  148. /// HA states for a cluster's `extra_entities` (parallel GETs).
  149. #[derive(Clone, Debug, Default, PartialEq)]
  150. pub struct EntityStates {
  151. /// A *blocking* entity reports anything other than "on" — including
  152. /// HA being unreachable (unknown is treated as off, same as the
  153. /// `health` command has always done). Blocking-off takes the whole
  154. /// cluster down.
  155. pub blocking_off: bool,
  156. /// Non-blocking entities (`blocks = false`) that are not "on" —
  157. /// tracked and displayed, but they never affect up/down.
  158. pub soft_off: Vec<String>,
  159. }
  160. /// Probe the cluster's `extra_entities`. No HA config and no `[mikrotik]`
  161. /// backend for an entity → that entity is "nothing to check", not off
  162. /// (legacy semantics). Mikrotik devices (exact name match from
  163. /// [`crate::config`]) are probed through the RouterOS API instead of HA:
  164. /// logged in = on, unreachable/rejected login = off (a dead router takes
  165. /// the cluster down like any other blocking switch).
  166. pub async fn extra_entities_states(config: &Config, cluster: &Cluster) -> EntityStates {
  167. if cluster.extra_entities.is_empty() {
  168. return EntityStates::default();
  169. }
  170. let ha_client: Option<Client> = shared_client(config);
  171. let has_ha = ha_client.is_some();
  172. let mut handles = Vec::new();
  173. for e in &cluster.extra_entities {
  174. let id = e.entity().to_string();
  175. let is_mikrotik = config
  176. .mikrotik
  177. .as_ref()
  178. .is_some_and(|m| m.devices.iter().any(|d| d.name == id.as_str()));
  179. // A non-mikrotik entity without an HA backend is "nothing to check",
  180. // not off — only entities with a configured backend flip the verdict.
  181. if !is_mikrotik && !has_ha {
  182. continue;
  183. }
  184. let config = config.clone();
  185. let client = ha_client.clone();
  186. let blocks = e.blocks();
  187. handles.push(crate::locale::spawn_with_locale(async move {
  188. (
  189. id.clone(),
  190. blocks,
  191. entity_state(&config, client.as_ref(), &id).await,
  192. )
  193. }));
  194. }
  195. let mut st = EntityStates::default();
  196. for h in futures::future::join_all(handles).await {
  197. let Ok((id, blocks, state)) = h else {
  198. continue;
  199. };
  200. let on = matches!(&state, Some(s) if s == "on");
  201. if !on {
  202. if blocks {
  203. st.blocking_off = true;
  204. } else {
  205. st.soft_off.push(id);
  206. }
  207. }
  208. }
  209. st
  210. }
  211. /// The state of one entity through the correct backend: a `[mikrotik]`
  212. /// device is probed with a RouterOS API login (logged in = "on";
  213. /// unreachable or rejected login = "off", like a switch that does not
  214. /// answer), everything else goes through HA REST.
  215. /// Returns `None` only when the entity has no configured backend at all
  216. /// (unknown — callers keep the "nothing to check ≠ off" semantics).
  217. pub async fn entity_state(
  218. config: &Config,
  219. client: Option<&Client>,
  220. entity_id: &str,
  221. ) -> Option<String> {
  222. if let Some(dev) = config
  223. .mikrotik
  224. .as_ref()
  225. .and_then(|m| m.devices.iter().find(|d| d.name == entity_id))
  226. {
  227. let key = format!("mikrotik:{}:{}", dev.name, dev.address);
  228. if let Some(Some(v)) = liveness::fresh(&key) {
  229. return Some((if v { "on" } else { "off" }).to_string());
  230. }
  231. let v = match crate::mikrotik::ping_device(dev).await {
  232. Ok(()) => true,
  233. Err(e) => {
  234. warn!("RouterOS ping {entity_id} failed: {e}");
  235. false
  236. }
  237. };
  238. liveness::store(&key, Some(v), MIKROTIK_TTL);
  239. return Some((if v { "on" } else { "off" }).to_string());
  240. }
  241. let client = client?;
  242. let key = format!("ha:{}", entity_id);
  243. if let Some(Some(v)) = liveness::fresh(&key) {
  244. return Some((if v { "on" } else { "off" }).to_string());
  245. }
  246. let state = client.get_state(entity_id).await;
  247. let v = state.as_deref() == Some("on");
  248. liveness::store(&key, Some(v), HA_TTL);
  249. Some((if v { "on" } else { "off" }).to_string())
  250. }
  251. /// Parse `switch.dgx1` into `("switch", "dgx1")` so callers can build
  252. /// their own endpoints if needed. Exposed for the `power` subcommand
  253. /// and for tests.
  254. pub fn split_entity(entity_id: &str) -> Result<(&str, &str)> {
  255. let (domain, entity) = entity_id.split_once('.').ok_or_else(|| {
  256. anyhow!(
  257. "entity_id '{}' has no domain (e.g. 'switch.foo')",
  258. entity_id
  259. )
  260. })?;
  261. if domain.is_empty() || entity.is_empty() {
  262. anyhow::bail!("entity_id '{}' has empty domain or entity name", entity_id);
  263. }
  264. Ok((domain, entity))
  265. }
  266. /// Emit a deprecation-style warning if a HA token looks suspiciously
  267. /// short. HA tokens are typically 100+ chars; anything shorter is
  268. /// almost certainly a placeholder.
  269. pub fn warn_if_token_looks_short(token: &str) {
  270. if token.len() < 20 {
  271. warn!(
  272. "[home_assistant].token looks suspiciously short ({} chars); \
  273. did you paste a placeholder?",
  274. token.len()
  275. );
  276. }
  277. }
  278. #[cfg(test)]
  279. mod tests {
  280. use super::*;
  281. #[tokio::test]
  282. async fn mikrotik_entity_unreachable_is_blocking_off() {
  283. // A `[mikrotik]` device referenced from `extra_entities` is probed
  284. // through the RouterOS API: unreachable (closed port) = off, and a
  285. // blocking entity takes the cluster down — the r18-class "dead
  286. // entity silently skipped" failure mode must not exist.
  287. let toml = r#"
  288. ssh_user = "u"
  289. [[clusters]]
  290. name = "c1"
  291. nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0 }]
  292. extra_entities = ["rt"]
  293. [mikrotik]
  294. [[mikrotik.devices]]
  295. name = "rt"
  296. address = "127.0.0.1"
  297. username = "x"
  298. password = "y"
  299. api_port = 1
  300. "#;
  301. let config: crate::config::Config = toml::from_str(toml).unwrap();
  302. let cluster = &config.clusters[0];
  303. let st = extra_entities_states(&config, cluster).await;
  304. assert!(
  305. st.blocking_off,
  306. "unreachable mikrotik device must be blocking-off"
  307. );
  308. }
  309. #[tokio::test]
  310. async fn mikrotik_device_without_backend_not_reported_off() {
  311. // No [mikrotik] section, no HA → the entity has no backend at all:
  312. // "nothing to check", not off (legacy semantics).
  313. let toml = r#"
  314. ssh_user = "u"
  315. [[clusters]]
  316. name = "c1"
  317. nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0 }]
  318. extra_entities = ["rt"]
  319. "#;
  320. let config: crate::config::Config = toml::from_str(toml).unwrap();
  321. let cluster = &config.clusters[0];
  322. let st = extra_entities_states(&config, cluster).await;
  323. assert!(!st.blocking_off);
  324. assert!(st.soft_off.is_empty());
  325. }
  326. #[test]
  327. fn split_entity_basic() {
  328. assert_eq!(split_entity("switch.dgx1").unwrap(), ("switch", "dgx1"));
  329. assert_eq!(
  330. split_entity("light.living_room").unwrap(),
  331. ("light", "living_room")
  332. );
  333. }
  334. #[test]
  335. fn split_entity_no_domain_errors() {
  336. assert!(split_entity("dgx1").is_err());
  337. }
  338. #[test]
  339. fn split_entity_empty_entity_errors() {
  340. assert!(split_entity("switch.").is_err());
  341. }
  342. #[test]
  343. fn split_entity_empty_domain_errors() {
  344. assert!(split_entity(".thing").is_err());
  345. }
  346. #[test]
  347. fn endpoint_extracts_domain() {
  348. let (_d, path) = Client::endpoint("switch.dgx1", "turn_off").unwrap();
  349. assert_eq!(path, "/api/services/switch/turn_off");
  350. let (_d, path) = Client::endpoint("light.kitchen", "turn_on").unwrap();
  351. assert_eq!(path, "/api/services/light/turn_on");
  352. }
  353. #[test]
  354. fn endpoint_rejects_no_domain() {
  355. assert!(Client::endpoint("plain", "turn_off").is_err());
  356. }
  357. }