home_assistant.rs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  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::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. pub struct Client {
  17. base_url: String,
  18. token: String,
  19. http: reqwest::Client,
  20. }
  21. impl Client {
  22. /// Construct a new client. The reqwest client is built once and
  23. /// reused so connection pooling works across the parallel calls in
  24. /// the `power` subcommand.
  25. pub fn new(cfg: &HomeAssistantConfig) -> Result<Self> {
  26. let base_url = cfg.url.trim_end_matches('/').to_string();
  27. if base_url.is_empty() {
  28. anyhow::bail!("[home_assistant].url must be set");
  29. }
  30. let http = reqwest::Client::builder()
  31. .timeout(Duration::from_secs(10))
  32. .build()
  33. .context("building reqwest client")?;
  34. Ok(Self {
  35. base_url,
  36. token: cfg.token.clone(),
  37. http,
  38. })
  39. }
  40. /// Build `https://.../api/services/<domain>/<service>` for the given
  41. /// `entity_id`. Domain is the prefix before the first `.`; pass an
  42. /// empty fallback for ambiguous input.
  43. fn endpoint(entity_id: &str, service: &str) -> Result<(String, String)> {
  44. let (domain, entity) = entity_id.split_once('.').ok_or_else(|| {
  45. anyhow!(
  46. "entity_id '{}' has no domain (e.g. 'switch.foo')",
  47. entity_id
  48. )
  49. })?;
  50. if entity.is_empty() {
  51. anyhow::bail!("entity_id '{}' has empty entity name", entity_id);
  52. }
  53. Ok((
  54. domain.to_string(),
  55. format!("/api/services/{}/{}", domain, service),
  56. ))
  57. }
  58. /// POST `/api/services/<domain>/<service>` with `{"entity_id": ...}`.
  59. async fn call_service(&self, entity_id: &str, service: &str) -> Result<()> {
  60. let (_domain, path) = Self::endpoint(entity_id, service)?;
  61. let url = format!("{}{}", self.base_url, path);
  62. debug!("HA POST {}", url);
  63. let resp = self
  64. .http
  65. .post(&url)
  66. .bearer_auth(&self.token)
  67. .json(&json!({ "entity_id": entity_id }))
  68. .send()
  69. .await
  70. .with_context(|| format!("calling HA at {}", url))?;
  71. let status = resp.status();
  72. if !status.is_success() {
  73. let body = resp.text().await.unwrap_or_default();
  74. anyhow::bail!(
  75. "HA {} {} returned {}: {}",
  76. service,
  77. entity_id,
  78. status,
  79. body.trim()
  80. );
  81. }
  82. Ok(())
  83. }
  84. /// `switch.turn_off` for the given entity.
  85. pub async fn turn_off(&self, entity_id: &str) -> Result<()> {
  86. info!("HA turn_off {}", entity_id);
  87. self.call_service(entity_id, "turn_off").await
  88. }
  89. /// `switch.turn_on` for the given entity.
  90. pub async fn turn_on(&self, entity_id: &str) -> Result<()> {
  91. info!("HA turn_on {}", entity_id);
  92. self.call_service(entity_id, "turn_on").await
  93. }
  94. }
  95. /// Parse `switch.dgx1` into `("switch", "dgx1")` so callers can build
  96. /// their own endpoints if needed. Exposed for the `power` subcommand
  97. /// and for tests.
  98. pub fn split_entity(entity_id: &str) -> Result<(&str, &str)> {
  99. let (domain, entity) = entity_id.split_once('.').ok_or_else(|| {
  100. anyhow!(
  101. "entity_id '{}' has no domain (e.g. 'switch.foo')",
  102. entity_id
  103. )
  104. })?;
  105. if domain.is_empty() || entity.is_empty() {
  106. anyhow::bail!("entity_id '{}' has empty domain or entity name", entity_id);
  107. }
  108. Ok((domain, entity))
  109. }
  110. /// Emit a deprecation-style warning if a HA token looks suspiciously
  111. /// short. HA tokens are typically 100+ chars; anything shorter is
  112. /// almost certainly a placeholder.
  113. pub fn warn_if_token_looks_short(token: &str) {
  114. if token.len() < 20 {
  115. warn!(
  116. "[home_assistant].token looks suspiciously short ({} chars); \
  117. did you paste a placeholder?",
  118. token.len()
  119. );
  120. }
  121. }
  122. #[cfg(test)]
  123. mod tests {
  124. use super::*;
  125. #[test]
  126. fn split_entity_basic() {
  127. assert_eq!(split_entity("switch.dgx1").unwrap(), ("switch", "dgx1"));
  128. assert_eq!(
  129. split_entity("light.living_room").unwrap(),
  130. ("light", "living_room")
  131. );
  132. }
  133. #[test]
  134. fn split_entity_no_domain_errors() {
  135. assert!(split_entity("dgx1").is_err());
  136. }
  137. #[test]
  138. fn split_entity_empty_entity_errors() {
  139. assert!(split_entity("switch.").is_err());
  140. }
  141. #[test]
  142. fn split_entity_empty_domain_errors() {
  143. assert!(split_entity(".thing").is_err());
  144. }
  145. #[test]
  146. fn endpoint_extracts_domain() {
  147. let (_d, path) = Client::endpoint("switch.dgx1", "turn_off").unwrap();
  148. assert_eq!(path, "/api/services/switch/turn_off");
  149. let (_d, path) = Client::endpoint("light.kitchen", "turn_on").unwrap();
  150. assert_eq!(path, "/api/services/light/turn_on");
  151. }
  152. #[test]
  153. fn endpoint_rejects_no_domain() {
  154. assert!(Client::endpoint("plain", "turn_off").is_err());
  155. }
  156. }