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>
This commit is contained in:
@@ -13,23 +13,25 @@
|
||||
mod api;
|
||||
mod covers;
|
||||
mod format;
|
||||
mod ha;
|
||||
mod keymap;
|
||||
mod library;
|
||||
mod oklch;
|
||||
mod ws;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{channel, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use slint::{ModelRc, SharedString, VecModel};
|
||||
|
||||
use api::{Album, Client, PlayerState};
|
||||
|
||||
use covers::Tier;
|
||||
use keymap::{Action, UiState};
|
||||
use keymap::{Action, Page, UiState};
|
||||
use library::{Group, Library, Query, Results, GROUPS};
|
||||
|
||||
slint::include_modules!();
|
||||
@@ -63,6 +65,9 @@ enum FromWorker {
|
||||
Library(Result<Vec<Album>, String>),
|
||||
Cover(covers::Ready),
|
||||
Ws(WsEvent),
|
||||
/// `None` means Home Assistant is not configured, which hides the room tab.
|
||||
HaConfig(Option<ha::Config>),
|
||||
HaStates(ha::Snapshot),
|
||||
}
|
||||
|
||||
enum WsEvent {
|
||||
@@ -90,6 +95,20 @@ struct App {
|
||||
/// False for exactly one render: the frame a track changes on, and the frame a seek
|
||||
/// lands on.
|
||||
smooth: bool,
|
||||
|
||||
// ------------------------------------------------------------ Mein Zimmer --
|
||||
ha: Sender<ha::Job>,
|
||||
ha_config: Option<ha::Config>,
|
||||
ha_states: HashMap<String, ha::EntityState>,
|
||||
/// When each entity last got an optimistic patch. A poll that was already in
|
||||
/// flight when the patch landed resolves with pre-click data; without this, that
|
||||
/// stale answer puts a lamp back to "off" until the next poll catches up, which
|
||||
/// is what made the web page feel laggy despite having optimistic updates.
|
||||
ha_patched_at: HashMap<String, Instant>,
|
||||
ha_loaded: bool,
|
||||
/// Home Assistant scenes have no "currently active" state of their own, so this is
|
||||
/// purely local: set by activating one, cleared by any manual device change.
|
||||
active_scene: Option<String>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -122,6 +141,41 @@ impl App {
|
||||
]
|
||||
}
|
||||
|
||||
fn ha_send(&self, job: ha::Job) {
|
||||
let _ = self.ha.send(job);
|
||||
}
|
||||
|
||||
/// Patch one entity locally so a tap feels instant, and remember when, so the poll
|
||||
/// already in flight cannot undo it.
|
||||
fn ha_optimistic(&mut self, entity_id: &str, patch: impl FnOnce(&mut ha::EntityState)) {
|
||||
self.ha_patched_at
|
||||
.insert(entity_id.to_string(), Instant::now());
|
||||
if let Some(state) = self.ha_states.get_mut(entity_id) {
|
||||
patch(state);
|
||||
}
|
||||
self.active_scene = None;
|
||||
}
|
||||
|
||||
fn ha_call(&self, domain: &str, service: &str, body: serde_json::Value) {
|
||||
self.ha_send(ha::Job::Call {
|
||||
domain: domain.into(),
|
||||
service: service.into(),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
fn ha_entity_ids(&self) -> Vec<String> {
|
||||
let Some(config) = &self.ha_config else {
|
||||
return Vec::new();
|
||||
};
|
||||
config
|
||||
.devices
|
||||
.iter()
|
||||
.chain(config.scenes.iter())
|
||||
.map(|d| d.entity_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply(&mut self, actions: Vec<Action>) {
|
||||
for action in actions {
|
||||
match action {
|
||||
@@ -409,6 +463,35 @@ fn render(app: &mut App, window: &AppWindow) {
|
||||
window.set_now(now);
|
||||
window.set_has_bar(has_album && !app.ui.play_view);
|
||||
|
||||
// The room tab only exists when Home Assistant is configured - the backend
|
||||
// answering 404 for /api/ha is how that is known.
|
||||
let tabs: Vec<Tab> = [Page::Music, Page::Room, Page::Typing]
|
||||
.into_iter()
|
||||
.filter(|page| *page != Page::Room || app.ha_config.is_some())
|
||||
.map(|page| Tab {
|
||||
id: match page {
|
||||
Page::Music => "music",
|
||||
Page::Room => "room",
|
||||
Page::Typing => "typing",
|
||||
}
|
||||
.into(),
|
||||
icon: page.icon().into(),
|
||||
label: page.label().into(),
|
||||
})
|
||||
.collect();
|
||||
window.set_tabs(ModelRc::new(VecModel::from(tabs)));
|
||||
|
||||
window.set_page(
|
||||
match app.ui.page {
|
||||
Page::Music => "music",
|
||||
Page::Room => "room",
|
||||
Page::Typing => "typing",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
render_room(app, window);
|
||||
|
||||
let root_screen = app.ui.is_root_shelf();
|
||||
window.set_root_screen(root_screen);
|
||||
window.set_shelf_row(app.ui.shelf_row as i32);
|
||||
@@ -573,6 +656,94 @@ fn render_results(app: &mut App, window: &AppWindow, cols: usize) {
|
||||
window.set_grid(ModelRc::new(VecModel::from(grid)));
|
||||
}
|
||||
|
||||
/// Build the room page's models. One `RoomCard` list in Home Assistant's own config
|
||||
/// order, because that order is the only layout information the config carries.
|
||||
fn render_room(app: &mut App, window: &AppWindow) {
|
||||
let Some(config) = app.ha_config.clone() else {
|
||||
return;
|
||||
};
|
||||
window.set_room_loading(!app.ha_loaded);
|
||||
|
||||
let mut cards = Vec::with_capacity(config.devices.len());
|
||||
for device in &config.devices {
|
||||
let state = app.ha_states.get(&device.entity_id);
|
||||
let mut card = RoomCard {
|
||||
entity_id: device.entity_id.as_str().into(),
|
||||
name: device.label().into(),
|
||||
..Default::default()
|
||||
};
|
||||
match device.domain() {
|
||||
"light" => {
|
||||
let on = state.is_some_and(|s| s.is_on());
|
||||
let rgb = state.map_or([255, 214, 140], |s| s.rgb());
|
||||
card.kind = "light".into();
|
||||
card.on = on;
|
||||
card.is_color = state.is_some_and(|s| s.is_color());
|
||||
// 0..5, matching the five buttons - HA reports 0..255.
|
||||
card.level = state
|
||||
.map_or(0.0, |s| (s.brightness() / 255.0 * 5.0).round())
|
||||
.clamp(0.0, 5.0) as i32;
|
||||
card.tint = slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]);
|
||||
card.swatch = ha::SWATCHES
|
||||
.iter()
|
||||
.position(|(_, swatch)| *swatch == rgb)
|
||||
.map_or(-1, |i| i as i32);
|
||||
}
|
||||
"cover" => {
|
||||
let position = state.and_then(|s| s.cover_position());
|
||||
let features = state.map_or(0, |s| s.supported_features());
|
||||
let closed = ha::closed_percent(position.unwrap_or(0.0));
|
||||
let moving = match state.map(|s| s.state.as_str()) {
|
||||
Some("opening") => "up",
|
||||
Some("closing") => "down",
|
||||
_ => "",
|
||||
};
|
||||
card.kind = "shutter".into();
|
||||
card.closed_percent = closed as f32;
|
||||
card.moving = moving.into();
|
||||
card.status = match moving {
|
||||
"down" => "Fährt runter …".to_string(),
|
||||
"up" => "Fährt hoch …".to_string(),
|
||||
_ => ha::closed_label(closed),
|
||||
}
|
||||
.into();
|
||||
card.supports_position =
|
||||
position.is_some() || features & ha::SUPPORT_SET_POSITION != 0;
|
||||
let label = ha::closed_label(closed);
|
||||
card.preset = ha::SHUTTER_PRESETS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == label)
|
||||
.map_or(-1, |i| i as i32);
|
||||
}
|
||||
// Not a domain this page knows how to draw - skip rather than guess.
|
||||
_ => continue,
|
||||
}
|
||||
cards.push(card);
|
||||
}
|
||||
|
||||
let scenes: Vec<SceneTile> = config
|
||||
.scenes
|
||||
.iter()
|
||||
.map(|scene| SceneTile {
|
||||
entity_id: scene.entity_id.as_str().into(),
|
||||
name: scene.label().into(),
|
||||
active: app.active_scene.as_deref() == Some(scene.entity_id.as_str()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Three across at 1080p, which is what the web grid's `minmax(300px, 1fr)` inside
|
||||
// a 1180px column comes to on the same screen.
|
||||
let columns = ((window.get_grid_width() / 400.0).floor() as usize).clamp(1, 3);
|
||||
let rows: Vec<RoomRow> = cards
|
||||
.chunks(columns)
|
||||
.map(|chunk| RoomRow {
|
||||
cards: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||||
})
|
||||
.collect();
|
||||
window.set_room_rows(ModelRc::new(VecModel::from(rows)));
|
||||
window.set_room_scenes(ModelRc::new(VecModel::from(scenes)));
|
||||
}
|
||||
|
||||
fn render_sheet(app: &mut App, window: &AppWindow) {
|
||||
let Some(album) = app
|
||||
.ui
|
||||
@@ -631,10 +802,16 @@ fn render_sheet(app: &mut App, window: &AppWindow) {
|
||||
// ----------------------------------------------------------------------- wiring --
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let base = std::env::args()
|
||||
.skip_while(|a| a != "--server")
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
|
||||
let arg = |name: &str| -> Option<String> { std::env::args().skip_while(|a| a != name).nth(1) };
|
||||
let base = arg("--server").unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
|
||||
// Which page to open on. A kiosk may well want to come up on the room panel rather
|
||||
// than the player, and it is the only way to reach a page without a keyboard or a
|
||||
// pointer.
|
||||
let page = match arg("--page").as_deref() {
|
||||
Some("room") => Page::Room,
|
||||
Some("typing") => Page::Typing,
|
||||
_ => Page::Music,
|
||||
};
|
||||
println!("musicmouse-slint → {base}");
|
||||
|
||||
// Nunito is the face the design is drawn in, vendored as a TTF in `fonts/` because
|
||||
@@ -668,6 +845,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (to_ui, from_workers) = channel::<FromWorker>();
|
||||
let (cover_jobs, cover_rx) = channel::<covers::Job>();
|
||||
let (commands, command_rx) = channel::<Command>();
|
||||
let (ha_jobs, ha_rx) = channel::<ha::Job>();
|
||||
|
||||
// Covers: fetch, downscale, cache to disk. Never on the UI thread.
|
||||
{
|
||||
@@ -711,6 +889,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
})?;
|
||||
}
|
||||
|
||||
// Home Assistant, behind the backend's proxy. One worker for both the poll and
|
||||
// the commands, so a command can never overtake the poll meant to confirm it.
|
||||
{
|
||||
let states_tx = to_ui.clone();
|
||||
ha::spawn(client.clone(), ha_rx, move |snapshot| {
|
||||
let _ = states_tx.send(FromWorker::HaStates(snapshot));
|
||||
});
|
||||
let client = client.clone();
|
||||
let to_ui = to_ui.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ha-config".into())
|
||||
.spawn(move || {
|
||||
// A 404 here means Home Assistant is not configured, which is a normal
|
||||
// answer: the tab simply does not appear.
|
||||
let _ = to_ui.send(FromWorker::HaConfig(client.ha_config().ok().flatten()));
|
||||
})?;
|
||||
}
|
||||
|
||||
// State.
|
||||
{
|
||||
let to_ui = to_ui.clone();
|
||||
@@ -738,10 +934,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let app = Rc::new(RefCell::new(App {
|
||||
commands,
|
||||
ui: UiState::default(),
|
||||
ui: UiState {
|
||||
page,
|
||||
..UiState::default()
|
||||
},
|
||||
player: PlayerState::default(),
|
||||
library: Library::default(),
|
||||
covers: covers::Store::new(cover_jobs),
|
||||
ha: ha_jobs,
|
||||
ha_config: None,
|
||||
ha_states: HashMap::new(),
|
||||
ha_patched_at: HashMap::new(),
|
||||
ha_loaded: false,
|
||||
active_scene: None,
|
||||
online: false,
|
||||
loaded: false,
|
||||
error: None,
|
||||
@@ -749,6 +954,45 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
smooth: true,
|
||||
}));
|
||||
|
||||
// The room page's poll. Only runs while that page is showing: a lamp's state is
|
||||
// not worth a request every 2.5 s when nobody is looking at it, and this device
|
||||
// has a music player to get out of the way of.
|
||||
let poll = slint::Timer::default();
|
||||
{
|
||||
let handle = Rc::clone(&app);
|
||||
let weak = window.as_weak();
|
||||
poll.start(slint::TimerMode::Repeated, ha::POLL_INTERVAL, move || {
|
||||
let Some(window) = weak.upgrade() else { return };
|
||||
let entity_ids = {
|
||||
let app = handle.borrow();
|
||||
if app.ui.page != Page::Room || app.ha_config.is_none() {
|
||||
return;
|
||||
}
|
||||
app.ha_entity_ids()
|
||||
};
|
||||
handle.borrow().ha_send(ha::Job::Poll(entity_ids));
|
||||
let _ = &window;
|
||||
});
|
||||
}
|
||||
|
||||
// The static parts of the room page - the six colours and the four shutter
|
||||
// presets - never change, so they are set once rather than rebuilt per render.
|
||||
window.set_room_swatches(ModelRc::new(VecModel::from(
|
||||
ha::SWATCHES
|
||||
.iter()
|
||||
.map(|(name, rgb)| Swatch {
|
||||
name: (*name).into(),
|
||||
tint: slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)));
|
||||
window.set_shutter_presets(ModelRc::new(VecModel::from(
|
||||
ha::SHUTTER_PRESETS
|
||||
.iter()
|
||||
.map(|(label, _)| SharedString::from(*label))
|
||||
.collect::<Vec<_>>(),
|
||||
)));
|
||||
|
||||
install_callbacks(&app, &window);
|
||||
pump_workers(&app, &window, from_workers);
|
||||
|
||||
@@ -846,6 +1090,23 @@ fn handle_worker_message(app: &mut App, message: FromWorker) {
|
||||
}
|
||||
app.smooth = true;
|
||||
}
|
||||
FromWorker::HaConfig(config) => {
|
||||
app.ha_config = config;
|
||||
if app.ha_config.is_some() {
|
||||
app.ha_send(ha::Job::Poll(app.ha_entity_ids()));
|
||||
}
|
||||
}
|
||||
FromWorker::HaStates(snapshot) => {
|
||||
for (entity_id, state) in snapshot.states {
|
||||
// Drop any entity whose optimistic patch is newer than the request
|
||||
// that produced this - see `ha_patched_at`.
|
||||
let patched = app.ha_patched_at.get(&entity_id).copied();
|
||||
if patched.is_none_or(|at| snapshot.started_at >= at) {
|
||||
app.ha_states.insert(entity_id, state);
|
||||
}
|
||||
}
|
||||
app.ha_loaded = true;
|
||||
}
|
||||
FromWorker::Ws(WsEvent::LibraryChanged) => {
|
||||
// A rescan is the one moment the backend rewrites the art behind an
|
||||
// unchanged cover URL, so the thumbnails go with it.
|
||||
@@ -987,6 +1248,109 @@ fn install_callbacks(app: &Rc<RefCell<App>>, window: &AppWindow) {
|
||||
a.ui.open_album = None;
|
||||
});
|
||||
|
||||
bind!(on_select_page, |a, page: SharedString| {
|
||||
a.ui.page = match page.as_str() {
|
||||
"room" => Page::Room,
|
||||
"typing" => Page::Typing,
|
||||
_ => Page::Music,
|
||||
};
|
||||
// Ask for fresh state the moment the page comes up, rather than making the
|
||||
// first thing you see be up to one poll interval stale.
|
||||
if a.ui.page == Page::Room && a.ha_config.is_some() {
|
||||
a.ha_send(ha::Job::Poll(a.ha_entity_ids()));
|
||||
}
|
||||
});
|
||||
|
||||
bind!(on_toggle_light, |a, id: SharedString| {
|
||||
let on = a.ha_states.get(id.as_str()).is_some_and(|s| s.is_on());
|
||||
a.ha_optimistic(id.as_str(), |state| {
|
||||
state.state = if on { "off".into() } else { "on".into() };
|
||||
});
|
||||
a.ha_call(
|
||||
"light",
|
||||
if on { "turn_off" } else { "turn_on" },
|
||||
serde_json::json!({ "entity_id": id.as_str() }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_set_light_level, |a, id: SharedString, level: i32| {
|
||||
let percent = level.clamp(1, 5) * 20;
|
||||
a.ha_optimistic(id.as_str(), |state| {
|
||||
state.state = "on".into();
|
||||
state.attributes.insert(
|
||||
"brightness".into(),
|
||||
serde_json::json!((percent as f64 / 100.0 * 255.0).round()),
|
||||
);
|
||||
});
|
||||
a.ha_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
serde_json::json!({ "entity_id": id.as_str(), "brightness_pct": percent }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_set_light_swatch, |a, id: SharedString, index: i32| {
|
||||
let Some((_, rgb)) = ha::SWATCHES.get(index.max(0) as usize) else {
|
||||
return;
|
||||
};
|
||||
let rgb = *rgb;
|
||||
a.ha_optimistic(id.as_str(), |state| {
|
||||
state.state = "on".into();
|
||||
state.attributes.insert(
|
||||
"rgb_color".into(),
|
||||
serde_json::json!([rgb[0], rgb[1], rgb[2]]),
|
||||
);
|
||||
});
|
||||
a.ha_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
serde_json::json!({ "entity_id": id.as_str(), "rgb_color": [rgb[0], rgb[1], rgb[2]] }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_set_shutter_preset, |a, id: SharedString, index: i32| {
|
||||
let Some((_, closed)) = ha::SHUTTER_PRESETS.get(index.max(0) as usize) else {
|
||||
return;
|
||||
};
|
||||
let position = ha::position_from_closed(*closed);
|
||||
a.ha_optimistic(id.as_str(), |state| {
|
||||
state
|
||||
.attributes
|
||||
.insert("current_position".into(), serde_json::json!(position));
|
||||
});
|
||||
a.ha_call(
|
||||
"cover",
|
||||
"set_cover_position",
|
||||
serde_json::json!({ "entity_id": id.as_str(), "position": position }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_shutter, |a, id: SharedString, what: SharedString| {
|
||||
let service = match what.as_str() {
|
||||
"open" => "open_cover",
|
||||
"close" => "close_cover",
|
||||
_ => "stop_cover",
|
||||
};
|
||||
// No optimistic position here: a real cover reports its own movement, and
|
||||
// guessing where it will end up is exactly what would fight with that.
|
||||
a.active_scene = None;
|
||||
a.ha_patched_at.remove(id.as_str());
|
||||
a.ha_call(
|
||||
"cover",
|
||||
service,
|
||||
serde_json::json!({ "entity_id": id.as_str() }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_activate_scene, |a, id: SharedString| {
|
||||
a.active_scene = Some(id.to_string());
|
||||
a.ha_call(
|
||||
"scene",
|
||||
"turn_on",
|
||||
serde_json::json!({ "entity_id": id.as_str() }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_play_sheet_track, |a, index: i32| {
|
||||
if let Some(album_id) = a.ui.open_album.clone() {
|
||||
a.apply(vec![Action::Play {
|
||||
|
||||
Reference in New Issue
Block a user