Files
musicmouse/slint-frontend/src/ha.rs
Martin Bauer 794126bfdb Port "Mein Zimmer" - the Home Assistant room panel
The second of the web front-end's three pages. Lights get a toggle, five brightness
steps and six colours; shutters get four presets, up/stop/down and a window that
reflects the position Home Assistant reports. Its own violet hue rather than the
player's teal, so the two pages read as siblings rather than one bleeding into the
other - the same reason room.css has its own tokens.

The backend keeps Home Assistant's URL and token and relays the calls, so this client
learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha
means Home Assistant is not configured, which is a normal answer: the tab simply does
not appear.

Polled, not subscribed, and only while the page is on screen. HA's own auth-and-
subscribe websocket is more machinery than a room panel needs, and a lamp's state is
not worth a request every 2.5 seconds when nobody is looking at it - this device has a
music player to stay out of the way of. A tap patches the entity locally and records
when; a poll that was already in flight when the patch landed is dropped for that
entity rather than putting the lamp back to "off" until the next one catches up. That
reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried
over for the same reason.

Nothing on a shutter card animates locally. A real cover reports its own movement and
position, and a client-side guess at where it will end up is precisely what would
fight with that - the design mockup animated it because it had no backend to ask.

Two additions beyond a straight port, both because a kiosk has no pointer:

CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a
front-end whose README claims every screen is reachable from the keyboard. Plain TAB
was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere
else.

--page opens directly on a page, which a kiosk may well want and which is the only way
in with neither keyboard nor pointer.

Verified against the real Home Assistant: the shutter reported open and the lamp off,
and both drew that way. Nothing was switched - those entities are hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 01:02:18 +02:00

320 lines
10 KiB
Rust

//! 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<String>,
}
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<Device>,
#[serde(default)]
pub scenes: Vec<Device>,
}
/// 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<String, serde_json::Value>,
}
impl EntityState {
pub fn is_on(&self) -> bool {
self.state == "on"
}
fn number(&self, key: &str) -> Option<f64> {
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<f64> {
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<Option<Config>, Error> {
match self.get_json::<Config>("/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<EntityState, Error> {
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<String, EntityState>,
/// 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<String>),
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<Job>,
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");
}
}