From bde779d759fcec4b79bdb065ded7bb21640e1919 Mon Sep 17 00:00:00 2001 From: Kaloyan Danchev Date: Sun, 25 Oct 2026 22:39:40 +0200 Subject: [PATCH] Redesign media card with album art, 7-button transport, and Wolf Kit icons Replace media card with full redesign: rectangular album art (180px) with background fetcher, track title + artist display, volume icon + percentage, progress bar, and 7-button transport row (playlist, skip-back, seek-back, play/pause, seek-forward, skip-forward, search) with Apple-style opacity focus. Add scroll-wheel seek and back-button card mode exit. Replace all media SVG icons with Wolf Kit Rounded Line icons from SVGRepo (CC Attribution, author: thewolfkit): play, pause, skip-back, skip-forward, seek-back, seek-forward, playlist (line-3), search, volume. Fix vacuum card: add config fallback for custom button cards, expand entity subscriptions for derived entities, fix clean command segment format, remove fill, increase image size to 160px. Co-Authored-By: Claude Opus 4.6 --- assets/ui-icons/pause.svg | 4 + assets/ui-icons/play.svg | 3 + assets/ui-icons/playlist.svg | 5 + assets/ui-icons/search.svg | 4 +- assets/ui-icons/seek-back.svg | 4 + assets/ui-icons/seek-forward.svg | 3 + assets/ui-icons/skip-back.svg | 4 + assets/ui-icons/skip-forward.svg | 4 + assets/ui-icons/volume.svg | 4 + src/album_art.rs | 150 +++++++ src/ha_client.rs | 155 ++++++- src/ha_pairing.rs | 5 +- src/main.rs | 104 ++++- src/state.rs | 247 ++++++++++- src/view.rs | 690 +++++++++++++++++++++++-------- 15 files changed, 1176 insertions(+), 210 deletions(-) create mode 100644 assets/ui-icons/pause.svg create mode 100644 assets/ui-icons/play.svg create mode 100644 assets/ui-icons/playlist.svg create mode 100644 assets/ui-icons/seek-back.svg create mode 100644 assets/ui-icons/seek-forward.svg create mode 100644 assets/ui-icons/skip-back.svg create mode 100644 assets/ui-icons/skip-forward.svg create mode 100644 assets/ui-icons/volume.svg create mode 100644 src/album_art.rs diff --git a/assets/ui-icons/pause.svg b/assets/ui-icons/pause.svg new file mode 100644 index 0000000..98d1441 --- /dev/null +++ b/assets/ui-icons/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/ui-icons/play.svg b/assets/ui-icons/play.svg new file mode 100644 index 0000000..f65b787 --- /dev/null +++ b/assets/ui-icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/ui-icons/playlist.svg b/assets/ui-icons/playlist.svg new file mode 100644 index 0000000..3acd279 --- /dev/null +++ b/assets/ui-icons/playlist.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/ui-icons/search.svg b/assets/ui-icons/search.svg index 09f7413..2ab8f9d 100644 --- a/assets/ui-icons/search.svg +++ b/assets/ui-icons/search.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/assets/ui-icons/seek-back.svg b/assets/ui-icons/seek-back.svg new file mode 100644 index 0000000..318f70b --- /dev/null +++ b/assets/ui-icons/seek-back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/ui-icons/seek-forward.svg b/assets/ui-icons/seek-forward.svg new file mode 100644 index 0000000..2787da5 --- /dev/null +++ b/assets/ui-icons/seek-forward.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/ui-icons/skip-back.svg b/assets/ui-icons/skip-back.svg new file mode 100644 index 0000000..6949138 --- /dev/null +++ b/assets/ui-icons/skip-back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/ui-icons/skip-forward.svg b/assets/ui-icons/skip-forward.svg new file mode 100644 index 0000000..cb22f0a --- /dev/null +++ b/assets/ui-icons/skip-forward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/ui-icons/volume.svg b/assets/ui-icons/volume.svg new file mode 100644 index 0000000..c60d585 --- /dev/null +++ b/assets/ui-icons/volume.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/album_art.rs b/src/album_art.rs new file mode 100644 index 0000000..6ba0968 --- /dev/null +++ b/src/album_art.rs @@ -0,0 +1,150 @@ +//! Background album-art fetcher for media_player entities. +//! Downloads `entity_picture` URLs from HA and saves them as local JPEG files. +//! The view layer renders from the local path. + +use std::{ + collections::HashMap, + fs, + io::Read, + sync::mpsc, + thread, + time::Duration, +}; + +pub struct AlbumArtService { + ready_rx: mpsc::Receiver, + fetch_tx: mpsc::Sender, + known_urls: HashMap, +} + +pub struct AlbumArtReady { + pub entity_id: String, + pub path: String, +} + +struct FetchRequest { + entity_id: String, + url: String, + token: String, + save_path: String, +} + +const ART_DIR: &str = "/tmp/luma-album-art"; +const MAX_BYTES: u64 = 2 * 1024 * 1024; + +impl AlbumArtService { + pub fn new() -> Self { + let (fetch_tx, fetch_rx) = mpsc::channel::(); + let (ready_tx, ready_rx) = mpsc::channel::(); + + fs::create_dir_all(ART_DIR).ok(); + + thread::spawn(move || { + let agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(10))) + .max_redirects(3) + .proxy(None) + .build() + .new_agent(); + + for request in fetch_rx { + let tx = ready_tx.clone(); + let agent = agent.clone(); + thread::spawn(move || { + match fetch_and_save(&agent, &request) { + Ok(()) => { + let _ = tx.send(AlbumArtReady { + entity_id: request.entity_id, + path: request.save_path, + }); + } + Err(e) => { + eprintln!("Album art fetch failed for {}: {e}", request.entity_id); + } + } + }); + } + }); + + Self { + ready_rx, + fetch_tx, + known_urls: HashMap::new(), + } + } + + /// Check if entity_picture changed; if so, start a background fetch. + pub fn update(&mut self, entity_id: &str, picture_url: &str, ha_base_url: &str, token: &str) { + if picture_url.is_empty() { + self.known_urls.remove(entity_id); + return; + } + if self.known_urls.get(entity_id).is_some_and(|u| u == picture_url) { + return; + } + self.known_urls.insert(entity_id.to_owned(), picture_url.to_owned()); + + let full_url = if picture_url.starts_with("http") { + picture_url.to_owned() + } else { + format!("{}{}", ha_base_url, picture_url) + }; + + let hash = fnv1a(picture_url); + let save_path = format!("{ART_DIR}/{hash}.jpg"); + + let _ = self.fetch_tx.send(FetchRequest { + entity_id: entity_id.to_owned(), + url: full_url, + token: token.to_owned(), + save_path, + }); + } + + pub fn poll(&self) -> Option { + self.ready_rx.try_recv().ok() + } +} + +fn fetch_and_save(agent: &ureq::Agent, req: &FetchRequest) -> Result<(), String> { + if std::path::Path::new(&req.save_path).exists() { + return Ok(()); + } + + let mut request = agent.get(&req.url); + if !req.token.is_empty() { + request = request.header("Authorization", &format!("Bearer {}", req.token)); + } + + let mut response = request.call().map_err(|e| format!("HTTP: {e}"))?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + + let mut body = Vec::new(); + response + .body_mut() + .as_reader() + .take(MAX_BYTES) + .read_to_end(&mut body) + .map_err(|e| format!("read: {e}"))?; + + if body.len() < 100 { + return Err(format!("too small: {} bytes", body.len())); + } + + let tmp = format!("{}.tmp", req.save_path); + fs::write(&tmp, &body).map_err(|e| format!("write: {e}"))?; + fs::rename(&tmp, &req.save_path).map_err(|e| format!("rename: {e}"))?; + + Ok(()) +} + +fn fnv1a(s: &str) -> String { + let mut h: u64 = 0xcbf29ce484222325; + for b in s.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + format!("{h:016x}") +} diff --git a/src/ha_client.rs b/src/ha_client.rs index bccbe03..57899cc 100644 --- a/src/ha_client.rs +++ b/src/ha_client.rs @@ -46,6 +46,34 @@ pub enum HaResponse { success: bool, }, Config(Value), + Forecast { + entity_id: String, + days: Vec, + }, + HourlyForecast { + entity_id: String, + hours: Vec, + }, +} + +#[derive(Clone, Debug)] +pub struct ForecastDay { + pub date: String, + pub condition: String, + pub temp_high: f64, + pub temp_low: f64, + pub precipitation: f64, + pub humidity: f64, + pub wind_speed: f64, +} + +#[derive(Clone, Debug)] +pub struct ForecastHour { + pub datetime: String, + pub condition: String, + pub temperature: f64, + pub humidity: f64, + pub wind_speed: f64, } pub enum HaCommand { @@ -62,6 +90,12 @@ pub enum HaCommand { }, RefreshStates, FetchConfig, + FetchForecast { + entity_id: String, + }, + FetchHourlyForecast { + entity_id: String, + }, } // ── Config ──────────────────────────────────────────────────────────── @@ -77,7 +111,7 @@ pub struct HaConfig { pub struct HaService { pub receiver: mpsc::Receiver, - commands: mpsc::Sender, + pub sender: mpsc::Sender, closed: Arc, worker: Option>, } @@ -95,14 +129,14 @@ impl HaService { Self { receiver: resp_rx, - commands: cmd_tx, + sender: cmd_tx, closed, worker: Some(worker), } } pub fn send(&self, command: HaCommand) { - let _ = self.commands.send(command); + let _ = self.sender.send(command); } } @@ -216,9 +250,11 @@ fn connect_and_run( &json!({ "id": config_id, "type": "luma/get_config" }), )?; - // Pending service call / history / refresh requests mapped by HA message id + // Pending service call / history / refresh / forecast requests mapped by HA message id let mut pending_service: HashMap = HashMap::new(); // ha_id -> caller_id let mut pending_history: HashMap = HashMap::new(); // ha_id -> entity_id + let mut pending_forecast: HashMap = HashMap::new(); // ha_id -> entity_id (daily) + let mut pending_hourly: HashMap = HashMap::new(); // ha_id -> entity_id (hourly) let mut pending_refresh: Option = None; let mut pending_config: Option = Some(config_id); @@ -267,6 +303,38 @@ fn connect_and_run( )?; pending_config = Some(ha_id); } + HaCommand::FetchForecast { entity_id } => { + let ha_id = next_id(); + pending_forecast.insert(ha_id, entity_id.clone()); + ws_send( + &mut ws, + &json!({ + "id": ha_id, + "type": "call_service", + "domain": "weather", + "service": "get_forecasts", + "target": { "entity_id": entity_id }, + "service_data": { "type": "daily" }, + "return_response": true, + }), + )?; + } + HaCommand::FetchHourlyForecast { entity_id } => { + let ha_id = next_id(); + pending_hourly.insert(ha_id, entity_id.clone()); + ws_send( + &mut ws, + &json!({ + "id": ha_id, + "type": "call_service", + "domain": "weather", + "service": "get_forecasts", + "target": { "entity_id": entity_id }, + "service_data": { "type": "hourly" }, + "return_response": true, + }), + )?; + } HaCommand::FetchHistory { entity_id, hours } => { let ha_id = next_id(); pending_history.insert(ha_id, entity_id.clone()); @@ -375,6 +443,24 @@ fn connect_and_run( }); } } + "result" if pending_forecast.contains_key(&id) => { + if let Some(entity_id) = pending_forecast.remove(&id) { + let days = parse_forecast_response(&value, &entity_id); + let _ = responses.send(HaResponse::Forecast { + entity_id, + days, + }); + } + } + "result" if pending_hourly.contains_key(&id) => { + if let Some(entity_id) = pending_hourly.remove(&id) { + let hours = parse_hourly_response(&value, &entity_id); + let _ = responses.send(HaResponse::HourlyForecast { + entity_id, + hours, + }); + } + } "result" if pending_config == Some(id) => { pending_config = None; if value.get("success").and_then(Value::as_bool) == Some(true) { @@ -460,6 +546,56 @@ fn parse_statistics(value: &Value, entity_id: &str) -> Vec { .collect() } +fn parse_hourly_response(value: &Value, entity_id: &str) -> Vec { + let response = match value.get("result").and_then(|r| r.get("response")) { + Some(r) => r, + None => return vec![], + }; + let forecasts = match response.get(entity_id).and_then(|e| e.get("forecast")).and_then(Value::as_array) { + Some(a) => a, + None => return vec![], + }; + forecasts + .iter() + .filter_map(|entry| { + Some(ForecastHour { + datetime: entry.get("datetime")?.as_str()?.to_owned(), + condition: entry.get("condition").and_then(Value::as_str).unwrap_or("").to_owned(), + temperature: entry.get("temperature").and_then(Value::as_f64).unwrap_or(0.), + humidity: entry.get("humidity").and_then(Value::as_f64).unwrap_or(0.), + wind_speed: entry.get("wind_speed").and_then(Value::as_f64).unwrap_or(0.), + }) + }) + .collect() +} + +fn parse_forecast_response(value: &Value, entity_id: &str) -> Vec { + let response = match value.get("result").and_then(|r| r.get("response")) { + Some(r) => r, + None => return vec![], + }; + let forecasts = match response.get(entity_id).and_then(|e| e.get("forecast")).and_then(Value::as_array) { + Some(a) => a, + None => return vec![], + }; + forecasts + .iter() + .filter_map(|entry| { + let dt = entry.get("datetime")?.as_str()?; + let date = if dt.len() >= 10 { &dt[..10] } else { dt }; + Some(ForecastDay { + date: date.to_owned(), + condition: entry.get("condition").and_then(Value::as_str).unwrap_or("").to_owned(), + temp_high: entry.get("temperature").and_then(Value::as_f64).unwrap_or(0.), + temp_low: entry.get("templow").and_then(Value::as_f64).unwrap_or(0.), + precipitation: entry.get("precipitation").and_then(Value::as_f64).unwrap_or(0.), + humidity: entry.get("humidity").and_then(Value::as_f64).unwrap_or(0.), + wind_speed: entry.get("wind_speed").and_then(Value::as_f64).unwrap_or(0.), + }) + }) + .collect() +} + /// Collect entity IDs from a v2 config (as returned by luma/get_config). pub fn entity_ids_from_v2_config(config: &Value) -> Vec { let mut ids = Vec::new(); @@ -470,6 +606,17 @@ pub fn entity_ids_from_v2_config(config: &Value) -> Vec { if let Some(id) = card.get("entity_id").and_then(Value::as_str) { if !id.is_empty() { ids.push(id.to_owned()); + // Vacuum cards need auxiliary entities for modes, errors, and actions + if card.get("type").and_then(Value::as_str) == Some("vacuum") { + if let Some(prefix) = id.strip_prefix("vacuum.") { + ids.push(format!("select.{prefix}_cleaning_mode")); + ids.push(format!("sensor.{prefix}_vacuum_error")); + ids.push(format!("button.{prefix}_mop_only")); + ids.push(format!("button.{prefix}_vac_mop_fast")); + ids.push(format!("button.{prefix}_after_meals")); + ids.push(format!("button.{prefix}_vac_mop")); + } + } } } } diff --git a/src/ha_pairing.rs b/src/ha_pairing.rs index 17862f0..0a979cb 100644 --- a/src/ha_pairing.rs +++ b/src/ha_pairing.rs @@ -34,8 +34,7 @@ pub struct PairingService { } impl PairingService { - pub fn start(ha_host: &str) -> Self { - let pin = generate_pin(); + pub fn start(ha_host: &str, pin: String) -> Self { let (tx, rx) = mpsc::channel(); let cancelled = Arc::new(AtomicBool::new(false)); let cancelled_flag = cancelled.clone(); @@ -130,7 +129,7 @@ fn poll_loop( } } -fn generate_pin() -> String { +pub fn generate_pin() -> String { let mut buf = [0u8; 4]; getrandom::getrandom(&mut buf).unwrap_or_else(|_| { use std::collections::hash_map::DefaultHasher; diff --git a/src/main.rs b/src/main.rs index 39512ed..eea0da2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod album_art; mod app_artwork; mod branding; mod diagnostics; @@ -173,6 +174,7 @@ const SCRIPT_POINTER: u32 = u32::MAX - 1; // SDL-webOS's Back scancode is outside rust-sdl2's standard Scancode enum. // Keycode retains the raw value: SDL_SCANCODE_TO_KEYCODE(SDL_SCANCODE_WEBOS_BACK). const WEBOS_BACK_KEYCODE: i32 = (1 << 30) | 482; +const WEBOS_BACK_ALT: i32 = 0x200003; fn effects( effects: Vec, @@ -235,13 +237,11 @@ fn effects( ha_dispatch(service, ha_effect); } } - Effect::HaPairStart(url) => { + Effect::HaPairStart { url, pin } => { if let Some(p) = ha_pairing.as_mut() { p.cancel(); } - let service = ha_pairing::PairingService::start(&url); - state.ha_pairing_pin = Some(service.pin.clone()); - state.dirty = true; + let service = ha_pairing::PairingService::start(&url, pin); *ha_pairing = Some(service); } Effect::HaDisconnect => { @@ -387,7 +387,7 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) { target: serde_json::json!({ "entity_id": entity_id }), data: serde_json::json!({ "command": "app_segment_clean", - "params": { "segments": segments, "repeat": 1 } + "params": [{ "segments": segments, "repeat": 1 }] }), }); } @@ -418,6 +418,15 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) { data: serde_json::json!({}), }); } + HaEffect::MediaSeek(entity_id, position) => { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "media_player".into(), + service: "media_seek".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({ "seek_position": position }), + }); + } HaEffect::SetVolume(entity_id, volume) => { service.send(ha_client::HaCommand::CallService { id: next_id(), @@ -445,6 +454,24 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) { data: serde_json::json!({ "color_temp": color_temp }), }); } + HaEffect::VacuumDock(entity_id) => { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "vacuum".into(), + service: "return_to_base".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({}), + }); + } + HaEffect::ButtonPress(entity_id) => { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "button".into(), + service: "press".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({}), + }); + } } } @@ -508,6 +535,15 @@ fn script( }) } +fn ws_to_http_base(ws_url: &str) -> String { + ws_url + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches("/api/websocket") + .trim_end_matches('/') + .to_owned() +} + fn main() { if let Err(error) = run() { eprintln!("Luma native: {error}"); @@ -603,10 +639,26 @@ fn ha_tick(state: &mut State, service: &ha_client::HaService) { } ha_client::HaResponse::Config(config) => { state.ha.config = state::HaTabConfig::parse(&config); - eprintln!( - "HA config loaded: {} rooms", - state.ha.config.rooms.len() - ); + for room in &state.ha.config.rooms { + for card in &room.cards { + if card.card_type == state::HaCardType::Weather { + let _ = service.sender.send(ha_client::HaCommand::FetchForecast { + entity_id: card.entity_id.clone(), + }); + let _ = service.sender.send(ha_client::HaCommand::FetchHourlyForecast { + entity_id: card.entity_id.clone(), + }); + } + } + } + state.dirty = true; + } + ha_client::HaResponse::Forecast { entity_id, days } => { + state.ha.forecasts.insert(entity_id, days); + state.dirty = true; + } + ha_client::HaResponse::HourlyForecast { entity_id, hours } => { + state.ha.hourly_forecasts.insert(entity_id, hours); state.dirty = true; } } @@ -745,12 +797,13 @@ fn run() -> Result<(), String> { weather_cache, ); let ha_token_path = ha_token::path(&preferences_path); - let mut ha_service = if let Some(creds) = ha_token::load(&ha_token_path) { + let ha_creds = ha_token::load(&ha_token_path); + let mut ha_service = if let Some(ref creds) = ha_creds { state.ha_paired = true; eprintln!("HA token loaded from {}", ha_token_path.display()); Some(ha_client::HaService::new(ha_client::HaConfig { - url: creds.ha_url, - token: creds.token, + url: creds.ha_url.clone(), + token: creds.token.clone(), entity_ids: vec![], })) } else { @@ -758,6 +811,9 @@ fn run() -> Result<(), String> { None }; let mut ha_pairing: Option = None; + let mut album_art_service = album_art::AlbumArtService::new(); + let ha_http_base = ha_creds.as_ref().map(|c| ws_to_http_base(&c.ha_url)).unwrap_or_default(); + let ha_token_str = ha_creds.as_ref().map(|c| c.token.clone()).unwrap_or_default(); let platform = Platform::new( webos, options.preferences.is_none() && options.test_extra_apps == 0, @@ -949,6 +1005,21 @@ fn run() -> Result<(), String> { last_ha_refresh = Instant::now(); } } + // Album art: check media entities for picture URL changes + for (eid, entity) in &state.ha.entities { + if eid.starts_with("media_player.") { + let pic = entity.attributes.get("entity_picture") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !pic.is_empty() { + album_art_service.update(eid, pic, &ha_http_base, &ha_token_str); + } + } + } + while let Some(ready) = album_art_service.poll() { + state.ha.album_art.insert(ready.entity_id, ready.path); + state.dirty = true; + } pairing_tick( &mut state, &mut ha_pairing, @@ -1029,7 +1100,7 @@ fn run() -> Result<(), String> { } if (state.ha.slider_open || state.ha.vacuum_open) && (matches!(key, Keycode::Escape | Keycode::AcBack | Keycode::Backspace) || i32::from(key) == 461 - || (webos && i32::from(key) == WEBOS_BACK_KEYCODE)) => + || (webos && (i32::from(key) == WEBOS_BACK_KEYCODE || i32::from(key) == WEBOS_BACK_ALT))) => { press = None; if state.ha.slider_open { @@ -1052,7 +1123,8 @@ fn run() -> Result<(), String> { keycode: Some(key), repeat, .. - } => match key { + } => { + match key { Keycode::Up => pending = state.direction(Direction::Up, &scene.controls), Keycode::Down => pending = state.direction(Direction::Down, &scene.controls), Keycode::Left => pending = state.direction(Direction::Left, &scene.controls), @@ -1091,13 +1163,13 @@ fn run() -> Result<(), String> { Keycode::F5 if !repeat => pending.push(Effect::Reload), key if !repeat && (i32::from(key) == 461 - || (webos && i32::from(key) == WEBOS_BACK_KEYCODE)) => + || (webos && (i32::from(key) == WEBOS_BACK_KEYCODE || i32::from(key) == WEBOS_BACK_ALT))) => { press = None; pending = state.back(); } _ => {} - }, + }}, Event::KeyUp { keycode: Some(Keycode::Return | Keycode::KpEnter), .. diff --git a/src/state.rs b/src/state.rs index a75e936..644fbfc 100644 --- a/src/state.rs +++ b/src/state.rs @@ -66,11 +66,13 @@ pub enum Target { HaRoom(usize), HaCard(usize, usize), HaWeather, - HaVacuumOpen, + HaVacuumAction(usize), + HaVacuumExit, HaVacuumRoom(usize), HaVacuumMode, HaVacuumStart, HaVacuumClose, + HaMediaAction(usize), HaSliderBrightness, HaSliderColorTemp, HaSliderClose, @@ -157,7 +159,7 @@ pub enum Effect { Quit, Weather(weather::Command), Ha(HaEffect), - HaPairStart(String), + HaPairStart { url: String, pin: String }, HaDisconnect, } @@ -172,9 +174,12 @@ pub enum HaEffect { segments: Vec, mode: Option, }, + VacuumDock(String), + ButtonPress(String), MediaPlayPause(String), MediaNext(String), MediaPrev(String), + MediaSeek(String, f64), SetVolume(String, f32), SetCoverPosition(String, u8), SetColorTemp(String, u32), @@ -186,7 +191,7 @@ pub const KEYS: [&str; 40] = [ "Delete", "Clear", "Done", ]; -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub enum HaCardType { #[default] Toggle, @@ -244,11 +249,16 @@ pub struct HaState { pub connected: bool, pub entities: std::collections::HashMap, pub history: std::collections::HashMap>, + pub forecasts: std::collections::HashMap>, + pub hourly_forecasts: std::collections::HashMap>, + pub album_art: std::collections::HashMap, pub config: HaTabConfig, pub room: usize, + pub vacuum_card_active: bool, pub vacuum_selected: Vec, pub vacuum_mode: usize, pub vacuum_open: bool, + pub media_card_active: bool, pub slider_open: bool, pub slider_entity: String, pub slider_card_type: HaCardType, @@ -295,7 +305,18 @@ impl HaTabConfig { if let Some(cme) = v.get("cleaning_mode_entity").and_then(|v| v.as_str()) { vc.cleaning_mode_entity = cme.to_owned(); } - if let Some(rooms) = v.get("rooms").and_then(|v| v.as_object()) { + if let Some(rooms) = v.get("rooms").and_then(|v| v.as_array()) { + for room in rooms { + let seg = room.get("segment_id").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let n = room.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if seg > 0 && !n.is_empty() { + vc.rooms.push(HaVacuumRoom { + segment_id: seg, + name: n.to_owned(), + }); + } + } + } else if let Some(rooms) = v.get("rooms").and_then(|v| v.as_object()) { for (id, name) in rooms { if let (Ok(seg), Some(n)) = (id.parse::(), name.as_str()) { vc.rooms.push(HaVacuumRoom { @@ -304,8 +325,8 @@ impl HaTabConfig { }); } } - vc.rooms.sort_by_key(|r| r.segment_id); } + vc.rooms.sort_by_key(|r| r.segment_id); if !vc.entity_id.is_empty() { config.vacuum = Some(vc); } @@ -343,6 +364,33 @@ impl HaTabConfig { } } + // Auto-populate vacuum config from the vacuum card if luma/get_config + // doesn't include a top-level "vacuum" section (it only returns rooms). + if config.vacuum.is_none() { + if let Some(vac_entity) = config.rooms.iter() + .flat_map(|r| &r.cards) + .find(|c| c.card_type == HaCardType::Vacuum) + .map(|c| c.entity_id.clone()) + { + let prefix = vac_entity.strip_prefix("vacuum.").unwrap_or(&vac_entity).to_owned(); + config.vacuum = Some(HaVacuumConfig { + entity_id: vac_entity, + cleaning_mode_entity: format!("select.{prefix}_cleaning_mode"), + rooms: vec![ + HaVacuumRoom { segment_id: 16, name: "Kitchen".to_owned() }, + HaVacuumRoom { segment_id: 17, name: "Bedroom".to_owned() }, + HaVacuumRoom { segment_id: 18, name: "Dining room".to_owned() }, + HaVacuumRoom { segment_id: 19, name: "Corridor".to_owned() }, + HaVacuumRoom { segment_id: 20, name: "Bathroom".to_owned() }, + HaVacuumRoom { segment_id: 21, name: "Living room".to_owned() }, + HaVacuumRoom { segment_id: 22, name: "Master bedroom".to_owned() }, + HaVacuumRoom { segment_id: 23, name: "Corridor 2".to_owned() }, + HaVacuumRoom { segment_id: 24, name: "Bedroom 2".to_owned() }, + ], + }); + } + } + config } } @@ -411,7 +459,7 @@ impl State { keyboard_field: TextField::Apps, greeting_name_draft: String::new(), input_name: String::new(), - ha_url_draft: String::new(), + ha_url_draft: "https://ha.xtrm-lab.org".into(), ha_paired: false, ha_pairing_pin: None, } @@ -848,6 +896,12 @@ impl State { } // ── HA page navigation ──────────────────────────────── if self.page == Page::Ha && !self.ha.config.rooms.is_empty() { + if self.ha.media_card_active { + return self.ha_media_card_direction(direction, controls); + } + if self.ha.vacuum_card_active { + return self.ha_vacuum_card_direction(direction, controls); + } if self.ha.vacuum_open { return self.ha_vacuum_direction(direction, controls); } @@ -1326,6 +1380,16 @@ impl State { } pub fn scroll_ha(&mut self, delta: i32) -> Vec { + if self.ha.media_card_active { + let card = self.ha.config.rooms.get(self.ha.room) + .and_then(|r| r.cards.iter().find(|c| c.card_type == HaCardType::Media)); + if let Some(card) = card { + let vol = self.ha_entity_volume(&card.entity_id); + let new = (vol + delta as f32 * 0.05).clamp(0., 1.); + return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))]; + } + return vec![]; + } // Slider modal — update local value instantly, send HA effect in background if self.ha.slider_open { self.dirty = true; @@ -1394,6 +1458,67 @@ impl State { vec![] } + fn ha_media_card_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec { + // 7 buttons: playlist(0), prev-song(1), seek-back(2), play/pause(3), seek-fwd(4), next-song(5), search(6) + let total = 7usize; + if let Target::HaMediaAction(index) = &self.focus { + let index = *index; + match direction { + Direction::Left if index > 0 => { + self.focus(Target::HaMediaAction(index - 1), controls); + } + Direction::Right if index + 1 < total => { + self.focus(Target::HaMediaAction(index + 1), controls); + } + Direction::Up | Direction::Down => { + let delta = if matches!(direction, Direction::Up) { 1 } else { -1 }; + let card = self.ha.config.rooms.get(self.ha.room) + .and_then(|r| r.cards.iter().find(|c| c.card_type == HaCardType::Media)); + if let Some(card) = card { + let vol = self.ha_entity_volume(&card.entity_id); + let new = (vol + delta as f32 * 0.05).clamp(0., 1.); + return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))]; + } + } + _ => {} + } + } + vec![] + } + + fn ha_vacuum_card_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec { + let cols = 3usize; + let total = 6usize; // 5 actions + 1 custom + match &self.focus { + Target::HaVacuumAction(index) => { + let index = *index; + match direction { + Direction::Left if index % cols > 0 => { + self.focus(Target::HaVacuumAction(index - 1), controls); + } + Direction::Right if index % cols + 1 < cols && index + 1 < total => { + self.focus(Target::HaVacuumAction(index + 1), controls); + } + Direction::Up if index >= cols => { + self.focus(Target::HaVacuumAction(index - cols), controls); + } + Direction::Down => { + let next = index + cols; + if next < total { + self.focus(Target::HaVacuumAction(next), controls); + } + } + _ => {} + } + } + Target::HaVacuumExit => { + self.focus(Target::HaVacuumAction(0), controls); + } + _ => {} + } + vec![] + } + fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec { let vac = match &self.ha.config.vacuum { Some(v) => v, @@ -1870,15 +1995,12 @@ impl State { return vec![Effect::Ha(HaEffect::ActivateScene(card.entity_id.clone()))]; } HaCardType::Vacuum => { - if let Some(vac) = &self.ha.config.vacuum { - self.ha.vacuum_open = true; - self.ha.vacuum_selected = vec![false; vac.rooms.len()]; - self.ha.vacuum_mode = 0; - self.focus = Target::HaVacuumRoom(0); - } + self.ha.vacuum_card_active = true; + self.focus = Target::HaVacuumAction(0); } HaCardType::Media => { - return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))]; + self.ha.media_card_active = true; + self.focus = Target::HaMediaAction(3); // play/pause in center } HaCardType::Weather => {} HaCardType::Range | HaCardType::Climate | HaCardType::Curtain | HaCardType::Blinds => { @@ -1951,7 +2073,84 @@ impl State { .position(|c| matches!(c.card_type, HaCardType::Vacuum)) .unwrap_or(0)); } - Target::HaVacuumOpen | Target::HaWeather => {} + Target::HaMediaAction(index) => { + let media_card = self.ha.config.rooms.get(self.ha.room) + .and_then(|r| r.cards.iter().find(|c| c.card_type == HaCardType::Media)); + if let Some(card) = media_card { + let entity_id = card.entity_id.clone(); + match index { + 1 => return vec![Effect::Ha(HaEffect::MediaPrev(entity_id))], + 2 => { + // Seek back 10 seconds + let pos = self.ha.entities.get(&entity_id) + .and_then(|e| e.attributes.get("media_position")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.); + return vec![Effect::Ha(HaEffect::MediaSeek(entity_id, (pos - 10.).max(0.)))]; + } + 3 => return vec![Effect::Ha(HaEffect::MediaPlayPause(entity_id))], + 4 => { + // Seek forward 10 seconds + let pos = self.ha.entities.get(&entity_id) + .and_then(|e| e.attributes.get("media_position")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.); + let dur = self.ha.entities.get(&entity_id) + .and_then(|e| e.attributes.get("media_duration")) + .and_then(|v| v.as_f64()) + .unwrap_or(f64::MAX); + return vec![Effect::Ha(HaEffect::MediaSeek(entity_id, (pos + 10.).min(dur)))]; + } + 5 => return vec![Effect::Ha(HaEffect::MediaNext(entity_id))], + 0 | 6 => {} // playlist / search — not yet implemented + _ => {} + } + } + } + Target::HaVacuumAction(index) => { + let vac_entity = self.ha.config.rooms.get(self.ha.room) + .and_then(|r| r.cards.iter().find(|c| matches!(c.card_type, HaCardType::Vacuum))) + .map(|c| c.entity_id.clone()); + let vac_idx = self.ha.config.rooms.get(self.ha.room) + .and_then(|r| r.cards.iter().position(|c| matches!(c.card_type, HaCardType::Vacuum))) + .unwrap_or(0); + + if let Some(entity_id) = vac_entity { + let prefix = entity_id.strip_prefix("vacuum.").unwrap_or(&entity_id); + if index == 5 { + if let Some(vac) = &self.ha.config.vacuum { + self.ha.vacuum_open = true; + self.ha.vacuum_selected = vec![false; vac.rooms.len()]; + self.ha.vacuum_mode = 0; + self.ha.vacuum_card_active = false; + self.focus = Target::HaVacuumRoom(0); + } + } else if index == 0 { + self.ha.vacuum_card_active = false; + self.focus = Target::HaCard(self.ha.room, vac_idx); + return vec![Effect::Ha(HaEffect::VacuumDock(entity_id))]; + } else { + let button_id = match index { + 1 => format!("button.{}_mop_only", prefix), + 2 => format!("button.{}_vac_mop_fast", prefix), + 3 => format!("button.{}_after_meals", prefix), + 4 => format!("button.{}_vac_mop", prefix), + _ => return vec![], + }; + self.ha.vacuum_card_active = false; + self.focus = Target::HaCard(self.ha.room, vac_idx); + return vec![Effect::Ha(HaEffect::ButtonPress(button_id))]; + } + } + } + Target::HaVacuumExit => { + self.ha.vacuum_card_active = false; + let vac_idx = self.ha.config.rooms[self.ha.room].cards.iter() + .position(|c| matches!(c.card_type, HaCardType::Vacuum)) + .unwrap_or(0); + self.focus = Target::HaCard(self.ha.room, vac_idx); + } + Target::HaWeather => {} Target::HaPairUrl => { self.open_keyboard(TextField::HaUrl); } @@ -1960,11 +2159,13 @@ impl State { if url.is_empty() { return vec![]; } + let pin = crate::ha_pairing::generate_pin(); + self.ha_pairing_pin = Some(pin.clone()); self.subpage = Some(Subpage::HaPairing); if !self.preferences.reduced_motion { self.panel_started = Some(Instant::now()); } - return vec![Effect::HaPairStart(url)]; + return vec![Effect::HaPairStart { url, pin }]; } Target::HaPairDisconnect => { self.ha_paired = false; @@ -2212,6 +2413,22 @@ impl State { self.scroll = 0.; return vec![]; } + if self.page == Page::Ha && self.ha.media_card_active { + self.ha.media_card_active = false; + let media_idx = self.ha.config.rooms[self.ha.room].cards.iter() + .position(|c| c.card_type == HaCardType::Media) + .unwrap_or(0); + self.focus = Target::HaCard(self.ha.room, media_idx); + return vec![]; + } + if self.page == Page::Ha && self.ha.vacuum_card_active { + self.ha.vacuum_card_active = false; + let vac_idx = self.ha.config.rooms[self.ha.room].cards.iter() + .position(|c| matches!(c.card_type, HaCardType::Vacuum)) + .unwrap_or(0); + self.focus = Target::HaCard(self.ha.room, vac_idx); + return vec![]; + } if self.page == Page::Ha && self.ha.vacuum_open { self.ha.vacuum_open = false; self.focus = Target::HaCard(self.ha.room, diff --git a/src/view.rs b/src/view.rs index 584f847..184fe7e 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1675,7 +1675,7 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { let mut occupied = vec![false; cols * max_rows]; for (index, card) in room.cards.iter().enumerate() { - let tall = matches!(card.card_type, HaCardType::Media | HaCardType::Weather); + let tall = matches!(card.card_type, HaCardType::Media | HaCardType::Weather | HaCardType::Vacuum); let row_span = if tall { 2 } else { 1 }; // Find next available cell that fits @@ -1717,7 +1717,7 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { ha_climate_card(out, state, assets, rect, card, entity, focused); } HaCardType::Action => { - ha_action_card(out, state, assets, rect, card, focused); + ha_action_card(out, state, assets, rect, card, entity, focused); } HaCardType::Vacuum => { ha_vacuum_card(out, state, assets, rect, card, entity, focused); @@ -1733,7 +1733,7 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { } HaCardType::Weather => { if let Some(e) = entity { - ha_weather_card(out, state, assets, rect, e, focused); + ha_weather_card(out, state, assets, rect, card, e, focused); } } } @@ -1753,6 +1753,18 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { } } +fn ha_card_name<'a>(card: &'a crate::state::HaCardConfig, entity: Option<&'a crate::state::HaEntity>) -> &'a str { + if !card.name.is_empty() { + return &card.name; + } + if let Some(e) = entity { + if let Some(name) = e.attributes.get("friendly_name").and_then(|v| v.as_str()) { + return name; + } + } + &card.entity_id +} + // ── Glass card base ────────────────────────────────────────────── fn ha_glass(out: &mut Scene, rect: Rect, focused: bool) { @@ -1943,7 +1955,7 @@ fn ha_toggle_card( ); // Entity name out.text( - &card.name, + ha_card_name(card, entity), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), text(18., 600, -0.02), WHITE, @@ -2021,7 +2033,7 @@ fn ha_range_card( ); // Entity name out.text( - &card.name, + ha_card_name(card, entity), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), text(18., 600, -0.02), WHITE, @@ -2109,7 +2121,7 @@ fn ha_climate_card( ); // Entity name out.text( - &card.name, + ha_card_name(card, entity), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), text(18., 600, -0.02), WHITE, @@ -2239,6 +2251,7 @@ fn ha_action_card( assets: &Assets, rect: Rect, card: &crate::state::HaCardConfig, + entity: Option<&crate::state::HaEntity>, focused: bool, ) { ha_glass(out, rect, focused); @@ -2265,7 +2278,7 @@ fn ha_action_card( ); // Entity name out.text( - &card.name, + ha_card_name(card, entity), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), text(18., 600, -0.02), WHITE, @@ -2284,144 +2297,249 @@ fn ha_action_card( } } -// ── Vacuum card (summary; opens modal) ─────────────────────────── +// ── Vacuum card (tall, with action buttons) ────────────────────── + +const VAC_ACTIONS: [&str; 6] = ["Dock", "Mop Only", "V&M Fast", "After Meals", "Vac & Mop", "Custom"]; fn ha_vacuum_card( out: &mut Scene, - _state: &State, + state: &State, assets: &Assets, rect: Rect, card: &crate::state::HaCardConfig, entity: Option<&crate::state::HaEntity>, focused: bool, ) { - ha_glass(out, rect, focused); - let status = entity.map_or("unavailable", |e| e.state.as_str()); - let accent = match status { - "cleaning" => c(0x7EB8DA, 1.), - "returning" => c(0xf0a060, 1.), - _ => c(0xbbbbbb, 0.5), + let prefix = card.entity_id.strip_prefix("vacuum.").unwrap_or(&card.entity_id); + let status_entity_id = format!("sensor.{}_status", prefix); + let status_text = state.ha.entities.get(&status_entity_id) + .map(|e| e.state.as_str()) + .unwrap_or_else(|| entity.map_or("unavailable", |e| e.state.as_str())); + let is_cleaning = matches!(status_text, + "cleaning" | "segment_cleaning" | "zoned_cleaning" | "spot_cleaning" + | "segment_mopping" | "robot_status_mopping"); + let is_returning = matches!(status_text, "returning_home" | "docking"); + + let error_entity_id = format!("sensor.{}_vacuum_error", prefix); + let has_error = state.ha.entities.get(&error_entity_id) + .is_some_and(|e| e.state != "none" && !e.state.is_empty()); + + let battery_entity_id = format!("sensor.{}_battery", prefix); + let battery = state.ha.entities.get(&battery_entity_id) + .and_then(|e| e.state.parse::().ok()) + .unwrap_or(0); + + let accent = if has_error { + c(0xe05050, 1.) + } else if is_cleaning { + c(0x7EB8DA, 1.) + } else if is_returning { + c(0xf0a060, 1.) + } else { + c(0x9daab8, 0.7) }; - // Fill for active states - let fill_color = ha_fill_color(&card.card_type, entity); - if status == "cleaning" || status == "returning" { - ha_card_fill(out, rect, if status == "cleaning" { 0.6 } else { 0.3 }, fill_color); + // ── Glass background ── + ha_glass(out, rect, focused); + + let pad = 16.; + + // ── Vacuum product image (top-right, proportional) ── + let img_s = 160.; + let img_x = rect.x + rect.w - pad - img_s; + let img_y = rect.y + pad + 4.; + + // Glow behind image: green when cleaning, red on error + if is_cleaning { + let glow_cx = img_x + img_s / 2.; + let glow_cy = img_y + img_s / 2.; + card_glow(out, rect, glow_cx, glow_cy, c(0x50e070, 0.45)); + } else if has_error { + let glow_cx = img_x + img_s / 2.; + let glow_cy = img_y + img_s / 2.; + card_glow(out, rect, glow_cx, glow_cy, c(0xe05050, 0.45)); } - // Icon in tinted circle - let icon_bg = if status == "cleaning" || status == "returning" { - [accent[0], accent[1], accent[2], 0.2] - } else { - c(0xffffff, 0.08) - }; - out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg); - out.icon( - assets, - "vacuum", - Rect::new(rect.x + 24., rect.y + 24., 32., 32.), - accent, + out.image( + "/tmp/luma-vacuum.png".to_owned(), + Rect::new(img_x, img_y, img_s, img_s), + 0., + true, ); - // Type label + // ── Label + status (left of image) ── + let text_w = rect.w - img_s - pad * 3.; out.text( - "Vacuum", - Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.), - text(12., 400, 0.04), - if status == "cleaning" { accent } else { c(0xe8e8e8, 0.45) }, - false, - ); - // Entity name - out.text( - &card.name, - Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), + "Roborock S7", + Rect::new(rect.x + pad, rect.y + pad, text_w, 22.), text(18., 600, -0.02), WHITE, false, ); - // Status at bottom - let display_status = match status { - "cleaning" => "Cleaning", - "returning" => "Returning", + let display_status = match status_text { + "charging" | "charging_complete" => "Charged", + "cleaning" | "segment_cleaning" | "zoned_cleaning" => "Cleaning", + "returning_home" | "docking" => "Returning", + "washing_the_mop" | "going_to_wash_the_mop" => "Washing mop", + "emptying_the_bin" => "Emptying bin", "docked" => "Docked", "idle" => "Idle", "paused" => "Paused", + "error" => "Error", other => other, }; + let status_line = if has_error { + let err = state.ha.entities.get(&error_entity_id) + .map(|e| e.state.replace('_', " ")) + .unwrap_or_default(); + format!("⚠ {} · {}%", err, battery) + } else { + format!("{} · {}%", display_status, battery) + }; out.text( - display_status, - Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.), - text(22., 700, -0.02), - if status == "cleaning" || status == "returning" { WHITE } else { c(0xbbbbbb, 0.5) }, + &status_line, + Rect::new(rect.x + pad, rect.y + pad + 26., text_w, 16.), + text(13., 400, 0.02), + if has_error { c(0xe05050, 1.) } else if is_cleaning { accent } else { c(0xe8e8e8, 0.50) }, false, ); + + // ── Action buttons grid (3×2) anchored to bottom ── + let btn_gap = 8.; + let cols = 3usize; + let rows = 2usize; + let btn_h = 48_f32; + let grid_h = btn_h * rows as f32 + btn_gap; + let grid_top = rect.y + rect.h - pad - grid_h; + let grid_x = rect.x + pad; + let btn_w = (rect.w - pad * 2. - btn_gap * (cols as f32 - 1.)) / cols as f32; + let card_active = state.ha.vacuum_card_active; + + for i in 0..6 { + let col = i % cols; + let row = i / cols; + let bx = grid_x + col as f32 * (btn_w + btn_gap); + let by = grid_top + row as f32 * (btn_h + btn_gap); + let btn_rect = Rect::new(bx, by, btn_w, btn_h); + let btn_focused = card_active && state.focus == Target::HaVacuumAction(i); + + let is_custom = i == 5; + let bg = if btn_focused { + c(0x7EB8DA, 0.25) + } else if is_custom { + c(0xffffff, 0.04) + } else { + c(0xffffff, 0.07) + }; + + out.shape(btn_rect, 12., bg); + out.border(btn_rect, 12., 1., if btn_focused { c(0x7EB8DA, 0.6) } else { c(0xffffff, 0.08) }); + if btn_focused { + out.border(btn_rect.inset(-3.), 15., 2., WHITE); + } + + let label_color = if btn_focused { WHITE } else if is_custom { c(0xe8e8e8, 0.55) } else { c(0xe8e8e8, 0.80) }; + out.text( + VAC_ACTIONS[i], + Rect::new(bx + 8., by + (btn_h - 14.) / 2., btn_w - 16., 18.), + text(13., 500, 0.), + label_color, + true, + ); + + out.control(Target::HaVacuumAction(i), btn_rect, false); + } } // ── Media card (Music Assistant / media_player) ──────────────── fn ha_media_card( out: &mut Scene, - _state: &State, + state: &State, assets: &Assets, rect: Rect, card: &crate::state::HaCardConfig, entity: Option<&crate::state::HaEntity>, focused: bool, ) { - let accent = ha_state_color(entity); - let is_active = entity.is_some_and(|e| e.state == "playing" || e.state == "paused"); + let is_playing = entity.is_some_and(|e| e.state == "playing"); + let is_paused = entity.is_some_and(|e| e.state == "paused"); + let has_media = entity.is_some_and(|e| { + e.attributes.get("media_title").and_then(|v| v.as_str()).is_some_and(|s| !s.is_empty()) + }); + let is_active = is_playing || is_paused || has_media; + let in_card = state.ha.media_card_active; - // Volume-driven radial glow from album art center let volume = entity .and_then(|e| e.attributes.get("volume_level")) .and_then(|v| v.as_f64()) .unwrap_or(0.) as f32; - // Portrait layout: album art circle centered at top - let art_size = 120.; - let art_x = rect.x + (rect.w - art_size) / 2.; - let art_y = rect.y + 24.; + let pad = 16.; + // ── Glass background ── ha_glass(out, rect, focused); - // Glow inside card, clipped to card bounds - if is_active && volume > 0. { - let glow_alpha = (volume * 0.3).clamp(0.05, 0.30); - card_glow(out, rect, art_x + art_size / 2., art_y + art_size / 2., [accent[0], accent[1], accent[2], glow_alpha]); + // ── Volume gradient fill ── + if volume > 0.01 { + let fill_h = rect.h * volume; + let fill_rect = Rect::new(rect.x, rect.y + rect.h - fill_h, rect.w, fill_h); + out.clip_rounded(rect, 22.); + out.draw.push(Draw::RoundedGradient( + fill_rect, + 0., + c(0x7050b0, 0.0), + c(0x7050b0, (volume * 0.35).clamp(0.04, 0.30)), + )); + out.unclip_rounded(); } - out.shape( - Rect::new(art_x, art_y, art_size, art_size), - art_size / 2., - if is_active { [accent[0], accent[1], accent[2], 0.25] } else { c(0xffffff, 0.08) }, - ); - out.icon( - assets, - "spotify", - Rect::new(art_x + 30., art_y + 30., 60., 60.), - if is_active { accent } else { c(0xbbbbbb, 0.4) }, - ); + // ── Album art (rectangular, left side, with shadow) ── + let art_s = 180.; + let art_r = 12.; + let art_x = rect.x + pad; + let art_y = rect.y + pad; + let art_rect = Rect::new(art_x, art_y, art_s, art_s); - // Text area below album art - let text_y = art_y + art_size + 16.; - let text_x = rect.x + 16.; - let text_w = rect.w - 32.; + out.shadow(art_rect, art_r, 24., 6., 0.4); - // Song title (centered) + let has_art = if let Some(path) = state.ha.album_art.get(&card.entity_id) { + out.image(path.clone(), art_rect, art_r, true); + true + } else { + false + }; + + if !has_art { + out.shape(art_rect, art_r, c(0x242830, 0.9)); + let icon_s = 56.; + out.icon( + assets, + "spotify", + Rect::new(art_x + (art_s - icon_s) / 2., art_y + (art_s - icon_s) / 2., icon_s, icon_s), + if is_active { c(0xb48efa, 0.7) } else { c(0xbbbbbb, 0.3) }, + ); + } + + // ── Track info (right of album art) ── + let info_x = art_x + art_s + pad; + let info_w = rect.x + rect.w - info_x - pad; + + // Song title let title = entity .and_then(|e| e.attributes.get("media_title")) .and_then(|v| v.as_str()) - .unwrap_or(&card.name); + .unwrap_or_else(|| ha_card_name(card, entity)); out.text( title, - Rect::new(text_x, text_y, text_w, 24.), + Rect::new(info_x, art_y + 8., info_w, 22.), text(17., 600, -0.02), WHITE, - true, + false, ); - // Artist (centered) + // Artist let artist = entity .and_then(|e| e.attributes.get("media_artist")) .and_then(|v| v.as_str()) @@ -2429,49 +2547,140 @@ fn ha_media_card( if !artist.is_empty() { out.text( artist, - Rect::new(text_x, text_y + 28., text_w, 18.), - text(13., 400, 0.), - c(0xe8e8e8, 0.6), - true, + Rect::new(info_x, art_y + 34., info_w, 18.), + text(14., 400, 0.01), + c(0xe8e8e8, 0.55), + false, ); } - // State label - let state_str = entity.map_or("off", |e| e.state.as_str()); - let status_label = match state_str { - "playing" => "Playing", - "paused" => "Paused", - "idle" => "Idle", - _ => "Off", - }; - out.text( - status_label, - Rect::new(text_x, text_y + 52., text_w, 14.), - text(11., 400, 0.04), - if is_active { accent } else { c(0xe8e8e8, 0.45) }, - true, - ); - - // Volume at bottom + // Volume icon + % let vol_pct = (volume * 100.).round() as u8; - let vol_label = if is_active { format!("Vol {}%", vol_pct) } else { "Off".into() }; + let vol_icon_s = 14.; + let vol_y = art_y + 58.; + out.icon(assets, "volume", Rect::new(info_x, vol_y, vol_icon_s, vol_icon_s), c(0xffffff, 0.40)); out.text( - &vol_label, - Rect::new(text_x, rect.y + rect.h - 36., text_w, 16.), + &format!("{}%", vol_pct), + Rect::new(info_x + vol_icon_s + 4., vol_y, info_w - vol_icon_s - 4., 16.), text(13., 500, 0.), - if is_active { WHITE } else { c(0xbbbbbb, 0.5) }, - true, + c(0xe8e8e8, 0.40), + false, ); - if focused && is_active { + // ── Progress bar (thin line above controls, Apple Music style) ── + let duration = entity + .and_then(|e| e.attributes.get("media_duration")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.); + let position_base = entity + .and_then(|e| e.attributes.get("media_position")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.); + + let bar_pad = pad; + let bar_w = rect.w - bar_pad * 2.; + let bar_h = 3.; + let bar_y = rect.y + rect.h - 82.; + let bar_x = rect.x + bar_pad; + + if duration > 0. { + let progress = (position_base / duration).clamp(0., 1.) as f32; + + // Track bar (dim) + out.shape(Rect::new(bar_x, bar_y, bar_w, bar_h), 1.5, c(0xffffff, 0.15)); + // Played portion + let played_w = (bar_w * progress).max(bar_h); + out.shape(Rect::new(bar_x, bar_y, played_w, bar_h), 1.5, c(0xffffff, 0.70)); + + // Time labels + let pos_secs = position_base as u32; + let dur_secs = duration as u32; + let fmt = |s: u32| -> String { + let m = s / 60; + let sec = s % 60; + format!("{m}:{sec:02}") + }; out.text( - "OK ▶/❚❚ scroll vol", - Rect::new(text_x, rect.y + rect.h - 18., text_w, 12.), - text(10., 400, 0.02), - c(0xeeeeee, 0.30), + &fmt(pos_secs), + Rect::new(bar_x, bar_y + 6., 60., 14.), + text(11., 400, 0.02), + c(0xffffff, 0.45), + false, + ); + out.text( + &format!("-{}", fmt(dur_secs.saturating_sub(pos_secs))), + Rect::new(bar_x + bar_w - 60., bar_y + 6., 60., 14.), + text(11., 400, 0.02), + c(0xffffff, 0.45), true, ); } + + // ── Transport controls (bottom row, 7 buttons) ── + // [playlist] ... [prev-song] [seek-back] [play/pause] [seek-fwd] [next-song] ... [search] + let ctrl_y = rect.y + rect.h - 48.; + let btn_s = 22.; + let play_s = 30.; + let inner_gap = 36.; + let outer_gap = 54.; + let ctrl_center = rect.x + rect.w / 2.; + + let icon_color = |idx: usize| -> Color { + if in_card && state.focus == Target::HaMediaAction(idx) { + WHITE + } else { + c(0xffffff, 0.45) + } + }; + + // Center cluster positions + let seek_back_cx = ctrl_center - inner_gap; + let seek_fwd_cx = ctrl_center + inner_gap; + let prev_cx = ctrl_center - outer_gap; + let next_cx = ctrl_center + outer_gap; + + // Playlist and search pinned to card edges + let playlist_cx = rect.x + pad + btn_s / 2.; + let search_cx = rect.x + rect.w - pad - btn_s / 2.; + + let by = ctrl_y + (play_s - btn_s) / 2.; + + // Helper to draw a button + let mut draw_btn = |out: &mut Scene, idx: usize, cx: f32, cy: f32, size: f32, icon: &str| { + let bx = cx - size / 2.; + let by = cy; + out.icon(assets, icon, Rect::new(bx, by, size, size), icon_color(idx)); + if in_card { + out.control(Target::HaMediaAction(idx), Rect::new(bx - 8., by - 8., size + 16., size + 16.), false); + } + }; + + // 0: Playlist (left edge) + draw_btn(out, 0, playlist_cx, by, btn_s, "playlist"); + + // 1: Previous song + draw_btn(out, 1, prev_cx, by, btn_s, "skip-back"); + + // 2: Seek back + draw_btn(out, 2, seek_back_cx, by, btn_s, "seek-back"); + + // 3: Play/Pause (larger, centered) + let bx = ctrl_center - play_s / 2.; + let by_play = ctrl_y; + let play_icon = if is_playing { "pause" } else { "play" }; + out.icon(assets, play_icon, Rect::new(bx, by_play, play_s, play_s), icon_color(3)); + if in_card { + out.control(Target::HaMediaAction(3), Rect::new(bx - 6., by_play - 6., play_s + 12., play_s + 12.), false); + } + + // 4: Seek forward + draw_btn(out, 4, seek_fwd_cx, by, btn_s, "seek-forward"); + + // 5: Next song + draw_btn(out, 5, next_cx, by, btn_s, "skip-forward"); + + // 6: Search (right edge) + draw_btn(out, 6, search_cx, by, btn_s, "search"); } // ── Cover card (curtain / blinds) ──────────────────────────────── @@ -2543,7 +2752,7 @@ fn ha_cover_card( ); // Entity name out.text( - &card.name, + ha_card_name(card, entity), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), text(18., 600, -0.02), WHITE, @@ -2574,11 +2783,89 @@ fn ha_cover_card( // ── Weather hero card ──────────────────────────────────────────── +fn weather_condition_icon(condition: &str) -> &'static str { + match condition { + "sunny" => "weather-sun", + "clear-night" => "weather-moon", + "cloudy" | "fog" => "weather-cloud", + "partlycloudy" => "weather-partly-cloudy", + "rainy" | "pouring" => "weather-rain", + "snowy" => "weather-snow", + "lightning" | "lightning-rainy" => "weather-storm", + "hail" | "exceptional" => "weather-drizzle", + _ => "weather-sun", + } +} + +fn weather_condition_label(condition: &str) -> &'static str { + match condition { + "sunny" => "Sunny", + "clear-night" => "Clear", + "cloudy" => "Cloudy", + "partlycloudy" => "Partly Cloudy", + "fog" => "Fog", + "rainy" => "Rain", + "pouring" => "Heavy Rain", + "snowy" => "Snow", + "lightning" => "Thunderstorm", + "lightning-rainy" => "Thunderstorm", + "hail" => "Hail", + "windy" | "windy-variant" => "Windy", + "exceptional" => "Exceptional", + _ => "Unknown", + } +} + +fn weather_day_abbr(date: &str) -> &str { + // date is YYYY-MM-DD; parse weekday + if date.len() < 10 { return "?"; } + let parts: Vec<&str> = date.split('-').collect(); + if parts.len() < 3 { return "?"; } + let (y, m, d) = match (parts[0].parse::(), parts[1].parse::(), parts[2].parse::()) { + (Ok(y), Ok(m), Ok(d)) => (y, m, d), + _ => return "?", + }; + if let Ok(date) = time::Date::from_calendar_date(y, time::Month::try_from(m).unwrap_or(time::Month::January), d) { + match date.weekday() { + time::Weekday::Monday => "Mon", + time::Weekday::Tuesday => "Tue", + time::Weekday::Wednesday => "Wed", + time::Weekday::Thursday => "Thu", + time::Weekday::Friday => "Fri", + time::Weekday::Saturday => "Sat", + time::Weekday::Sunday => "Sun", + } + } else { + "?" + } +} + +fn closest_hourly_temp( + hourly: &std::collections::HashMap>, + entity_id: &str, +) -> Option { + let hours = hourly.get(entity_id)?; + if hours.is_empty() { return None; } + let now = time::OffsetDateTime::now_utc(); + let now_ts = now.unix_timestamp(); + let mut best: Option<(i64, f64)> = None; + for h in hours { + if let Ok(dt) = time::OffsetDateTime::parse(&h.datetime, &time::format_description::well_known::Rfc3339) { + let diff = (dt.unix_timestamp() - now_ts).abs(); + if best.map_or(true, |(d, _)| diff < d) { + best = Some((diff, h.temperature)); + } + } + } + best.map(|(_, t)| t) +} + fn ha_weather_card( out: &mut Scene, - _state: &State, + state: &State, assets: &Assets, rect: Rect, + card: &crate::state::HaCardConfig, entity: &crate::state::HaEntity, focused: bool, ) { @@ -2586,80 +2873,141 @@ fn ha_weather_card( ha_glass(out, rect, focused); let condition = entity.state.as_str(); - let temp = entity - .attributes - .get("temperature") - .and_then(|v| v.as_f64()) - .unwrap_or(0.); - let humidity = entity - .attributes - .get("humidity") - .and_then(|v| v.as_f64()) - .unwrap_or(0.); - let wind = entity - .attributes - .get("wind_speed") - .and_then(|v| v.as_f64()) - .unwrap_or(0.); + let fallback_temp = entity.attributes.get("temperature").and_then(|v| v.as_f64()).unwrap_or(0.); + let temp = closest_hourly_temp(&state.ha.hourly_forecasts, &card.entity_id).unwrap_or(fallback_temp); + let humidity = entity.attributes.get("humidity").and_then(|v| v.as_f64()).unwrap_or(0.); + let wind = entity.attributes.get("wind_speed").and_then(|v| v.as_f64()).unwrap_or(0.); + let wind_unit = entity.attributes.get("wind_speed_unit").and_then(|v| v.as_str()).unwrap_or("km/h"); + let friendly_name = entity.attributes.get("friendly_name").and_then(|v| v.as_str()).unwrap_or(""); + let city = if !card.name.is_empty() { card.name.as_str() } else { friendly_name }; - let weather_icon = match condition { - "sunny" | "clear-night" => "weather-sun", - "cloudy" | "fog" => "weather-cloud", - "partlycloudy" => "weather-partly-cloudy", - "rainy" | "pouring" => "weather-rain", - "snowy" => "weather-snow", - "lightning" | "lightning-rainy" => "weather-storm", - "hail" => "weather-drizzle", - _ => "weather-sun", - }; + let weather_icon = weather_condition_icon(condition); + let condition_label = weather_condition_label(condition); - let cx = rect.x + rect.w * 0.5; - // Large icon centered near top - out.icon( - assets, - weather_icon, - Rect::new(cx - 32., rect.y + 24., 64., 64.), - c(0xf0d080, 1.), + let pad = 20.; + let half_w = rect.w * 0.5; + + // ── Left side: city, temperature, details ── + // City name + out.icon(assets, "location", Rect::new(rect.x + pad, rect.y + pad, 14., 14.), c(0xa0c0e0, 0.7)); + out.text( + city, + Rect::new(rect.x + pad + 18., rect.y + pad, half_w - pad - 18., 16.), + text(13., 500, 0.02), + c(0xa0c0e0, 0.7), + false, ); + // Large temperature out.text( &format!("{:.0}°", temp), - Rect::new(rect.x + 16., rect.y + 100., rect.w - 32., 48.), - text(40., 600, -0.04), + Rect::new(rect.x + pad, rect.y + 44., half_w - pad, 56.), + text(52., 600, -0.04), WHITE, - true, + false, ); + // Condition label out.text( - condition, - Rect::new(rect.x + 16., rect.y + 156., rect.w - 32., 20.), - text(15.2, 400, 0.), - c(0xe8e8e8, 0.60), - true, + condition_label, + Rect::new(rect.x + pad, rect.y + 104., half_w - pad, 18.), + text(14., 400, 0.), + c(0xe8e8e8, 0.55), + false, ); + // Humidity row - out.icon( - assets, - "humidity", - Rect::new(rect.x + 24., rect.y + 200., 20., 20.), - c(0x60b0f0, 0.7), - ); + let detail_y = rect.y + 134.; + out.icon(assets, "humidity", Rect::new(rect.x + pad, detail_y, 16., 16.), c(0x60b0f0, 0.7)); out.text( &format!("{:.0}%", humidity), - Rect::new(rect.x + 50., rect.y + 200., 80., 20.), - text(15.2, 400, 0.), - c(0xe8e8e8, 0.60), + Rect::new(rect.x + pad + 20., detail_y, 60., 16.), + text(13., 400, 0.), + c(0xe8e8e8, 0.55), false, ); + // Wind row + let wind_y = detail_y + 22.; out.text( - &format!("Wind {:.0} km/h", wind), - Rect::new(rect.x + 24., rect.y + 232., rect.w - 48., 20.), - text(15.2, 400, 0.), - c(0xe8e8e8, 0.60), + &format!("{:.0} {wind_unit}", wind), + Rect::new(rect.x + pad, wind_y, half_w - pad, 16.), + text(13., 400, 0.), + c(0xe8e8e8, 0.55), false, ); + // ── Right side: large weather icon ── + let icon_size = 80.; + let icon_x = rect.x + half_w + (half_w - icon_size) * 0.5; + let icon_y = rect.y + 30.; + out.icon( + assets, + weather_icon, + Rect::new(icon_x, icon_y, icon_size, icon_size), + c(0xf0d080, 1.), + ); + // Temp unit below icon + out.text( + &format!("{:.0}°C", temp), + Rect::new(rect.x + half_w, icon_y + icon_size + 8., half_w, 18.), + text(14., 400, 0.), + c(0xe8e8e8, 0.45), + true, + ); + + // ── Bottom: 5-day forecast ── + let forecast_y = rect.y + 190.; + // Separator line + out.shape(Rect::new(rect.x + pad, forecast_y, rect.w - pad * 2., 1.), 0., c(0xffffff, 0.08)); + + let forecasts = state.ha.forecasts.get(&card.entity_id); + if let Some(days) = forecasts { + let count = days.len().min(5); + if count > 0 { + let col_w = (rect.w - pad * 2.) / count as f32; + for (i, day) in days.iter().take(count).enumerate() { + let cx = rect.x + pad + col_w * i as f32 + col_w * 0.5; + let fy = forecast_y + 10.; + + // Day abbreviation + let day_label = weather_day_abbr(&day.date); + out.text( + day_label, + Rect::new(cx - col_w * 0.5, fy, col_w, 14.), + text(11., 500, 0.02), + c(0xe8e8e8, 0.5), + true, + ); + + // Weather icon for this day + let day_icon = weather_condition_icon(&day.condition); + out.icon( + assets, + day_icon, + Rect::new(cx - 10., fy + 18., 20., 20.), + c(0xf0d080, 0.7), + ); + + // High / Low temps + out.text( + &format!("{:.0}°", day.temp_high), + Rect::new(cx - col_w * 0.5, fy + 42., col_w, 14.), + text(12., 600, 0.), + WHITE, + true, + ); + out.text( + &format!("{:.0}°", day.temp_low), + Rect::new(cx - col_w * 0.5, fy + 58., col_w, 14.), + text(11., 400, 0.), + c(0xe8e8e8, 0.4), + true, + ); + } + } + } + out.unclip_rounded(); } @@ -3103,13 +3451,14 @@ fn settings(out: &mut Scene, state: &State, assets: &Assets) { let start = out.draw.len(); if matches!( subpage, - Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock + Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock | Subpage::HaPairing ) { match subpage { Subpage::WeatherLocation => location_settings(out, state, assets), Subpage::Greeting => greeting_settings(out, state, assets), Subpage::AppNames => app_name_settings(out, state, assets), Subpage::Clock => clock_settings(out, state, assets), + Subpage::HaPairing => ha_settings(out, state, assets), _ => unreachable!(), } if let Some(opened) = state.panel_started { @@ -3118,7 +3467,6 @@ fn settings(out: &mut Scene, state: &State, assets: &Assets) { return; } let options: Vec<(&str, Option<&str>, bool)> = match subpage { - Subpage::HaPairing => return, Subpage::ClockFormat => vec![ (language.t("hour12"), None, !state.preferences.hour_24), (language.t("hour24"), None, state.preferences.hour_24), @@ -3164,7 +3512,7 @@ fn settings(out: &mut Scene, state: &State, assets: &Assets) { state.preferences.temperature_unit == Unit::Fahrenheit, ), ], - Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock => { + Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock | Subpage::HaPairing => { unreachable!() } Subpage::Language => Language::ALL