//! Home Assistant, through the backend's proxy. //! //! Ported from `web/src/hooks/useHomeAssistant.ts`. The browser never learns Home //! Assistant's URL or token and neither does this: the backend keeps both and relays //! `GET /api/ha/states/{id}` and `POST /api/ha/services/{domain}/{service}`, so all //! this client knows is which entities exist. //! //! A short poll rather than a subscription. HA's own auth/subscribe websocket is more //! machinery than a room panel needs, and the panel is only on screen while someone is //! looking at it. use std::collections::HashMap; use std::time::{Duration, Instant}; use serde::Deserialize; use crate::api::{Client, Error}; pub const POLL_INTERVAL: Duration = Duration::from_millis(2500); #[derive(Debug, Clone, Deserialize)] pub struct Device { pub entity_id: String, #[serde(default)] pub name: Option, } impl Device { pub fn label(&self) -> &str { self.name.as_deref().unwrap_or(&self.entity_id) } pub fn domain(&self) -> &str { self.entity_id.split('.').next().unwrap_or("") } } /// Presence-only, like the LIRC config - there is nothing secret in a list of entity /// ids. `None` from `GET /api/ha` means Home Assistant is not configured at all, which /// is a normal answer and hides the tab rather than being an error. #[derive(Debug, Clone, Default, Deserialize)] pub struct Config { #[serde(default)] pub devices: Vec, #[serde(default)] pub scenes: Vec, } /// One entity's `state`/`attributes`, relayed byte-for-byte from Home Assistant. #[derive(Debug, Clone, Deserialize)] pub struct EntityState { #[serde(default)] pub state: String, #[serde(default)] pub attributes: serde_json::Map, } impl EntityState { pub fn is_on(&self) -> bool { self.state == "on" } fn number(&self, key: &str) -> Option { self.attributes.get(key)?.as_f64() } /// 0..255 as Home Assistant reports it. pub fn brightness(&self) -> f64 { self.number("brightness").unwrap_or(0.0) } /// Percent *open*, which is HA's convention. The room UI thinks in percent closed - /// see `closed_percent`. pub fn cover_position(&self) -> Option { self.number("current_position") } pub fn supported_features(&self) -> u64 { self.number("supported_features").unwrap_or(0.0) as u64 } pub fn rgb(&self) -> [u8; 3] { let Some(values) = self.attributes.get("rgb_color").and_then(|v| v.as_array()) else { // The warm default the web card falls back to, so an on-but-uncoloured lamp // is not drawn as if it were off. return [255, 214, 140]; }; let mut out = [255u8, 214, 140]; for (slot, value) in out.iter_mut().zip(values) { if let Some(number) = value.as_f64() { *slot = number.clamp(0.0, 255.0) as u8; } } out } /// Whether this light can be given a colour, as opposed to only a brightness. A /// `brightness_pct` sent to a plain on/off bulb is harmlessly ignored by HA, so /// only the colour row is worth branching on. pub fn is_color(&self) -> bool { const COLOR_MODES: [&str; 5] = ["hs", "rgb", "rgbw", "rgbww", "xy"]; self.attributes .get("supported_color_modes") .and_then(|v| v.as_array()) .is_some_and(|modes| { modes .iter() .filter_map(|m| m.as_str()) .any(|m| COLOR_MODES.contains(&m)) }) } } // --------------------------------------------------------------------- shutters -- /// Home Assistant's `CoverEntityFeature.SET_POSITION`. pub const SUPPORT_SET_POSITION: u64 = 4; /// The four buttons on a shutter card, in percent *closed*. pub const SHUTTER_PRESETS: [(&str, f64); 4] = [ ("Offen", 0.0), ("Halb zu", 50.0), ("Fast zu", 85.0), ("Ganz zu", 100.0), ]; /// HA reports percent *open*; the UI, following the design, thinks in percent *closed*. /// The inversion lives in one place because it is easy to get backwards. pub fn closed_percent(position: f64) -> f64 { 100.0 - position } pub fn position_from_closed(closed: f64) -> f64 { 100.0 - closed } pub fn closed_label(closed: f64) -> String { if closed <= 1.0 { "Offen".into() } else if closed >= 99.0 { "Ganz zu".into() } else if closed >= 80.0 { "Fast zu".into() } else if closed >= 40.0 { "Halb zu".into() } else { format!("{}% zu", closed.round()) } } // ---------------------------------------------------------------------- colours -- /// The six colours the room page offers, with the oklch each was authored as. Kept as /// literals rather than converted at runtime for the same reason `ui/theme.slint` does: /// the oklch is the form the design is maintained in, and the sRGB is what gets drawn. pub const SWATCHES: [(&str, [u8; 3]); 6] = [ ("Warmweiß", [0xf0, 0xdc, 0xb1]), // oklch(90% 0.06 85) ("Sonnengelb", [0xef, 0xcc, 0x36]), // oklch(85% 0.16 95) ("Korallenrot", [0xf1, 0x4d, 0x4c]), // oklch(65% 0.20 25) ("Delfinblau", [0x00, 0xab, 0xed]), // oklch(70% 0.15 235) ("Riffgrün", [0x42, 0xcb, 0x80]), // oklch(75% 0.16 155) ("Quallenlila", [0xb1, 0x6a, 0xe0]), // oklch(65% 0.18 310) ]; // ----------------------------------------------------------------------- client -- impl Client { /// `Ok(None)` when Home Assistant is not configured - a 404 here is a normal /// answer, and the room tab simply does not appear. pub fn ha_config(&self) -> Result, Error> { match self.get_json::("/ha") { Ok(config) => Ok(Some(config)), Err(error) if error.contains("404") => Ok(None), Err(error) => Err(error), } } pub fn ha_state(&self, entity_id: &str) -> Result { self.get_json(&format!("/ha/states/{entity_id}")) } pub fn ha_call( &self, domain: &str, service: &str, body: serde_json::Value, ) -> Result<(), Error> { self.post_json(&format!("/ha/services/{domain}/{service}"), body) } } /// What the poll thread sends back. pub struct Snapshot { pub states: HashMap, /// When the request that produced this went out. The UI drops any entity whose /// optimistic patch is newer - see the note in `Room::apply`. pub started_at: Instant, } pub enum Job { /// Poll these entities once, now. Poll(Vec), Call { domain: String, service: String, body: serde_json::Value, }, } /// One worker for both polling and commands, so a command can never overtake the poll /// that was meant to confirm it. pub fn spawn( client: Client, jobs: std::sync::mpsc::Receiver, deliver: impl Fn(Snapshot) + Send + 'static, ) -> std::thread::JoinHandle<()> { std::thread::Builder::new() .name("ha".into()) .spawn(move || { for job in jobs { match job { Job::Call { domain, service, body, } => { if let Err(error) = client.ha_call(&domain, &service, body) { // The next poll corrects whatever the optimistic update // guessed, so a failed call is worth a line and nothing more. eprintln!("{domain}.{service}: {error}"); } } Job::Poll(entity_ids) => { let started_at = Instant::now(); let mut states = HashMap::new(); for entity_id in entity_ids { if let Ok(state) = client.ha_state(&entity_id) { states.insert(entity_id, state); } } deliver(Snapshot { states, started_at }); } } } }) .expect("spawning the home assistant worker") } #[cfg(test)] mod tests { use super::*; fn state(json: serde_json::Value) -> EntityState { serde_json::from_value(json).unwrap() } #[test] fn closed_percent_inverts_home_assistants_convention() { assert_eq!(closed_percent(100.0), 0.0); // fully open assert_eq!(closed_percent(0.0), 100.0); // fully closed assert_eq!(position_from_closed(85.0), 15.0); } #[test] fn closed_label_names_the_presets_and_falls_back_to_a_number() { assert_eq!(closed_label(0.0), "Offen"); assert_eq!(closed_label(100.0), "Ganz zu"); assert_eq!(closed_label(85.0), "Fast zu"); assert_eq!(closed_label(50.0), "Halb zu"); assert_eq!(closed_label(20.0), "20% zu"); } #[test] fn a_colour_light_is_told_apart_by_its_modes() { assert!(state(serde_json::json!({ "state": "on", "attributes": { "supported_color_modes": ["hs", "color_temp"] } })) .is_color()); assert!(!state(serde_json::json!({ "state": "on", "attributes": { "supported_color_modes": ["brightness"] } })) .is_color()); assert!(!state(serde_json::json!({ "state": "on", "attributes": {} })).is_color()); } #[test] fn rgb_falls_back_to_a_warm_white_rather_than_black() { assert_eq!( state(serde_json::json!({"state":"on","attributes":{}})).rgb(), [255, 214, 140] ); assert_eq!( state(serde_json::json!({ "state": "on", "attributes": { "rgb_color": [10, 20, 30] } })) .rgb(), [10, 20, 30] ); } #[test] fn a_missing_attribute_reads_as_zero_rather_than_failing() { let s = state(serde_json::json!({ "state": "off", "attributes": {} })); assert_eq!(s.brightness(), 0.0); assert_eq!(s.cover_position(), None); assert_eq!(s.supported_features(), 0); assert!(!s.is_on()); } #[test] fn a_device_knows_its_domain_and_its_label() { let device: Device = serde_json::from_value(serde_json::json!({ "entity_id": "light.kitchen" })).unwrap(); assert_eq!(device.domain(), "light"); assert_eq!(device.label(), "light.kitchen"); } }