home_assistant.rs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  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::time::Duration;
  10. use tracing::{debug, info, warn};
  11. use crate::config::{Cluster, Config, HomeAssistantConfig};
  12. /// Thin async wrapper around the HA REST API.
  13. ///
  14. /// `base_url` is the HA root URL (`http://homeassistant.local:8123`),
  15. /// `token` is the long-lived access token used as bearer auth.
  16. #[derive(Clone)]
  17. pub struct Client {
  18. base_url: String,
  19. token: String,
  20. http: reqwest::Client,
  21. }
  22. impl Client {
  23. /// Construct a new client. The reqwest client is built once and
  24. /// reused so connection pooling works across the parallel calls in
  25. /// the `power` subcommand.
  26. pub fn new(cfg: &HomeAssistantConfig) -> Result<Self> {
  27. let base_url = cfg.url.trim_end_matches('/').to_string();
  28. if base_url.is_empty() {
  29. anyhow::bail!("[home_assistant].url must be set");
  30. }
  31. let http = reqwest::Client::builder()
  32. .timeout(Duration::from_secs(10))
  33. .build()
  34. .context("building reqwest client")?;
  35. Ok(Self {
  36. base_url,
  37. token: cfg.token.clone(),
  38. http,
  39. })
  40. }
  41. /// Build `https://.../api/services/<domain>/<service>` for the given
  42. /// `entity_id`. Domain is the prefix before the first `.`; pass an
  43. /// empty fallback for ambiguous input.
  44. fn endpoint(entity_id: &str, service: &str) -> Result<(String, String)> {
  45. let (domain, entity) = entity_id.split_once('.').ok_or_else(|| {
  46. anyhow!(
  47. "entity_id '{}' has no domain (e.g. 'switch.foo')",
  48. entity_id
  49. )
  50. })?;
  51. if entity.is_empty() {
  52. anyhow::bail!("entity_id '{}' has empty entity name", entity_id);
  53. }
  54. Ok((
  55. domain.to_string(),
  56. format!("/api/services/{}/{}", domain, service),
  57. ))
  58. }
  59. /// POST `/api/services/<domain>/<service>` with `{"entity_id": ...}`.
  60. async fn call_service(&self, entity_id: &str, service: &str) -> Result<()> {
  61. let (_domain, path) = Self::endpoint(entity_id, service)?;
  62. let url = format!("{}{}", self.base_url, path);
  63. debug!("HA POST {}", url);
  64. let resp = self
  65. .http
  66. .post(&url)
  67. .bearer_auth(&self.token)
  68. .json(&json!({ "entity_id": entity_id }))
  69. .send()
  70. .await
  71. .with_context(|| format!("calling HA at {}", url))?;
  72. let status = resp.status();
  73. if !status.is_success() {
  74. let body = resp.text().await.unwrap_or_default();
  75. anyhow::bail!(
  76. "HA {} {} returned {}: {}",
  77. service,
  78. entity_id,
  79. status,
  80. body.trim()
  81. );
  82. }
  83. Ok(())
  84. }
  85. /// `switch.turn_off` for the given entity.
  86. pub async fn turn_off(&self, entity_id: &str) -> Result<()> {
  87. info!("HA turn_off {}", entity_id);
  88. self.call_service(entity_id, "turn_off").await
  89. }
  90. /// `switch.turn_on` for the given entity.
  91. pub async fn turn_on(&self, entity_id: &str) -> Result<()> {
  92. info!("HA turn_on {}", entity_id);
  93. self.call_service(entity_id, "turn_on").await
  94. }
  95. /// GET `/api/states/<entity_id>` and return the state string.
  96. /// Returns `None` if the entity doesn't exist or isn't reachable.
  97. /// Logs a warning when the HTTP request or JSON parsing fails so
  98. /// callers (e.g. `entity_is_on`) can distinguish "entity is off"
  99. /// from "HA is unreachable" via the log.
  100. pub async fn get_state(&self, entity_id: &str) -> Option<String> {
  101. let url = format!("{}/api/states/{}", self.base_url, entity_id);
  102. let resp = match self.http.get(&url).bearer_auth(&self.token).send().await {
  103. Ok(r) => r,
  104. Err(e) => {
  105. warn!("HA GET {} failed: {}", url, e);
  106. return None;
  107. }
  108. };
  109. if !resp.status().is_success() {
  110. warn!("HA GET {} returned {}", url, resp.status());
  111. return None;
  112. }
  113. let body: serde_json::Value = match resp.json().await {
  114. Ok(v) => v,
  115. Err(e) => {
  116. warn!("HA GET {} JSON parse failed: {}", url, e);
  117. return None;
  118. }
  119. };
  120. body.get("state").and_then(|s| s.as_str()).map(String::from)
  121. }
  122. }
  123. /// HA states for a cluster's `extra_entities` (parallel GETs).
  124. #[derive(Clone, Debug, Default, PartialEq)]
  125. pub struct EntityStates {
  126. /// A *blocking* entity reports anything other than "on" — including
  127. /// HA being unreachable (unknown is treated as off, same as the
  128. /// `health` command has always done). Blocking-off takes the whole
  129. /// cluster down.
  130. pub blocking_off: bool,
  131. /// Non-blocking entities (`blocks = false`) that are not "on" —
  132. /// tracked and displayed, but they never affect up/down.
  133. pub soft_off: Vec<String>,
  134. }
  135. /// Probe the cluster's `extra_entities`. No HA config, no entities, or an
  136. /// unparseable HA URL → default (nothing to check ≠ off).
  137. pub async fn extra_entities_states(config: &Config, cluster: &Cluster) -> EntityStates {
  138. let Some(ha_cfg) = &config.home_assistant else {
  139. return EntityStates::default();
  140. };
  141. if cluster.extra_entities.is_empty() {
  142. return EntityStates::default();
  143. }
  144. let Ok(client) = Client::new(ha_cfg) else {
  145. return EntityStates::default();
  146. };
  147. let mut handles = Vec::new();
  148. for e in &cluster.extra_entities {
  149. let client = client.clone();
  150. let id = e.entity().to_string();
  151. let blocks = e.blocks();
  152. handles.push(crate::locale::spawn_with_locale(async move {
  153. (id.clone(), blocks, client.get_state(&id).await)
  154. }));
  155. }
  156. let mut st = EntityStates::default();
  157. for h in futures::future::join_all(handles).await {
  158. let Ok((id, blocks, state)) = h else {
  159. continue;
  160. };
  161. let on = matches!(&state, Some(s) if s == "on");
  162. if !on {
  163. if blocks {
  164. st.blocking_off = true;
  165. } else {
  166. st.soft_off.push(id);
  167. }
  168. }
  169. }
  170. st
  171. }
  172. /// Parse `switch.dgx1` into `("switch", "dgx1")` so callers can build
  173. /// their own endpoints if needed. Exposed for the `power` subcommand
  174. /// and for tests.
  175. pub fn split_entity(entity_id: &str) -> Result<(&str, &str)> {
  176. let (domain, entity) = entity_id.split_once('.').ok_or_else(|| {
  177. anyhow!(
  178. "entity_id '{}' has no domain (e.g. 'switch.foo')",
  179. entity_id
  180. )
  181. })?;
  182. if domain.is_empty() || entity.is_empty() {
  183. anyhow::bail!("entity_id '{}' has empty domain or entity name", entity_id);
  184. }
  185. Ok((domain, entity))
  186. }
  187. /// Emit a deprecation-style warning if a HA token looks suspiciously
  188. /// short. HA tokens are typically 100+ chars; anything shorter is
  189. /// almost certainly a placeholder.
  190. pub fn warn_if_token_looks_short(token: &str) {
  191. if token.len() < 20 {
  192. warn!(
  193. "[home_assistant].token looks suspiciously short ({} chars); \
  194. did you paste a placeholder?",
  195. token.len()
  196. );
  197. }
  198. }
  199. #[cfg(test)]
  200. mod tests {
  201. use super::*;
  202. #[test]
  203. fn split_entity_basic() {
  204. assert_eq!(split_entity("switch.dgx1").unwrap(), ("switch", "dgx1"));
  205. assert_eq!(
  206. split_entity("light.living_room").unwrap(),
  207. ("light", "living_room")
  208. );
  209. }
  210. #[test]
  211. fn split_entity_no_domain_errors() {
  212. assert!(split_entity("dgx1").is_err());
  213. }
  214. #[test]
  215. fn split_entity_empty_entity_errors() {
  216. assert!(split_entity("switch.").is_err());
  217. }
  218. #[test]
  219. fn split_entity_empty_domain_errors() {
  220. assert!(split_entity(".thing").is_err());
  221. }
  222. #[test]
  223. fn endpoint_extracts_domain() {
  224. let (_d, path) = Client::endpoint("switch.dgx1", "turn_off").unwrap();
  225. assert_eq!(path, "/api/services/switch/turn_off");
  226. let (_d, path) = Client::endpoint("light.kitchen", "turn_on").unwrap();
  227. assert_eq!(path, "/api/services/light/turn_on");
  228. }
  229. #[test]
  230. fn endpoint_rejects_no_domain() {
  231. assert!(Client::endpoint("plain", "turn_off").is_err());
  232. }
  233. }