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) {