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