diff --git a/assets/ui-icons/curtain.svg b/assets/ui-icons/curtain.svg new file mode 100644 index 0000000..4f1c174 --- /dev/null +++ b/assets/ui-icons/curtain.svg @@ -0,0 +1 @@ + diff --git a/assets/ui-icons/humidity.svg b/assets/ui-icons/humidity.svg new file mode 100644 index 0000000..d24a274 --- /dev/null +++ b/assets/ui-icons/humidity.svg @@ -0,0 +1 @@ + diff --git a/assets/ui-icons/light.svg b/assets/ui-icons/light.svg new file mode 100644 index 0000000..5d06214 --- /dev/null +++ b/assets/ui-icons/light.svg @@ -0,0 +1 @@ + diff --git a/assets/ui-icons/scene.svg b/assets/ui-icons/scene.svg new file mode 100644 index 0000000..a1063d0 --- /dev/null +++ b/assets/ui-icons/scene.svg @@ -0,0 +1 @@ + diff --git a/assets/ui-icons/switch.svg b/assets/ui-icons/switch.svg new file mode 100644 index 0000000..3e69227 --- /dev/null +++ b/assets/ui-icons/switch.svg @@ -0,0 +1 @@ + diff --git a/assets/ui-icons/vacuum.svg b/assets/ui-icons/vacuum.svg new file mode 100644 index 0000000..a5589f2 --- /dev/null +++ b/assets/ui-icons/vacuum.svg @@ -0,0 +1 @@ + diff --git a/src/main.rs b/src/main.rs index 1d53e3a..bf03ede 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,6 +178,7 @@ fn effects( platform: &Platform, preferences: &std::path::Path, weather: &weather_service::Service, + ha: Option<&ha_client::HaService>, ) -> bool { for effect in effects { match effect { @@ -225,11 +226,110 @@ fn effects( } Effect::Quit => return false, Effect::Weather(command) => weather.send(command), + Effect::Ha(ha_effect) => { + if let Some(service) = ha { + ha_dispatch(service, ha_effect); + } + } } } true } +fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) { + use state::HaEffect; + static CALL_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1000); + let next_id = || CALL_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + match effect { + HaEffect::Toggle(entity_id) => { + let (domain, service_name) = if entity_id.starts_with("light.") { + ("light", "toggle") + } else if entity_id.starts_with("switch.") { + ("switch", "toggle") + } else if entity_id.starts_with("cover.") { + ("cover", "toggle") + } else { + ("homeassistant", "toggle") + }; + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: domain.into(), + service: service_name.into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({}), + }); + } + HaEffect::SetBrightness(entity_id, brightness) => { + if brightness <= 0. { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "light".into(), + service: "turn_off".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({}), + }); + } else { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "light".into(), + service: "turn_on".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({ "brightness": brightness as u8 }), + }); + } + } + HaEffect::SetClimateTemp(entity_id, temp) => { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "climate".into(), + service: "set_temperature".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({ "temperature": temp }), + }); + } + HaEffect::ActivateScene(entity_id) => { + let (domain, service_name) = if entity_id.starts_with("script.") { + ("script", "turn_on") + } else { + ("scene", "turn_on") + }; + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: domain.into(), + service: service_name.into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({}), + }); + } + HaEffect::VacuumClean { entity_id, segments, mode } => { + if let Some(mode_name) = &mode { + let cleaning_mode_entity = format!( + "select.{}_cleaning_mode", + entity_id.strip_prefix("vacuum.").unwrap_or(&entity_id) + ); + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "select".into(), + service: "select_option".into(), + target: serde_json::json!({ "entity_id": cleaning_mode_entity }), + data: serde_json::json!({ "option": mode_name }), + }); + } + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "vacuum".into(), + service: "send_command".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({ + "command": "app_segment_clean", + "params": { "segments": segments, "repeat": 1 } + }), + }); + } + } +} + fn script( action: &str, state: &mut State, @@ -524,6 +624,7 @@ fn run() -> Result<(), String> { }; let ha_service = if let Ok(bytes) = std::fs::read(&ha_config_path) { if let Ok(config_value) = serde_json::from_slice::(&bytes) { + state.ha.config = state::HaTabConfig::parse(&config_value); let url = config_value.get("ha").and_then(|h| h.get("url")).and_then(|v| v.as_str()).unwrap_or("").to_owned(); let token = config_value.get("ha").and_then(|h| h.get("token")).and_then(|v| v.as_str()).unwrap_or("").to_owned(); if !url.is_empty() && !token.is_empty() { @@ -555,6 +656,7 @@ fn run() -> Result<(), String> { &platform, &preferences_path, &weather_service, + ha_service.as_ref(), ); platform.sync_splash(&state.preferences.background); startup_stage("preferences and workers"); @@ -965,6 +1067,7 @@ fn run() -> Result<(), String> { &platform, &preferences_path, &weather_service, + ha_service.as_ref(), ); if press .as_ref() @@ -1008,6 +1111,7 @@ fn run() -> Result<(), String> { &platform, &preferences_path, &weather_service, + ha_service.as_ref(), ); } } @@ -1029,6 +1133,7 @@ fn run() -> Result<(), String> { &platform, &preferences_path, &weather_service, + ha_service.as_ref(), ); } let keyboard = state.text_input_active(); diff --git a/src/state.rs b/src/state.rs index fbf08ee..d02e81a 100644 --- a/src/state.rs +++ b/src/state.rs @@ -62,6 +62,14 @@ pub enum Target { WeatherSearch, WeatherClear, WeatherResult(usize), + HaRoom(usize), + HaCard(usize, usize), + HaWeather, + HaVacuumOpen, + HaVacuumRoom(usize), + HaVacuumMode, + HaVacuumStart, + HaVacuumClose, } #[derive(Clone, Copy, Debug, Default)] @@ -139,6 +147,20 @@ pub enum Effect { Launch(String), Quit, Weather(weather::Command), + Ha(HaEffect), +} + +#[derive(Debug, PartialEq)] +pub enum HaEffect { + Toggle(String), + SetBrightness(String, f32), + SetClimateTemp(String, f32), + ActivateScene(String), + VacuumClean { + entity_id: String, + segments: Vec, + mode: Option, + }, } pub const KEYS: [&str; 40] = [ @@ -147,11 +169,64 @@ pub const KEYS: [&str; 40] = [ "Delete", "Clear", "Done", ]; +#[derive(Clone, Debug)] +pub enum HaCardType { + Toggle, + Range, + Climate, + Action, + Vacuum, +} + +#[derive(Clone, Debug)] +pub struct HaCardConfig { + pub card_type: HaCardType, + pub entity_id: String, + pub name: String, +} + +#[derive(Clone, Debug)] +pub struct HaRoomConfig { + pub id: String, + pub name: String, + pub cards: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct HaWeatherConfig { + pub entity_id: String, +} + +#[derive(Clone, Debug)] +pub struct HaVacuumRoom { + pub segment_id: u32, + pub name: String, +} + +#[derive(Clone, Debug, Default)] +pub struct HaVacuumConfig { + pub entity_id: String, + pub cleaning_mode_entity: String, + pub rooms: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct HaTabConfig { + pub rooms: Vec, + pub weather: Option, + pub vacuum: Option, +} + #[derive(Default)] pub struct HaState { pub connected: bool, pub entities: std::collections::HashMap, pub history: std::collections::HashMap>, + pub config: HaTabConfig, + pub room: usize, + pub vacuum_selected: Vec, + pub vacuum_mode: usize, + pub vacuum_open: bool, } #[derive(Clone, Debug)] @@ -160,6 +235,74 @@ pub struct HaEntity { pub attributes: serde_json::Value, } +impl HaTabConfig { + pub fn parse(value: &serde_json::Value) -> Self { + let mut config = HaTabConfig::default(); + + if let Some(w) = value.get("weather") { + if let Some(eid) = w.get("entity_id").and_then(|v| v.as_str()) { + config.weather = Some(HaWeatherConfig { + entity_id: eid.to_owned(), + }); + } + } + + if let Some(v) = value.get("vacuum") { + let mut vc = HaVacuumConfig::default(); + if let Some(eid) = v.get("entity_id").and_then(|v| v.as_str()) { + vc.entity_id = eid.to_owned(); + } + 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()) { + for (id, name) in rooms { + if let (Ok(seg), Some(n)) = (id.parse::(), name.as_str()) { + vc.rooms.push(HaVacuumRoom { + segment_id: seg, + name: n.to_owned(), + }); + } + } + vc.rooms.sort_by_key(|r| r.segment_id); + } + if !vc.entity_id.is_empty() { + config.vacuum = Some(vc); + } + } + + if let Some(rooms) = value.get("rooms").and_then(|v| v.as_array()) { + for room in rooms { + let id = room.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned(); + let name = room.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned(); + let mut cards = Vec::new(); + if let Some(card_arr) = room.get("cards").and_then(|v| v.as_array()) { + for card in card_arr { + let ct = match card.get("type").and_then(|v| v.as_str()) { + Some("toggle") => HaCardType::Toggle, + Some("range") => HaCardType::Range, + Some("climate") => HaCardType::Climate, + Some("action") => HaCardType::Action, + Some("vacuum") => HaCardType::Vacuum, + _ => continue, + }; + let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned(); + let n = card.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned(); + cards.push(HaCardConfig { + card_type: ct, + entity_id: eid, + name: n, + }); + } + } + config.rooms.push(HaRoomConfig { id, name, cards }); + } + } + + config + } +} + pub struct State { pub page: Page, pub focus: Target, @@ -410,7 +553,13 @@ impl State { .first() .map(|app| Target::App(app.id.clone())) .unwrap_or(Target::Filter(1)), - Page::Ha => Target::Nav(2), + Page::Ha => { + if self.ha.config.rooms.is_empty() { + Target::Nav(2) + } else { + Target::HaRoom(self.ha.room) + } + } Page::Settings => Target::Sidebar(0), } } @@ -636,6 +785,99 @@ impl State { return vec![]; } } + // ── HA page navigation ──────────────────────────────── + if self.page == Page::Ha && !self.ha.config.rooms.is_empty() { + if self.ha.vacuum_open { + return self.ha_vacuum_direction(direction, controls); + } + if let Target::HaRoom(room) = self.focus { + match direction { + Direction::Left if room > 0 => { + self.ha.room = room - 1; + self.focus(Target::HaRoom(room - 1), controls); + self.scroll = 0.; + } + Direction::Right if room + 1 < self.ha.config.rooms.len() => { + self.ha.room = room + 1; + self.focus(Target::HaRoom(room + 1), controls); + self.scroll = 0.; + } + Direction::Up => self.focus(Target::Nav(2), controls), + Direction::Down => { + if let Some(weather) = &self.ha.config.weather + && self.ha.entities.contains_key(&weather.entity_id) + { + self.focus(Target::HaWeather, controls); + } else if !self.ha.config.rooms[self.ha.room].cards.is_empty() { + self.focus(Target::HaCard(self.ha.room, 0), controls); + } + } + _ => {} + } + return vec![]; + } + if self.focus == Target::HaWeather { + match direction { + Direction::Up => self.focus(Target::HaRoom(self.ha.room), controls), + Direction::Down => { + if !self.ha.config.rooms[self.ha.room].cards.is_empty() { + self.focus(Target::HaCard(self.ha.room, 0), controls); + } + } + _ => {} + } + return vec![]; + } + if let Target::HaCard(room, index) = self.focus { + let room_cfg = &self.ha.config.rooms[room]; + let card = &room_cfg.cards[index]; + let cols = self.ha_card_columns(); + match (&card.card_type, direction) { + (HaCardType::Range, Direction::Left) => { + let brightness = self.ha_entity_brightness(&card.entity_id); + let new = (brightness - 25.5).max(0.); + return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))]; + } + (HaCardType::Range, Direction::Right) => { + let brightness = self.ha_entity_brightness(&card.entity_id); + let new = (brightness + 25.5).min(255.); + return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))]; + } + (HaCardType::Climate, Direction::Left) => { + let temp = self.ha_entity_target_temp(&card.entity_id); + return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp - 0.5))]; + } + (HaCardType::Climate, Direction::Right) => { + let temp = self.ha_entity_target_temp(&card.entity_id); + return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp + 0.5))]; + } + (_, Direction::Left) if index % cols > 0 => { + self.focus(Target::HaCard(room, index - 1), controls); + } + (_, Direction::Right) if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => { + self.focus(Target::HaCard(room, index + 1), controls); + } + (_, Direction::Up) => { + if index >= cols { + self.focus(Target::HaCard(room, index - cols), controls); + } else if self.ha.config.weather.is_some() { + self.focus(Target::HaWeather, controls); + } else { + self.focus(Target::HaRoom(self.ha.room), controls); + } + } + (_, Direction::Down) => { + let next = index + cols; + if next < room_cfg.cards.len() { + self.focus(Target::HaCard(room, next), controls); + } + } + _ => {} + } + return vec![]; + } + } + let setting_rows = match self.subpage { Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]), Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]), @@ -982,6 +1224,80 @@ impl State { vec![] } + fn ha_card_columns(&self) -> usize { + 3 + } + + fn ha_entity_brightness(&self, entity_id: &str) -> f32 { + self.ha.entities.get(entity_id) + .and_then(|e| e.attributes.get("brightness")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.) as f32 + } + + fn ha_entity_target_temp(&self, entity_id: &str) -> f32 { + self.ha.entities.get(entity_id) + .and_then(|e| e.attributes.get("temperature")) + .and_then(|v| v.as_f64()) + .unwrap_or(20.) as f32 + } + + fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec { + let vac = match &self.ha.config.vacuum { + Some(v) => v, + None => return vec![], + }; + let room_count = vac.rooms.len(); + let cols = 3usize; + match &self.focus { + Target::HaVacuumRoom(index) => { + let index = *index; + match direction { + Direction::Left if index % cols > 0 => { + self.focus(Target::HaVacuumRoom(index - 1), controls); + } + Direction::Right if index % cols + 1 < cols && index + 1 < room_count => { + self.focus(Target::HaVacuumRoom(index + 1), controls); + } + Direction::Up if index >= cols => { + self.focus(Target::HaVacuumRoom(index - cols), controls); + } + Direction::Up => { + self.focus(Target::HaVacuumClose, controls); + } + Direction::Down => { + let next = index + cols; + if next < room_count { + self.focus(Target::HaVacuumRoom(next), controls); + } else { + self.focus(Target::HaVacuumMode, controls); + } + } + _ => {} + } + } + Target::HaVacuumMode => match direction { + Direction::Up => { + let last_row_start = (room_count.saturating_sub(1)) / cols * cols; + let idx = (last_row_start).min(room_count.saturating_sub(1)); + self.focus(Target::HaVacuumRoom(idx), controls); + } + Direction::Down => self.focus(Target::HaVacuumStart, controls), + _ => {} + }, + Target::HaVacuumStart => match direction { + Direction::Up => self.focus(Target::HaVacuumMode, controls), + _ => {} + }, + Target::HaVacuumClose => match direction { + Direction::Down => self.focus(Target::HaVacuumRoom(0), controls), + _ => {} + }, + _ => {} + } + vec![] + } + pub fn options(&mut self) { if self.overlay.is_some() || self.moving.is_some() { return; @@ -1330,6 +1646,82 @@ impl State { return vec![Effect::Save]; } } + Target::HaRoom(index) => { + self.ha.room = index; + self.scroll = 0.; + if !self.ha.config.rooms[index].cards.is_empty() { + self.focus = Target::HaCard(index, 0); + } + } + Target::HaCard(room, index) => { + let card = &self.ha.config.rooms[room].cards[index]; + match card.card_type { + HaCardType::Toggle => { + return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))]; + } + HaCardType::Action => { + 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); + } + } + _ => {} + } + } + Target::HaVacuumRoom(index) => { + if index < self.ha.vacuum_selected.len() { + self.ha.vacuum_selected[index] = !self.ha.vacuum_selected[index]; + } + } + Target::HaVacuumMode => { + if let Some(vac) = &self.ha.config.vacuum { + if let Some(entity) = self.ha.entities.get(&vac.cleaning_mode_entity) { + if let Some(options) = entity.attributes.get("options").and_then(|o| o.as_array()) { + self.ha.vacuum_mode = (self.ha.vacuum_mode + 1) % options.len().max(1); + } + } + } + } + Target::HaVacuumStart => { + if let Some(vac) = &self.ha.config.vacuum { + let segments: Vec = vac.rooms.iter() + .enumerate() + .filter(|(i, _)| self.ha.vacuum_selected.get(*i).copied().unwrap_or(false)) + .map(|(_, r)| r.segment_id) + .collect(); + if !segments.is_empty() { + let mode = self.ha.entities.get(&vac.cleaning_mode_entity) + .and_then(|e| e.attributes.get("options")) + .and_then(|o| o.as_array()) + .and_then(|opts| opts.get(self.ha.vacuum_mode)) + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()); + self.ha.vacuum_open = false; + self.focus = Target::HaCard(self.ha.room, + self.ha.config.rooms[self.ha.room].cards.iter() + .position(|c| matches!(c.card_type, HaCardType::Vacuum)) + .unwrap_or(0)); + return vec![Effect::Ha(HaEffect::VacuumClean { + entity_id: vac.entity_id.clone(), + segments, + mode, + })]; + } + } + } + Target::HaVacuumClose => { + self.ha.vacuum_open = false; + self.focus = Target::HaCard(self.ha.room, + self.ha.config.rooms[self.ha.room].cards.iter() + .position(|c| matches!(c.card_type, HaCardType::Vacuum)) + .unwrap_or(0)); + } + Target::HaVacuumOpen | Target::HaWeather => {} Target::Sidebar(index) => { self.section = index; self.scroll = 0.; @@ -1560,6 +1952,19 @@ impl State { self.scroll = 0.; return vec![]; } + if self.page == Page::Ha && self.ha.vacuum_open { + self.ha.vacuum_open = false; + self.focus = Target::HaCard(self.ha.room, + self.ha.config.rooms[self.ha.room].cards.iter() + .position(|c| matches!(c.card_type, HaCardType::Vacuum)) + .unwrap_or(0)); + return vec![]; + } + if self.page == Page::Ha && matches!(self.focus, Target::HaCard(..)) { + self.focus = Target::HaRoom(self.ha.room); + self.scroll = 0.; + return vec![]; + } if self.page == Page::Apps && matches!(self.focus, Target::Search | Target::ClearSearch) { self.query.clear(); self.focus = Target::Nav(1); diff --git a/src/view.rs b/src/view.rs index bb4ea4c..f9d08d8 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1608,20 +1608,686 @@ fn location_settings(out: &mut Scene, state: &State, assets: &Assets) { } fn ha(out: &mut Scene, state: &State, assets: &Assets) { + use crate::state::HaCardType; + let language = state.preferences.language; - let rect = Rect::new(84., 164.8, 1752., 780.); - empty( - out, - state, + let config = &state.ha.config; + let s = state.scroll; + + if config.rooms.is_empty() { + let rect = Rect::new(84., 164.8, 1752., 780.); + empty( + out, + state, + assets, + EmptyState { + rect, + icon: "settings", + title: language.t("ha"), + hint: "Home Assistant", + action: None, + }, + ); + return; + } + + // ── Room tabs (horizontal pill strip) ──────────────────────── + let mut tab_x = 84.; + for (index, room) in config.rooms.iter().enumerate() { + let width = (assets.width(&room.name, text(16., 500, 0.)) + 36.).max(88.); + let rect = Rect::new(tab_x, 164.8, width, 42.8); + let focused = state.focus == Target::HaRoom(index); + let selected = state.ha.room == index; + out.pill(rect, focused, selected); + let color = if focused { + c(0x2c2c2c, 1.) + } else if selected { + WHITE + } else { + c(0xeeeeee, 0.55) + }; + out.text( + &room.name, + Rect::new(rect.x + 18., rect.y + 12., rect.w - 36., 20.), + text(16., 500, 0.), + color, + true, + ); + out.control(Target::HaRoom(index), rect, false); + tab_x += width + 8.; + } + + // ── Connection indicator ───────────────────────────────────── + if !state.ha.connected { + let dot = Rect::new(tab_x + 12., 178., 12., 12.); + out.shape(dot, 6., c(0xff6b6b, 0.8)); + } + + let room = &config.rooms[state.ha.room]; + let mut y = 230. - s; + + // ── Weather hero card (if configured) ──────────────────────── + if let Some(weather_cfg) = &config.weather { + if let Some(entity) = state.ha.entities.get(&weather_cfg.entity_id) { + let rect = Rect::new(84., y, 1752., 100.); + ha_weather_card(out, state, assets, rect, entity); + out.control(Target::HaWeather, rect, true); + y += 116.; + } + } + + // ── Card grid ──────────────────────────────────────────────── + let cols = 3usize; + let card_w = 564.; + let card_gap = 16.; + let card_h = 88.; + + for (index, card) in room.cards.iter().enumerate() { + let col = index % cols; + let row = index / cols; + let cx = 84. + col as f32 * (card_w + card_gap); + let cy = y + row as f32 * (card_h + card_gap); + let rect = Rect::new(cx, cy, card_w, card_h); + let focused = state.focus == Target::HaCard(state.ha.room, index); + + let entity = state.ha.entities.get(&card.entity_id); + match card.card_type { + HaCardType::Toggle => { + ha_toggle_card(out, state, assets, rect, card, entity, focused); + } + HaCardType::Range => { + ha_range_card(out, state, assets, rect, card, entity, focused); + } + HaCardType::Climate => { + ha_climate_card(out, state, assets, rect, card, entity, focused); + } + HaCardType::Action => { + ha_action_card(out, state, assets, rect, card, focused); + } + HaCardType::Vacuum => { + ha_vacuum_card(out, state, assets, rect, card, entity, focused); + } + } + out.control(Target::HaCard(state.ha.room, index), rect, true); + } + + // ── Vacuum modal (overlays everything) ─────────────────────── + if state.ha.vacuum_open { + if let Some(vac_cfg) = &config.vacuum { + ha_vacuum_modal(out, state, assets, vac_cfg); + } + } +} + +// ── Glass card base ────────────────────────────────────────────── + +fn ha_glass(out: &mut Scene, rect: Rect, focused: bool) { + out.shadow( + rect, + 22., + if focused { 28. } else { 6. }, + if focused { 8. } else { 2. }, + if focused { 0.28 } else { 0.06 }, + ); + out.shape(rect, 22., c(0xffffff, if focused { 0.16 } else { 0.12 })); + out.border(rect, 22., 1., c(0xffffff, if focused { 0.28 } else { 0.15 })); + if focused { + out.border(rect.inset(-4.), 26., 2., c(0xffffff, 0.12)); + } +} + +fn ha_entity_icon(card_type: &crate::state::HaCardType, entity_id: &str) -> &'static str { + use crate::state::HaCardType; + match card_type { + HaCardType::Toggle => { + if entity_id.starts_with("light.") { + "light" + } else if entity_id.starts_with("cover.") { + "curtain" + } else { + "switch" + } + } + HaCardType::Range => "light", + HaCardType::Climate => "thermometer", + HaCardType::Action => "scene", + HaCardType::Vacuum => "vacuum", + } +} + +fn ha_state_color(entity: Option<&crate::state::HaEntity>) -> Color { + match entity.map(|e| e.state.as_str()) { + Some("on") => c(0x7EB8DA, 1.), + Some("heat" | "heat_cool" | "auto") => c(0xf0a060, 1.), + Some("cool") => c(0x60b0f0, 1.), + _ => c(0xbbbbbb, 0.5), + } +} + +// ── Toggle card ────────────────────────────────────────────────── + +fn ha_toggle_card( + out: &mut Scene, + _state: &State, + assets: &Assets, + rect: Rect, + card: &crate::state::HaCardConfig, + entity: Option<&crate::state::HaEntity>, + focused: bool, +) { + ha_glass(out, rect, focused); + let icon_name = ha_entity_icon(&card.card_type, &card.entity_id); + let accent = ha_state_color(entity); + let is_on = entity.is_some_and(|e| e.state == "on"); + + out.icon( assets, - EmptyState { + icon_name, + Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + if is_on { accent } else { c(0xbbbbbb, 0.5) }, + ); + out.text( + &card.name, + Rect::new(rect.x + 80., rect.y + 22., rect.w - 180., 24.), + text(20., 500, -0.02), + WHITE, + false, + ); + let status = entity.map_or("unavailable", |e| e.state.as_str()); + out.text( + status, + Rect::new(rect.x + 80., rect.y + 52., rect.w - 180., 16.), + text(13.12, 400, 0.), + c(0xe8e8e8, 0.48), + false, + ); + // On/off indicator dot + let dot_x = rect.x + rect.w - 36.; + let dot_y = rect.y + rect.h / 2. - 8.; + out.shape( + Rect::new(dot_x, dot_y, 16., 16.), + 8., + if is_on { accent } else { c(0x555555, 0.6) }, + ); +} + +// ── Range card (dimmable lights, cover position) ───────────────── + +fn ha_range_card( + out: &mut Scene, + _state: &State, + assets: &Assets, + rect: Rect, + card: &crate::state::HaCardConfig, + entity: Option<&crate::state::HaEntity>, + focused: bool, +) { + ha_glass(out, rect, focused); + let is_on = entity.is_some_and(|e| e.state == "on"); + let brightness = entity + .and_then(|e| e.attributes.get("brightness")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.); + let pct = (brightness / 255. * 100.).round() as u8; + let accent = if is_on { c(0x7EB8DA, 1.) } else { c(0xbbbbbb, 0.5) }; + + out.icon( + assets, + "light", + Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + accent, + ); + out.text( + &card.name, + Rect::new(rect.x + 80., rect.y + 16., rect.w - 180., 24.), + text(20., 500, -0.02), + WHITE, + false, + ); + // Brightness bar + let bar_x = rect.x + 80.; + let bar_y = rect.y + 52.; + let bar_w = rect.w - 160.; + let bar_h = 8.; + out.shape(Rect::new(bar_x, bar_y, bar_w, bar_h), 4., c(0xffffff, 0.08)); + if is_on && pct > 0 { + let fill_w = bar_w * (pct as f32 / 100.).min(1.); + out.shape(Rect::new(bar_x, bar_y, fill_w, bar_h), 4., accent); + } + // Percentage label + let label = if is_on { format!("{}%", pct) } else { "Off".into() }; + out.text( + &label, + Rect::new(rect.x + rect.w - 72., rect.y + 34., 52., 20.), + text(15.2, 500, 0.).right(), + if is_on { WHITE } else { c(0xbbbbbb, 0.5) }, + false, + ); + if focused { + // D-pad hint arrows + out.icon(assets, "left", Rect::new(rect.x + 80., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4)); + out.icon(assets, "right", Rect::new(rect.x + 96., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4)); + } +} + +// ── Climate card ───────────────────────────────────────────────── + +fn ha_climate_card( + out: &mut Scene, + state: &State, + assets: &Assets, + rect: Rect, + card: &crate::state::HaCardConfig, + entity: Option<&crate::state::HaEntity>, + focused: bool, +) { + ha_glass(out, rect, focused); + let accent = ha_state_color(entity); + let current_temp = entity + .and_then(|e| e.attributes.get("current_temperature")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.); + let target_temp = entity + .and_then(|e| e.attributes.get("temperature")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.); + let hvac_mode = entity.map_or("off", |e| e.state.as_str()); + + out.icon( + assets, + "thermometer", + Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + accent, + ); + out.text( + &card.name, + Rect::new(rect.x + 80., rect.y + 16., 280., 24.), + text(20., 500, -0.02), + WHITE, + false, + ); + out.text( + hvac_mode, + Rect::new(rect.x + 80., rect.y + 46., 120., 16.), + text(13.12, 400, 0.), + c(0xe8e8e8, 0.48), + false, + ); + // Current temperature + let current_str = format!("{:.1}°", current_temp); + out.text( + ¤t_str, + Rect::new(rect.x + rect.w - 200., rect.y + 16., 80., 28.), + text(24., 600, -0.03).right(), + WHITE, + false, + ); + // Target temperature + let target_str = format!("→ {:.1}°", target_temp); + out.text( + &target_str, + Rect::new(rect.x + rect.w - 108., rect.y + 18., 88., 24.), + text(18., 400, 0.), + accent, + false, + ); + if focused { + out.icon(assets, "left", Rect::new(rect.x + rect.w - 108., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4)); + out.icon(assets, "right", Rect::new(rect.x + rect.w - 92., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4)); + } + // History sparkline if available + if let Some(points) = state.ha.history.get(&card.entity_id) { + if points.len() >= 2 { + ha_sparkline(out, Rect::new(rect.x + 80., rect.y + 64., 300., 16.), points, accent); + } + } +} + +fn ha_sparkline(out: &mut Scene, rect: Rect, points: &[(f64, f64)], color: Color) { + if points.is_empty() { return; } + let min_val = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min); + let max_val = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max); + let range = (max_val - min_val).max(1.); + let step = rect.w / (points.len() as f32 - 1.).max(1.); + for i in 0..points.len() { + let x = rect.x + i as f32 * step; + let norm = ((points[i].1 - min_val) / range) as f32; + let bar_h = (norm * rect.h).max(2.); + let y = rect.y + rect.h - bar_h; + out.shape( + Rect::new(x, y, step.max(2.), bar_h), + 0., + [color[0], color[1], color[2], 0.35], + ); + } +} + +// ── Action card (scenes, scripts) ──────────────────────────────── + +fn ha_action_card( + out: &mut Scene, + _state: &State, + assets: &Assets, + rect: Rect, + card: &crate::state::HaCardConfig, + focused: bool, +) { + ha_glass(out, rect, focused); + let accent = c(0xc9a0dc, 1.); + out.icon( + assets, + "scene", + Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + accent, + ); + out.text( + &card.name, + Rect::new(rect.x + 80., rect.y + 28., rect.w - 120., 32.), + text(20., 500, -0.02), + WHITE, + false, + ); + if focused { + out.text( + "OK to activate", + Rect::new(rect.x + 80., rect.y + 60., 200., 16.), + text(12., 400, 0.), + c(0xe8e8e8, 0.40), + false, + ); + } +} + +// ── Vacuum card (summary; opens modal) ─────────────────────────── + +fn ha_vacuum_card( + out: &mut Scene, + _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), + }; + out.icon( + assets, + "vacuum", + Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + accent, + ); + out.text( + &card.name, + Rect::new(rect.x + 80., rect.y + 22., rect.w - 120., 24.), + text(20., 500, -0.02), + WHITE, + false, + ); + out.text( + status, + Rect::new(rect.x + 80., rect.y + 52., rect.w - 120., 16.), + text(13.12, 400, 0.), + c(0xe8e8e8, 0.48), + false, + ); + if focused { + out.text( + "OK to open", + Rect::new(rect.x + rect.w - 120., rect.y + 36., 100., 16.), + text(12., 400, 0.).right(), + c(0xe8e8e8, 0.40), + false, + ); + } +} + +// ── Weather hero card ──────────────────────────────────────────── + +fn ha_weather_card( + out: &mut Scene, + _state: &State, + assets: &Assets, + rect: Rect, + entity: &crate::state::HaEntity, +) { + let focused = _state.focus == Target::HaWeather; + 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 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", + }; + out.icon( + assets, + weather_icon, + Rect::new(rect.x + 24., rect.y + 22., 56., 56.), + c(0xf0d080, 1.), + ); + out.text( + &format!("{:.0}°", temp), + Rect::new(rect.x + 100., rect.y + 18., 120., 40.), + text(36., 600, -0.04), + WHITE, + false, + ); + out.text( + condition, + Rect::new(rect.x + 100., rect.y + 62., 200., 18.), + text(14.4, 400, 0.), + c(0xe8e8e8, 0.60), + false, + ); + out.icon( + assets, + "humidity", + Rect::new(rect.x + 340., rect.y + 34., 20., 20.), + c(0x60b0f0, 0.7), + ); + out.text( + &format!("{:.0}%", humidity), + Rect::new(rect.x + 368., rect.y + 36., 80., 20.), + text(15.2, 400, 0.), + c(0xe8e8e8, 0.60), + false, + ); + out.text( + &format!("{:.0} km/h", wind), + Rect::new(rect.x + 480., rect.y + 36., 120., 20.), + text(15.2, 400, 0.), + c(0xe8e8e8, 0.60), + false, + ); +} + +// ── Vacuum modal ───────────────────────────────────────────────── + +fn ha_vacuum_modal( + out: &mut Scene, + state: &State, + assets: &Assets, + vac: &crate::state::HaVacuumConfig, +) { + let overlay_rect = Rect::new(0., 0., 1920., 1080.); + out.shape(overlay_rect, 0., c(0x080b12, 0.75)); + out.modal = Some(overlay_rect); + + let modal = Rect::new(320., 120., 1280., 840.); + ha_glass(out, modal, false); + + // Title + close + out.text( + "Room Cleaning", + Rect::new(modal.x + 40., modal.y + 28., 400., 32.), + text(28., 600, -0.03), + WHITE, + false, + ); + let close_rect = Rect::new(modal.x + modal.w - 72., modal.y + 24., 40., 40.); + out.shape(close_rect, 20., c(0xffffff, 0.08)); + out.icon(assets, "close", close_rect.inset(10.), c(0xeeeeee, 0.7)); + let close_focused = state.focus == Target::HaVacuumClose; + if close_focused { + out.border(close_rect.inset(-3.), 23., 2., WHITE); + } + out.control(Target::HaVacuumClose, close_rect, false); + + // Room grid + let cols = 3usize; + let room_w = 380.; + let room_h = 72.; + let gap = 12.; + let grid_x = modal.x + 40.; + let grid_y = modal.y + 88.; + + for (index, room) in vac.rooms.iter().enumerate() { + let col = index % cols; + let row = index / cols; + let rx = grid_x + col as f32 * (room_w + gap); + let ry = grid_y + row as f32 * (room_h + gap); + let rect = Rect::new(rx, ry, room_w, room_h); + let selected = state.ha.vacuum_selected.get(index).copied().unwrap_or(false); + let focused = state.focus == Target::HaVacuumRoom(index); + + out.shape( rect, - icon: "settings", - title: language.t("ha"), - hint: "Home Assistant", - action: None, + 16., + if selected { + c(0x7EB8DA, 0.20) + } else { + c(0xffffff, 0.06) + }, + ); + out.border( + rect, + 16., + if selected { 2. } else { 1. }, + if selected { + c(0x7EB8DA, 0.60) + } else { + c(0xffffff, 0.10) + }, + ); + if focused { + out.border(rect.inset(-4.), 20., 2., WHITE); + } + if selected { + out.icon( + assets, + "check", + Rect::new(rect.x + 16., rect.y + 20., 32., 32.), + c(0x7EB8DA, 1.), + ); + } + out.text( + &room.name, + Rect::new( + rect.x + if selected { 56. } else { 20. }, + rect.y + 24., + rect.w - 76., + 24., + ), + text(18., 500, -0.02), + WHITE, + false, + ); + out.control(Target::HaVacuumRoom(index), rect, false); + } + + // Cleaning mode selector + let mode_y = grid_y + ((vac.rooms.len() + cols - 1) / cols) as f32 * (room_h + gap) + 16.; + let mode_rect = Rect::new(grid_x, mode_y, 600., 56.); + let mode_focused = state.focus == Target::HaVacuumMode; + out.shape(mode_rect, 14., c(0xffffff, 0.06)); + out.border(mode_rect, 14., 1., c(0xffffff, 0.10)); + if mode_focused { + out.border(mode_rect.inset(-4.), 18., 2., WHITE); + } + let mode_label = state.ha.entities.get(&vac.cleaning_mode_entity) + .and_then(|e| e.attributes.get("options")) + .and_then(|o| o.as_array()) + .and_then(|opts| opts.get(state.ha.vacuum_mode)) + .and_then(|v| v.as_str()) + .unwrap_or("default"); + out.text( + &format!("Mode: {}", mode_label), + Rect::new(mode_rect.x + 20., mode_rect.y + 16., 540., 24.), + text(18., 500, -0.02), + WHITE, + false, + ); + out.control(Target::HaVacuumMode, mode_rect, false); + + // Start button + let start_y = mode_y + 72.; + let selected_count = state.ha.vacuum_selected.iter().filter(|&&s| s).count(); + let start_rect = Rect::new(grid_x, start_y, 600., 56.); + let start_focused = state.focus == Target::HaVacuumStart; + let can_start = selected_count > 0; + out.shape( + start_rect, + 14., + if can_start { + c(0x7EB8DA, 0.25) + } else { + c(0xffffff, 0.04) }, ); + out.border( + start_rect, + 14., + 1., + if can_start { + c(0x7EB8DA, 0.50) + } else { + c(0xffffff, 0.06) + }, + ); + if start_focused && can_start { + out.border(start_rect.inset(-4.), 18., 2., WHITE); + } + out.text( + &format!( + "Start Cleaning{}", + if selected_count > 0 { + format!(" ({} rooms)", selected_count) + } else { + String::new() + } + ), + Rect::new(start_rect.x + 20., start_rect.y + 16., 540., 24.), + text(18., 600, -0.02), + if can_start { WHITE } else { c(0xbbbbbb, 0.5) }, + false, + ); + out.control(Target::HaVacuumStart, start_rect, false); } fn settings(out: &mut Scene, state: &State, assets: &Assets) {