diff --git a/Cargo.lock b/Cargo.lock index 8ca3174..bbfa84e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,6 +358,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" name = "luma" version = "2.3.1" dependencies = [ + "getrandom", "gl", "image", "libc", diff --git a/Cargo.toml b/Cargo.toml index 7840ce8..49d5581 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ default = [] webos = ["dep:libc"] [dependencies] +getrandom = "0.2" gl = "0.14" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } libc = { version = "0.2", optional = true } diff --git a/assets/messages.json b/assets/messages.json index 8f74e09..9ea1616 100644 --- a/assets/messages.json +++ b/assets/messages.json @@ -1254,5 +1254,77 @@ "zh": "滚动测试卡片,没有可启动的应用。", "pt": "Card de teste para rolagem; não abre nenhum app.", "it": "Scheda di test per lo scorrimento; nessuna app da avviare." + }, + "homeAssistant": { + "en": "Home Assistant", + "de": "Home Assistant", + "es": "Home Assistant", + "zh": "Home Assistant", + "pt": "Home Assistant", + "it": "Home Assistant" + }, + "haServerUrl": { + "en": "Server URL", + "de": "Server-URL", + "es": "URL del servidor", + "zh": "服务器地址", + "pt": "URL do servidor", + "it": "URL del server" + }, + "haConnect": { + "en": "Connect", + "de": "Verbinden", + "es": "Conectar", + "zh": "连接", + "pt": "Conectar", + "it": "Connetti" + }, + "haDisconnect": { + "en": "Disconnect", + "de": "Trennen", + "es": "Desconectar", + "zh": "断开", + "pt": "Desconectar", + "it": "Disconnetti" + }, + "haConnected": { + "en": "Connected", + "de": "Verbunden", + "es": "Conectado", + "zh": "已连接", + "pt": "Conectado", + "it": "Connesso" + }, + "haPairing": { + "en": "Pairing", + "de": "Koppeln", + "es": "Emparejando", + "zh": "配对中", + "pt": "Emparelhando", + "it": "Associazione" + }, + "haPairingPin": { + "en": "Enter this PIN on your pairing page", + "de": "Diesen PIN auf der Kopplungsseite eingeben", + "es": "Introduzca este PIN en la página de emparejamiento", + "zh": "在配对页面输入此PIN码", + "pt": "Insira este PIN na página de emparelhamento", + "it": "Inserisci questo PIN nella pagina di associazione" + }, + "haWaiting": { + "en": "Waiting for pairing…", + "de": "Warten auf Kopplung…", + "es": "Esperando emparejamiento…", + "zh": "等待配对…", + "pt": "Aguardando emparelhamento…", + "it": "In attesa di associazione…" + }, + "haUrlHint": { + "en": "e.g. https://ha.example.org", + "de": "z.B. https://ha.example.org", + "es": "p. ej. https://ha.example.org", + "zh": "例如 https://ha.example.org", + "pt": "ex. https://ha.example.org", + "it": "es. https://ha.example.org" } } diff --git a/ha-home/config.json b/ha-home/config.json index d2f8d37..04bf464 100644 --- a/ha-home/config.json +++ b/ha-home/config.json @@ -24,8 +24,22 @@ }, "rooms": [ { - "id": "living_room", - "name": "Living Room", + "id": "home", + "name": "Home", + "background": "living-room.jpg", + "sensors": [ + "sensor.living_room_thermostat_temperature", + "sensor.living_room_thermostat_humidity" + ], + "cards": [ + { "type": "weather", "entity_id": "weather.forecast_home", "name": "Weather" }, + { "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" }, + { "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" } + ] + }, + { + "id": "living_area", + "name": "Living Area", "background": "living-room.jpg", "sensors": [ "sensor.living_room_thermostat_temperature", @@ -40,8 +54,16 @@ { "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" }, { "type": "toggle", "entity_id": "light.picture_frame_lamp", "name": "Picture Lamp" }, { "type": "curtain", "entity_id": "cover.living_room_curtain", "name": "Curtain" }, - { "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" }, - { "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" } + { "type": "range", "entity_id": "light.dining_room_light", "name": "Dining Light" }, + { "type": "toggle", "entity_id": "light.entrance_light_1", "name": "Entrance" }, + { "type": "toggle", "entity_id": "light.entrance_mirror_light", "name": "Mirror" }, + { "type": "action", "entity_id": "scene.tv_mode", "name": "TV Mode" }, + { "type": "action", "entity_id": "scene.living_room_100", "name": "Living Room 100%" }, + { "type": "action", "entity_id": "scene.living_room_lights_10", "name": "Living Room 10%" }, + { "type": "action", "entity_id": "script.tv_ambient_on", "name": "TV Ambient On" }, + { "type": "action", "entity_id": "script.tv_ambient_off", "name": "TV Ambient Off" }, + { "type": "action", "entity_id": "script.living_dining_all_off", "name": "All Lights Off" }, + { "type": "action", "entity_id": "scene.curtains_close", "name": "Close Curtains" } ] }, { @@ -66,26 +88,9 @@ { "type": "climate", "entity_id": "climate.main_bedroom_radiator", "name": "Radiator" }, { "type": "toggle", "entity_id": "light.bedroom_ceiling_light", "name": "Ceiling" }, { "type": "toggle", "entity_id": "light.bedroom_desk_lights", "name": "Desk" }, - { "type": "range", "entity_id": "light.bedroom_led_strip", "name": "LED Strip" } - ] - }, - { - "id": "dining_room", - "name": "Dining Room", - "background": "dining-room.jpg", - "sensors": [], - "cards": [ - { "type": "range", "entity_id": "light.dining_room_light", "name": "Light" } - ] - }, - { - "id": "entrance", - "name": "Entrance", - "background": "entrance.jpg", - "sensors": [], - "cards": [ - { "type": "toggle", "entity_id": "light.entrance_light_1", "name": "Light" }, - { "type": "toggle", "entity_id": "light.entrance_mirror_light", "name": "Mirror" } + { "type": "range", "entity_id": "light.bedroom_led_strip", "name": "LED Strip" }, + { "type": "action", "entity_id": "scene.desk_mac", "name": "Desk: Mac" }, + { "type": "action", "entity_id": "scene.desk_pc", "name": "Desk: PC" } ] }, { @@ -127,23 +132,6 @@ { "type": "toggle", "entity_id": "switch.washing_machine_power", "name": "Washing Machine" }, { "type": "toggle", "entity_id": "switch.dryer_power", "name": "Dryer" } ] - }, - { - "id": "scenes", - "name": "Scenes", - "background": "scenes.jpg", - "sensors": [], - "cards": [ - { "type": "action", "entity_id": "scene.tv_mode", "name": "TV Mode" }, - { "type": "action", "entity_id": "scene.living_room_100", "name": "Living Room 100%" }, - { "type": "action", "entity_id": "scene.living_room_lights_10", "name": "Living Room 10%" }, - { "type": "action", "entity_id": "scene.desk_mac", "name": "Desk: Mac" }, - { "type": "action", "entity_id": "scene.desk_pc", "name": "Desk: PC" }, - { "type": "action", "entity_id": "scene.curtains_close", "name": "Close Curtains" }, - { "type": "action", "entity_id": "script.tv_ambient_on", "name": "TV Ambient On" }, - { "type": "action", "entity_id": "script.tv_ambient_off", "name": "TV Ambient Off" }, - { "type": "action", "entity_id": "script.living_dining_all_off", "name": "All Lights Off" } - ] } ] } diff --git a/src/ha_client.rs b/src/ha_client.rs index 90a8445..bccbe03 100644 --- a/src/ha_client.rs +++ b/src/ha_client.rs @@ -45,6 +45,7 @@ pub enum HaResponse { id: u64, success: bool, }, + Config(Value), } pub enum HaCommand { @@ -59,6 +60,8 @@ pub enum HaCommand { entity_id: String, hours: u32, }, + RefreshStates, + FetchConfig, } // ── Config ──────────────────────────────────────────────────────────── @@ -115,13 +118,13 @@ impl Drop for HaService { // ── Worker thread ───────────────────────────────────────────────────── fn worker_loop( - config: HaConfig, + mut config: HaConfig, responses: mpsc::Sender, commands: mpsc::Receiver, closed: Arc, ) { while !closed.load(Ordering::SeqCst) { - match connect_and_run(&config, &responses, &commands, &closed) { + match connect_and_run(&mut config, &responses, &commands, &closed) { Ok(()) => break, Err(e) => { eprintln!("HA WebSocket: {e}"); @@ -136,7 +139,7 @@ fn worker_loop( } fn connect_and_run( - config: &HaConfig, + config: &mut HaConfig, responses: &mpsc::Sender, commands: &mpsc::Receiver, closed: &Arc, @@ -206,9 +209,18 @@ fn connect_and_run( }), )?; - // Pending service call / history requests mapped by HA message id + // Phase 5: fetch luma/get_config for dashboard layout + let config_id = next_id(); + ws_send( + &mut ws, + &json!({ "id": config_id, "type": "luma/get_config" }), + )?; + + // Pending service call / history / refresh 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_refresh: Option = None; + let mut pending_config: Option = Some(config_id); let mut last_ping = Instant::now(); @@ -242,6 +254,19 @@ fn connect_and_run( }), )?; } + HaCommand::RefreshStates => { + let ha_id = next_id(); + ws_send(&mut ws, &json!({ "id": ha_id, "type": "get_states" }))?; + pending_refresh = Some(ha_id); + } + HaCommand::FetchConfig => { + let ha_id = next_id(); + ws_send( + &mut ws, + &json!({ "id": ha_id, "type": "luma/get_config" }), + )?; + pending_config = Some(ha_id); + } HaCommand::FetchHistory { entity_id, hours } => { let ha_id = next_id(); pending_history.insert(ha_id, entity_id.clone()); @@ -305,7 +330,8 @@ fn connect_and_run( let id = value.get("id").and_then(Value::as_u64).unwrap_or(0); match msg_type { - "result" if id == states_id => { + "result" if id == states_id || pending_refresh == Some(id) => { + pending_refresh = pending_refresh.filter(|&r| r != id); if let Some(result) = value.get("result").and_then(Value::as_array) { let states: Vec = result .iter() @@ -349,6 +375,21 @@ fn connect_and_run( }); } } + "result" if pending_config == Some(id) => { + pending_config = None; + if value.get("success").and_then(Value::as_bool) == Some(true) { + if let Some(result) = value.get("result") { + let new_ids = entity_ids_from_v2_config(result); + config.entity_ids = new_ids; + if !config.entity_ids.is_empty() { + let refresh_id = next_id(); + ws_send(&mut ws, &json!({ "id": refresh_id, "type": "get_states" }))?; + pending_refresh = Some(refresh_id); + } + let _ = responses.send(HaResponse::Config(result.clone())); + } + } + } "pong" => {} _ => {} } @@ -419,51 +460,24 @@ fn parse_statistics(value: &Value, entity_id: &str) -> Vec { .collect() } -/// Collect all entity IDs referenced in the HA config JSON. -pub fn entity_ids_from_config(config: &Value) -> Vec { +/// 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(); - - if let Some(weather_id) = config - .get("weather") - .and_then(|w| w.get("entity_id")) - .and_then(Value::as_str) - { - ids.push(weather_id.to_owned()); - } - - if let Some(vacuum) = config.get("vacuum") { - if let Some(id) = vacuum.get("entity_id").and_then(Value::as_str) { - ids.push(id.to_owned()); - } - if let Some(id) = vacuum.get("cleaning_mode_entity").and_then(Value::as_str) { - ids.push(id.to_owned()); - } - } - - if let Some(media) = config.get("media").and_then(|m| m.get("entity_id")).and_then(Value::as_str) { - ids.push(media.to_owned()); - } - if let Some(rooms) = config.get("rooms").and_then(Value::as_array) { for room in rooms { - if let Some(sensors) = room.get("sensors").and_then(Value::as_array) { - for sensor in sensors { - if let Some(id) = sensor.as_str() { - ids.push(id.to_owned()); - } - } - } if let Some(cards) = room.get("cards").and_then(Value::as_array) { for card in cards { if let Some(id) = card.get("entity_id").and_then(Value::as_str) { - ids.push(id.to_owned()); + if !id.is_empty() { + ids.push(id.to_owned()); + } } } } } } - ids.sort(); ids.dedup(); ids } + diff --git a/src/ha_pairing.rs b/src/ha_pairing.rs new file mode 100644 index 0000000..17862f0 --- /dev/null +++ b/src/ha_pairing.rs @@ -0,0 +1,155 @@ +//! Background pairing service: generates a PIN, polls the HA pairing endpoint +//! until the user provisions a token from the web pairing page. +//! +//! Flow: +//! 1. TV shows a 6-digit PIN on the Settings screen +//! 2. User visits `https://{ha_host}/local/luma-pair.html`, enters PIN + token +//! 3. The web page calls `luma/pair` WS command → HA writes `{pin}.json` +//! 4. This service polls `https://{ha_host}/local/luma-pairing/{pin}.json` +//! 5. When found, sends `PairingResult` back to the main thread + +use crate::ha_token::HaCredentials; +use std::{ + io::Read, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::Duration, +}; + +const POLL_INTERVAL: Duration = Duration::from_secs(3); +const POLL_TIMEOUT: Duration = Duration::from_secs(5); + +pub enum PairingResponse { + Paired(HaCredentials), +} + +pub struct PairingService { + pub pin: String, + pub receiver: mpsc::Receiver, + cancelled: Arc, +} + +impl PairingService { + pub fn start(ha_host: &str) -> Self { + let pin = generate_pin(); + let (tx, rx) = mpsc::channel(); + let cancelled = Arc::new(AtomicBool::new(false)); + let cancelled_flag = cancelled.clone(); + let url = format!( + "{}/local/luma-pairing/{}.json", + ha_host.trim_end_matches('/'), + pin + ); + let ha_host = ha_host.to_owned(); + + thread::spawn(move || { + poll_loop(url, ha_host, tx, cancelled_flag); + }); + + Self { + pin, + receiver: rx, + cancelled, + } + } + + pub fn cancel(&mut self) { + self.cancelled.store(true, Ordering::SeqCst); + } +} + +impl Drop for PairingService { + fn drop(&mut self) { + self.cancelled.store(true, Ordering::SeqCst); + // Don't join — the worker thread will exit on its next poll check. + // Joining would block the render loop for up to POLL_TIMEOUT (5s). + } +} + +fn poll_loop( + url: String, + ha_host: String, + tx: mpsc::Sender, + cancelled: Arc, +) { + let agent = ureq::Agent::config_builder() + .timeout_global(Some(POLL_TIMEOUT)) + .max_redirects(0) + .proxy(None) + .build() + .new_agent(); + while !cancelled.load(Ordering::SeqCst) { + match agent.get(&url).call() { + Ok(mut response) => { + if response.status().is_success() { + let mut bytes = Vec::new(); + if response + .body_mut() + .as_reader() + .take(8192) + .read_to_end(&mut bytes) + .is_ok() + { + if let Ok(value) = + serde_json::from_slice::(&bytes) + { + let token = value + .get("token") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let ha_url = value + .get("ha_url") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()) + .unwrap_or_else(|| ws_url_from_host(&ha_host)); + if !token.is_empty() { + let _ = tx.send(PairingResponse::Paired(HaCredentials { + ha_url, + token, + })); + return; + } + } + } + } + } + Err(e) => { + let is_404 = matches!(&e, ureq::Error::StatusCode(404)); + if !is_404 { + eprintln!("Pairing poll error: {e}"); + } + } + } + thread::sleep(POLL_INTERVAL); + } +} + +fn generate_pin() -> String { + let mut buf = [0u8; 4]; + getrandom::getrandom(&mut buf).unwrap_or_else(|_| { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut h = DefaultHasher::new(); + std::time::SystemTime::now().hash(&mut h); + std::process::id().hash(&mut h); + buf = (h.finish() as u32).to_le_bytes(); + }); + format!("{:06}", u32::from_le_bytes(buf) % 1_000_000) +} + +fn ws_url_from_host(host: &str) -> String { + let host = host.trim_end_matches('/'); + if host.starts_with("https://") { + format!("wss://{}/api/websocket", &host[8..]) + } else if host.starts_with("http://") { + format!("ws://{}/api/websocket", &host[7..]) + } else { + format!("wss://{host}/api/websocket") + } +} diff --git a/src/ha_token.rs b/src/ha_token.rs new file mode 100644 index 0000000..70b8dad --- /dev/null +++ b/src/ha_token.rs @@ -0,0 +1,53 @@ +//! Persistent storage for Home Assistant connection credentials. +//! +//! The token file lives next to preferences on the TV's persistent volume +//! or in the desktop config directory. It is a small JSON object with +//! `ha_url` (the WebSocket endpoint) and `token` (a long-lived access token). + +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct HaCredentials { + pub ha_url: String, + pub token: String, +} + +const FILENAME: &str = "ha-token"; + +pub fn path(preferences: &Path) -> PathBuf { + preferences + .parent() + .map(|dir| dir.join(FILENAME)) + .unwrap_or_else(|| PathBuf::from(FILENAME)) +} + +pub fn load(path: &Path) -> Option { + let bytes = fs::read(path).ok()?; + let creds: HaCredentials = serde_json::from_slice(&bytes).ok()?; + if creds.ha_url.is_empty() || creds.token.is_empty() { + return None; + } + Some(creds) +} + +pub fn save(path: &Path, creds: &HaCredentials) -> Result<(), String> { + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent) + .map_err(|e| format!("Could not create token directory {}: {e}", parent.display()))?; + } + let tmp = path.with_extension("tmp"); + let bytes = serde_json::to_vec_pretty(creds) + .map_err(|e| format!("Could not encode HA credentials: {e}"))?; + fs::write(&tmp, bytes) + .map_err(|e| format!("Could not write token file {}: {e}", tmp.display()))?; + fs::rename(&tmp, path) + .map_err(|e| format!("Could not replace token file {}: {e}", path.display())) +} + +pub fn remove(path: &Path) { + let _ = fs::remove_file(path); +} diff --git a/src/main.rs b/src/main.rs index 6590256..39512ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,8 @@ mod branding; mod diagnostics; mod font; mod ha_client; +mod ha_pairing; +mod ha_token; mod i18n; #[cfg(all(feature = "webos", target_os = "linux", target_arch = "arm"))] mod luna; @@ -178,7 +180,9 @@ fn effects( platform: &Platform, preferences: &std::path::Path, weather: &weather_service::Service, - ha: Option<&ha_client::HaService>, + ha_service: &mut Option, + ha_pairing: &mut Option, + ha_token_path: &std::path::Path, ) -> bool { for effect in effects { match effect { @@ -227,15 +231,75 @@ fn effects( Effect::Quit => return false, Effect::Weather(command) => weather.send(command), Effect::Ha(ha_effect) => { - if let Some(service) = ha { + if let Some(service) = ha_service.as_ref() { ha_dispatch(service, ha_effect); } } + Effect::HaPairStart(url) => { + 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; + *ha_pairing = Some(service); + } + Effect::HaDisconnect => { + *ha_service = None; + if let Some(p) = ha_pairing.as_mut() { + p.cancel(); + } + *ha_pairing = None; + state.ha_pairing_pin = None; + ha_token::remove(ha_token_path); + state.dirty = true; + } } } true } + +fn pairing_tick( + state: &mut State, + ha_pairing: &mut Option, + ha_service: &mut Option, + ha_token_path: &std::path::Path, +) { + // Sync service lifetime to state: if the subpage was closed (pin cleared) + // but the service is still alive, cancel it now. + if state.ha_pairing_pin.is_none() { + if ha_pairing.is_some() { + *ha_pairing = None; // Drop triggers cancel flag + } + return; + } + let Some(service) = ha_pairing.as_ref() else { return }; + match service.receiver.try_recv() { + Ok(ha_pairing::PairingResponse::Paired(creds)) => { + eprintln!("Pairing successful, saving token"); + if let Err(e) = ha_token::save(ha_token_path, &creds) { + eprintln!("Failed to save HA token: {e}"); + state.notify("Token save failed"); + return; + } + state.ha_paired = true; + state.ha_pairing_pin = None; + state.subpage = None; + state.focus = state::Target::HaPairDisconnect; + let config = ha_client::HaConfig { + url: creds.ha_url, + token: creds.token, + entity_ids: vec![], + }; + *ha_pairing = None; + *ha_service = Some(ha_client::HaService::new(config)); + state.dirty = 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); @@ -503,6 +567,7 @@ fn ha_tick(state: &mut State, service: &ha_client::HaService) { } ha_client::HaResponse::Disconnected(_) => { state.ha.connected = false; + state.ha_paired = false; state.dirty = true; } ha_client::HaResponse::AllStates(entities) => { @@ -536,6 +601,14 @@ fn ha_tick(state: &mut State, service: &ha_client::HaService) { ha_client::HaResponse::ServiceResult { .. } => { state.dirty = true; } + ha_client::HaResponse::Config(config) => { + state.ha.config = state::HaTabConfig::parse(&config); + eprintln!( + "HA config loaded: {} rooms", + state.ha.config.rooms.len() + ); + state.dirty = true; + } } } } @@ -671,35 +744,20 @@ fn run() -> Result<(), String> { weather_service::Client::new(options.weather_test_server.as_deref())?, weather_cache, ); - let ha_config_path = if webos { - PathBuf::from("/media/developer/apps/usr/palm/applications/com.ecklf.luma/ha-home/config.json") + let ha_token_path = ha_token::path(&preferences_path); + let mut ha_service = if let Some(creds) = ha_token::load(&ha_token_path) { + 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, + entity_ids: vec![], + })) } else { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("ha-home/config.json") - }; - 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() { - let entity_ids = ha_client::entity_ids_from_config(&config_value); - Some(ha_client::HaService::new(ha_client::HaConfig { - url, - token, - entity_ids, - })) - } else { - eprintln!("HA config: missing url or token, HA tab disabled"); - None - } - } else { - eprintln!("HA config: invalid JSON at {}", ha_config_path.display()); - None - } - } else { - eprintln!("HA config not found at {}, HA tab disabled", ha_config_path.display()); + eprintln!("No HA token at {}, pairing required", ha_token_path.display()); None }; + let mut ha_pairing: Option = None; let platform = Platform::new( webos, options.preferences.is_none() && options.test_extra_apps == 0, @@ -710,7 +768,9 @@ fn run() -> Result<(), String> { &platform, &preferences_path, &weather_service, - ha_service.as_ref(), + &mut ha_service, + &mut ha_pairing, + &ha_token_path, ); platform.sync_splash(&state.preferences.background); startup_stage("preferences and workers"); @@ -803,6 +863,7 @@ fn run() -> Result<(), String> { let mut pill = initial_nav.items[0].x; let mut pill_width = initial_nav.items[0].w; let mut last_frame = Instant::now(); + let mut last_ha_refresh = Instant::now(); let mut last_minute = None; let mut frame = 0u64; let mut visible = true; @@ -883,7 +944,17 @@ fn run() -> Result<(), String> { weather_tick(&mut state, &weather_service, visible); if let Some(ref ha) = ha_service { ha_tick(&mut state, ha); + if last_ha_refresh.elapsed() >= Duration::from_secs(60) { + ha.send(ha_client::HaCommand::RefreshStates); + last_ha_refresh = Instant::now(); + } } + pairing_tick( + &mut state, + &mut ha_pairing, + &mut ha_service, + &ha_token_path, + ); // Rebuild hit regions before input whenever inventory/layout changed. if state.dirty { scene = compose(&mut state, &assets, pill, pill_width, now); @@ -949,6 +1020,34 @@ fn run() -> Result<(), String> { } if text.is_empty() && matches!(state.overlay, Some(Overlay::SystemKeyboard)) => { state.delete_surrounding_text(start, length); } + // Close HA modals on ANY back-like key — handled before the + // general KeyDown match to guarantee it fires on webOS. + Event::KeyDown { + keycode: Some(key), + repeat: false, + .. + } 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)) => + { + press = None; + if state.ha.slider_open { + state.ha.slider_open = false; + state.dirty = true; + state.focus = state.ha.slider_card_target(); + } else { + state.ha.vacuum_open = false; + state.dirty = true; + state.focus = Target::HaCard( + state.ha.room, + state.ha.config.rooms[state.ha.room] + .cards.iter() + .position(|c| matches!(c.card_type, crate::state::HaCardType::Vacuum)) + .unwrap_or(0), + ); + } + } Event::KeyDown { keycode: Some(key), repeat, @@ -1138,7 +1237,9 @@ fn run() -> Result<(), String> { &platform, &preferences_path, &weather_service, - ha_service.as_ref(), + &mut ha_service, + &mut ha_pairing, + &ha_token_path, ); if press .as_ref() @@ -1182,7 +1283,9 @@ fn run() -> Result<(), String> { &platform, &preferences_path, &weather_service, - ha_service.as_ref(), + &mut ha_service, + &mut ha_pairing, + &ha_token_path, ); } } @@ -1204,7 +1307,9 @@ fn run() -> Result<(), String> { &platform, &preferences_path, &weather_service, - ha_service.as_ref(), + &mut ha_service, + &mut ha_pairing, + &ha_token_path, ); } let keyboard = state.text_input_active(); diff --git a/src/renderer.rs b/src/renderer.rs index 9f4fa18..5c723bb 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -69,6 +69,7 @@ pub enum Draw { TintedImage(String, Rect, f32, bool, Color), AppImage(Arc, Rect, Color), Clip(Option), + ClipRounded(Option<(Rect, f32)>), EdgeFade(Option<[f32; 4]>), } @@ -192,6 +193,7 @@ enum BatchKind { struct Batch { kind: BatchKind, clip: Option, + clip_rounded: Option<(Rect, f32)>, fade: Option<[f32; 4]>, } @@ -199,6 +201,11 @@ pub struct Renderer { program: GLuint, vector_program: GLuint, text_program: GLuint, + u_aa: GLint, + u_texture: GLint, + u_clip_rounded: GLint, + u_clip_radius: GLint, + u_edge_fade: [GLint; 3], buffer: GLuint, vao: GLuint, white: Texture, @@ -213,6 +220,7 @@ pub struct Renderer { vertices: Vec, batches: Vec, clip: Option, + clip_rounded: Option<(Rect, f32)>, fade: Option<[f32; 4]>, frame: u64, stats: RenderStats, @@ -308,10 +316,28 @@ impl Renderer { gl::PixelStorei(gl::UNPACK_ALIGNMENT, 1); } let white = texture(1, 1, &[255; 4]); + let (u_aa, u_texture, u_clip_rounded, u_clip_radius, u_edge_fade) = unsafe { + ( + gl::GetUniformLocation(program, c"u_aa".as_ptr()), + gl::GetUniformLocation(program, c"u_texture".as_ptr()), + gl::GetUniformLocation(program, c"u_clip_rounded".as_ptr()), + gl::GetUniformLocation(program, c"u_clip_radius".as_ptr()), + [ + gl::GetUniformLocation(program, c"u_edge_fade".as_ptr()), + gl::GetUniformLocation(vector_program, c"u_edge_fade".as_ptr()), + gl::GetUniformLocation(text_program, c"u_edge_fade".as_ptr()), + ], + ) + }; Ok(Self { program, vector_program, text_program, + u_aa, + u_texture, + u_clip_rounded, + u_clip_radius, + u_edge_fade, buffer, vao, white, @@ -326,6 +352,7 @@ impl Renderer { vertices: Vec::with_capacity(65536), batches: Vec::new(), clip: None, + clip_rounded: None, fade: None, frame: 0, stats: RenderStats::new(Size::HD), @@ -474,7 +501,13 @@ impl Renderer { (Some(a), Some(b)) => a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h, _ => false, }; + let same_clip_rounded = match (batch.clip_rounded, self.clip_rounded) { + (None, None) => true, + (Some((a, ar)), Some((b, br))) => a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h && ar == br, + _ => false, + }; if same_clip + && same_clip_rounded && batch.fade == self.fade && let BatchKind::Paint { texture: previous, @@ -494,6 +527,7 @@ impl Renderer { count: 6, }, clip: self.clip, + clip_rounded: self.clip_rounded, fade: self.fade, }); } @@ -735,6 +769,7 @@ impl Renderer { self.batches.push(Batch { kind: BatchKind::Text { key, origin, tint }, clip: self.clip, + clip_rounded: self.clip_rounded, fade: self.fade, }); } @@ -853,6 +888,7 @@ impl Renderer { tint, }, clip: Some(clip), + clip_rounded: self.clip_rounded, fade: self.fade, }); } @@ -898,6 +934,7 @@ impl Renderer { self.vertices.clear(); self.batches.clear(); self.clip = None; + self.clip_rounded = None; self.fade = None; for command in commands { match command { @@ -936,6 +973,7 @@ impl Renderer { } Draw::AppImage(image, rect, tint) => self.app_image(image, *rect, *tint), Draw::Clip(clip) => self.clip = *clip, + Draw::ClipRounded(cr) => self.clip_rounded = *cr, Draw::EdgeFade(fade) => self.fade = *fade, } } @@ -963,14 +1001,8 @@ impl Renderer { gl::DYNAMIC_DRAW, ); gl::UseProgram(self.program); - gl::Uniform1f( - gl::GetUniformLocation(self.program, c"u_aa".as_ptr()), - 0.85 / scale.max(0.01), - ); - gl::Uniform1i( - gl::GetUniformLocation(self.program, c"u_texture".as_ptr()), - 0, - ); + gl::Uniform1f(self.u_aa, 0.85 / scale.max(0.01)); + gl::Uniform1i(self.u_texture, 0); gl::ActiveTexture(gl::TEXTURE0); let mut paint_bound = false; let mut current_fade = None; @@ -979,15 +1011,10 @@ impl Renderer { // The same cheap alpha mask applies to shapes, textures, // icons and text; the unmasked wallpaper remains beneath. let fade = batch.fade.unwrap_or([0., 0., 0., 0.]); - for program in [self.program, self.vector_program, self.text_program] { + let programs = [self.program, self.vector_program, self.text_program]; + for (program, &loc) in programs.into_iter().zip(&self.u_edge_fade) { gl::UseProgram(program); - gl::Uniform4f( - gl::GetUniformLocation(program, c"u_edge_fade".as_ptr()), - fade[0], - fade[1], - fade[2], - fade[3], - ); + gl::Uniform4f(loc, fade[0], fade[1], fade[2], fade[3]); } current_fade = Some(batch.fade); paint_bound = false; @@ -1027,6 +1054,12 @@ impl Renderer { } paint_bound = true; } + if let Some((cr, cr_r)) = batch.clip_rounded { + gl::Uniform4f(self.u_clip_rounded, cr.x, cr.y, cr.w, cr.h); + gl::Uniform1f(self.u_clip_radius, cr_r); + } else { + gl::Uniform4f(self.u_clip_rounded, 0., 0., 0., 0.); + } gl::BindTexture(gl::TEXTURE_2D, *texture); gl::DrawArrays(gl::TRIANGLES, *start as i32, *count as i32); } diff --git a/src/shaders/paint.frag b/src/shaders/paint.frag index b023158..c286cbb 100644 --- a/src/shaders/paint.frag +++ b/src/shaders/paint.frag @@ -1,19 +1,32 @@ uniform sampler2D u_texture; uniform float u_aa; uniform vec4 u_edge_fade; +uniform vec4 u_clip_rounded; +uniform float u_clip_radius; varying PAINT_PRECISION vec2 v_uv; varying PAINT_PRECISION vec4 v_color; varying PAINT_PRECISION vec4 v_shape; varying PAINT_PRECISION vec2 v_params; varying PAINT_PRECISION float v_logical_y; +varying PAINT_PRECISION vec2 v_pos; float edge_coverage() { if (u_edge_fade.y <= u_edge_fade.x) return 1.0; return smoothstep(u_edge_fade.x, u_edge_fade.y, v_logical_y) * (1.0 - smoothstep(u_edge_fade.z, u_edge_fade.w, v_logical_y)); } +float clip_coverage() { + if (u_clip_rounded.z <= 0.0) return 1.0; + vec2 half_sz = u_clip_rounded.zw * 0.5; + vec2 center = u_clip_rounded.xy + half_sz; + float r = min(u_clip_radius, min(half_sz.x, half_sz.y)); + vec2 p = abs(v_pos - center) - half_sz + vec2(r); + float d = length(max(p, vec2(0.0))) + min(max(p.x, p.y), 0.0) - r; + return 1.0 - smoothstep(-u_aa, u_aa, d); +} void main() { + float cc = clip_coverage(); if (v_params.x < 0.0) { - gl_FragColor = texture2D(u_texture, v_uv) * v_color * vec4(1.0, 1.0, 1.0, edge_coverage()); + gl_FragColor = texture2D(u_texture, v_uv) * v_color * vec4(1.0, 1.0, 1.0, edge_coverage() * cc); return; } float blur = max(-v_params.y, 0.0); @@ -28,5 +41,5 @@ void main() { if (v_params.y > 0.0) { alpha *= smoothstep(-v_params.y - u_aa, -v_params.y, d); } - gl_FragColor = texture2D(u_texture, v_uv) * v_color * vec4(1.0, 1.0, 1.0, alpha * edge_coverage()); + gl_FragColor = texture2D(u_texture, v_uv) * v_color * vec4(1.0, 1.0, 1.0, alpha * edge_coverage() * cc); } diff --git a/src/shaders/paint.vert b/src/shaders/paint.vert index d7a8201..03a3d79 100644 --- a/src/shaders/paint.vert +++ b/src/shaders/paint.vert @@ -8,6 +8,7 @@ varying PAINT_PRECISION vec4 v_color; varying PAINT_PRECISION vec4 v_shape; varying PAINT_PRECISION vec2 v_params; varying PAINT_PRECISION float v_logical_y; +varying PAINT_PRECISION vec2 v_pos; void main() { gl_Position = vec4(a_position.x / 960.0 - 1.0, 1.0 - a_position.y / 540.0, 0.0, 1.0); v_uv = a_uv; @@ -15,4 +16,5 @@ void main() { v_shape = a_shape; v_params = a_params; v_logical_y = a_position.y; + v_pos = a_position; } diff --git a/src/state.rs b/src/state.rs index d7357b8..a75e936 100644 --- a/src/state.rs +++ b/src/state.rs @@ -10,7 +10,8 @@ use std::time::{Duration, Instant}; pub const WEATHER_SECTION: usize = 2; pub const INPUTS_SECTION: usize = 3; pub const SYSTEM_SECTION: usize = 4; -pub const SECTION_COUNT: usize = 5; +pub const HA_SECTION: usize = 5; +pub const SECTION_COUNT: usize = 6; pub const APPS_COLUMNS: usize = 6; pub const HOME_STRIP_LEFT: f32 = 84.; pub const HOME_STRIP_RIGHT: f32 = 1836.; @@ -73,6 +74,9 @@ pub enum Target { HaSliderBrightness, HaSliderColorTemp, HaSliderClose, + HaPairUrl, + HaPairConnect, + HaPairDisconnect, } #[derive(Clone, Copy, Debug, Default)] @@ -122,6 +126,7 @@ pub enum Subpage { WeatherLocation, Temperature, Language, + HaPairing, } #[derive(Clone, Copy, Debug, PartialEq, Serialize)] @@ -130,6 +135,7 @@ pub enum TextField { Location, Name, InputName, + HaUrl, } #[derive(Clone, Copy)] @@ -151,6 +157,8 @@ pub enum Effect { Quit, Weather(weather::Command), Ha(HaEffect), + HaPairStart(String), + HaDisconnect, } #[derive(Debug, PartialEq)] @@ -189,6 +197,7 @@ pub enum HaCardType { Media, Curtain, Blinds, + Weather, } #[derive(Clone, Debug)] @@ -243,6 +252,21 @@ pub struct HaState { pub slider_open: bool, pub slider_entity: String, pub slider_card_type: HaCardType, + pub slider_value: f32, + pub slider_ct_value: u32, +} + +impl HaState { + pub fn slider_card_target(&self) -> Target { + Target::HaCard( + self.room, + self.config.rooms[self.room] + .cards + .iter() + .position(|c| c.entity_id == self.slider_entity) + .unwrap_or(0), + ) + } } #[derive(Clone, Debug)] @@ -303,6 +327,7 @@ impl HaTabConfig { Some("media") => HaCardType::Media, Some("curtain") => HaCardType::Curtain, Some("blinds") => HaCardType::Blinds, + Some("weather") => HaCardType::Weather, _ => continue, }; let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned(); @@ -351,6 +376,9 @@ pub struct State { pub keyboard_field: TextField, pub greeting_name_draft: String, pub input_name: String, + pub ha_url_draft: String, + pub ha_paired: bool, + pub ha_pairing_pin: Option, } impl State { @@ -383,6 +411,9 @@ impl State { keyboard_field: TextField::Apps, greeting_name_draft: String::new(), input_name: String::new(), + ha_url_draft: String::new(), + ha_paired: false, + ha_pairing_pin: None, } } @@ -692,6 +723,13 @@ impl State { .first() .map(|input| Target::InputMode(input.id.clone())) .unwrap_or(Target::Setting(3)), + HA_SECTION => { + if self.ha_paired { + Target::HaPairDisconnect + } else { + Target::HaPairUrl + } + } _ => Target::Setting(0), } } @@ -718,6 +756,10 @@ impl State { Some(Subpage::WeatherLocation) => Target::WeatherLocation, Some(Subpage::Temperature) => Target::WeatherTemperature, Some(Subpage::Language) => Target::Setting(4), + Some(Subpage::HaPairing) => { + self.ha_pairing_pin = None; + Target::HaPairUrl + } None => Target::Sidebar(self.section), }; self.scroll = 0.; @@ -988,6 +1030,9 @@ impl State { | Target::InputMode(_) | Target::WeatherLocation | Target::WeatherTemperature + | Target::HaPairUrl + | Target::HaPairConnect + | Target::HaPairDisconnect ) && matches!(direction, Direction::Right) { return self.activate(); @@ -1281,36 +1326,38 @@ impl State { } pub fn scroll_ha(&mut self, delta: i32) -> Vec { - // Slider modal scroll — adjust the focused slider + // Slider modal — update local value instantly, send HA effect in background if self.ha.slider_open { + self.dirty = true; let eid = self.ha.slider_entity.clone(); match self.focus { Target::HaSliderBrightness => { match self.ha.slider_card_type { HaCardType::Range => { - let brightness = self.ha_entity_brightness(&eid); - let new = (brightness + delta as f32 * 25.5).clamp(0., 255.); + let new = (self.ha.slider_value + delta as f32 * 25.5).clamp(0., 255.); + self.ha.slider_value = new; return vec![Effect::Ha(HaEffect::SetBrightness(eid, new))]; } HaCardType::Climate => { - let temp = self.ha_entity_target_temp(&eid); - let new = temp + delta as f32 * 0.5; + let new = (self.ha.slider_value + delta as f32 * 0.5).clamp(5., 35.); + self.ha.slider_value = new; return vec![Effect::Ha(HaEffect::SetClimateTemp(eid, new))]; } HaCardType::Curtain | HaCardType::Blinds => { - let pos = self.ha_entity_position(&eid); - let new = (pos + delta as f32 * 10.).clamp(0., 100.) as u8; - return vec![Effect::Ha(HaEffect::SetCoverPosition(eid, new))]; + let new = (self.ha.slider_value + delta as f32 * 10.).clamp(0., 100.); + self.ha.slider_value = new; + return vec![Effect::Ha(HaEffect::SetCoverPosition(eid, new as u8))]; } _ => {} } } Target::HaSliderColorTemp => { - let ct = self.ha_entity_color_temp(&eid); let min = self.ha_entity_min_mireds(&eid); let max = self.ha_entity_max_mireds(&eid); let step = ((max - min) / 10).max(1); - let new = (ct as i64 + delta as i64 * step as i64).clamp(min as i64, max as i64) as u32; + let new = (self.ha.slider_ct_value as i64 + delta as i64 * step as i64) + .clamp(min as i64, max as i64) as u32; + self.ha.slider_ct_value = new; return vec![Effect::Ha(HaEffect::SetColorTemp(eid, new))]; } _ => {} @@ -1406,11 +1453,20 @@ impl State { fn ha_slider_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec { let has_color_temp = matches!(self.ha.slider_card_type, HaCardType::Range) && self.ha.entities.get(&self.ha.slider_entity) - .and_then(|e| e.attributes.get("color_temp")) - .is_some(); + .is_some_and(|e| { + e.attributes.get("color_temp").is_some() + || e.attributes.get("supported_color_modes") + .and_then(|v| v.as_array()) + .is_some_and(|modes| modes.iter().any(|m| m.as_str() == Some("color_temp"))) + }); match &self.focus { + Target::HaSliderClose => match direction { + Direction::Down => self.focus(Target::HaSliderBrightness, controls), + _ => {} + }, Target::HaSliderBrightness => match direction { + Direction::Up => self.focus(Target::HaSliderClose, controls), Direction::Down if has_color_temp => { self.focus(Target::HaSliderColorTemp, controls); } @@ -1495,6 +1551,15 @@ impl State { self.dirty = true; return; } + if self.keyboard_field == TextField::HaUrl { + self.ha_url_draft.extend( + text.chars() + .filter(|c| !c.is_control()) + .take(200usize.saturating_sub(self.ha_url_draft.chars().count())), + ); + self.dirty = true; + return; + } self.query.extend( text.chars() .filter(|c| !c.is_control()) @@ -1515,6 +1580,8 @@ impl State { self.greeting_name_draft.pop(); } else if self.keyboard_field == TextField::InputName { self.input_name.pop(); + } else if self.keyboard_field == TextField::HaUrl { + self.ha_url_draft.pop(); } else { self.query.pop(); } @@ -1595,6 +1662,8 @@ impl State { self.greeting_name_draft = query; } else if self.keyboard_field == TextField::InputName { self.input_name = query; + } else if self.keyboard_field == TextField::HaUrl { + self.ha_url_draft = query; } else { self.query = query; self.scroll = 0.; @@ -1607,6 +1676,7 @@ impl State { TextField::Location => &self.weather.query, TextField::Name => &self.greeting_name_draft, TextField::InputName => &self.input_name, + TextField::HaUrl => &self.ha_url_draft, TextField::Apps => &self.query, } } @@ -1630,6 +1700,7 @@ impl State { TextField::Location => Target::WeatherQuery, TextField::Name => Target::GreetingName, TextField::InputName => Target::InputRename, + TextField::HaUrl => Target::HaPairUrl, TextField::Apps => Target::Search, }; self.panel_started = None; @@ -1658,6 +1729,7 @@ impl State { TextField::Location => Target::WeatherSearch, TextField::Name => Target::GreetingName, TextField::InputName => Target::InputRename, + TextField::HaUrl => Target::HaPairConnect, TextField::Apps => Target::Search, } } @@ -1808,17 +1880,29 @@ impl State { HaCardType::Media => { return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))]; } + HaCardType::Weather => {} HaCardType::Range | HaCardType::Climate | HaCardType::Curtain | HaCardType::Blinds => { self.ha.slider_open = true; self.ha.slider_entity = card.entity_id.clone(); self.ha.slider_card_type = card.card_type.clone(); + self.ha.slider_value = match card.card_type { + HaCardType::Range => self.ha_entity_brightness(&card.entity_id), + HaCardType::Climate => self.ha_entity_target_temp(&card.entity_id), + HaCardType::Curtain | HaCardType::Blinds => self.ha_entity_position(&card.entity_id), + _ => 0., + }; + self.ha.slider_ct_value = self.ha_entity_color_temp(&card.entity_id); self.focus = Target::HaSliderBrightness; } } } - Target::HaSliderBrightness | Target::HaSliderColorTemp | Target::HaSliderClose => { + Target::HaSliderBrightness | Target::HaSliderColorTemp => { // sliders adjusted via Left/Right/scroll — OK does nothing } + Target::HaSliderClose => { + self.ha.slider_open = false; + self.focus = self.ha.slider_card_target(); + } Target::HaVacuumRoom(index) => { if index < self.ha.vacuum_selected.len() { self.ha.vacuum_selected[index] = !self.ha.vacuum_selected[index]; @@ -1868,6 +1952,29 @@ impl State { .unwrap_or(0)); } Target::HaVacuumOpen | Target::HaWeather => {} + Target::HaPairUrl => { + self.open_keyboard(TextField::HaUrl); + } + Target::HaPairConnect => { + let url = self.ha_url_draft.trim().to_owned(); + if url.is_empty() { + return vec![]; + } + self.subpage = Some(Subpage::HaPairing); + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + return vec![Effect::HaPairStart(url)]; + } + Target::HaPairDisconnect => { + self.ha_paired = false; + self.ha.config = HaTabConfig::default(); + self.ha.connected = false; + self.ha.entities.clear(); + self.ha_pairing_pin = None; + self.focus = Target::HaPairUrl; + return vec![Effect::HaDisconnect]; + } Target::Sidebar(index) => { self.section = index; self.scroll = 0.; @@ -1970,7 +2077,8 @@ impl State { Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames - | Subpage::Clock, + | Subpage::Clock + | Subpage::HaPairing, ) => { return vec![]; } @@ -2056,6 +2164,7 @@ impl State { TextField::Name => self.greeting_name_draft.clear(), TextField::InputName => self.input_name.clear(), TextField::Apps => self.query.clear(), + TextField::HaUrl => self.ha_url_draft.clear(), }, "Done" => { self.overlay = None; @@ -2076,6 +2185,11 @@ impl State { if self.moving.take().is_some() { return vec![]; } + if matches!(self.focus, Target::HaSliderBrightness | Target::HaSliderColorTemp) { + self.ha.slider_open = false; + self.focus = self.ha.slider_card_target(); + return vec![]; + } if let Some(overlay) = self.overlay.take() { self.panel_started = None; if let Overlay::Options { restore, .. } = overlay { @@ -2098,14 +2212,6 @@ impl State { self.scroll = 0.; return vec![]; } - if self.page == Page::Ha && self.ha.slider_open { - self.ha.slider_open = false; - self.focus = Target::HaCard(self.ha.room, - self.ha.config.rooms[self.ha.room].cards.iter() - .position(|c| c.entity_id == self.ha.slider_entity) - .unwrap_or(0)); - 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 00af35d..584f847 100644 --- a/src/view.rs +++ b/src/view.rs @@ -5,7 +5,7 @@ use crate::{ renderer::{Color, Draw, TextStyle, WHITE}, state::{ APPS_COLUMNS, Control, HOME_TILE_PITCH, HOME_TILE_WIDTH, KEYS, Overlay, Page, Rect, - SYSTEM_SECTION, State, Subpage, Target, TextField, WEATHER_SECTION, + HA_SECTION, SYSTEM_SECTION, State, Subpage, Target, TextField, WEATHER_SECTION, }, weather::{self, Unit}, }; @@ -182,6 +182,18 @@ impl Scene { fn glow(&mut self, rect: Rect, radius: f32, blur: f32, color: Color) { self.draw.push(Draw::Shadow(rect, radius, blur, color)); } + fn clip(&mut self, rect: Rect) { + self.draw.push(Draw::Clip(Some(rect))); + } + fn unclip(&mut self) { + self.draw.push(Draw::Clip(None)); + } + fn clip_rounded(&mut self, rect: Rect, radius: f32) { + self.draw.push(Draw::ClipRounded(Some((rect, radius)))); + } + fn unclip_rounded(&mut self) { + self.draw.push(Draw::ClipRounded(None)); + } fn pill(&mut self, rect: Rect, focused: bool, selected: bool) { self.shadow(rect, rect.h / 2., 18., 4., 0.06); if focused { @@ -231,7 +243,7 @@ impl Scene { [brightness, brightness, brightness, alpha], ); } - Draw::Clip(_) | Draw::EdgeFade(_) => {} + Draw::Clip(_) | Draw::ClipRounded(_) | Draw::EdgeFade(_) => {} } } } @@ -1634,30 +1646,15 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { return; } - // ── Room tabs (horizontal pill strip) ──────────────────────── + // ── Room tabs (text-only, same style as apps category tabs) ── 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 width = assets.width(&room.name, text(16., 400, 0.)) + 35.6; let rect = Rect::new(tab_x, 164.8, width, 42.8); - let focused = state.focus == Target::HaRoom(index); + 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.; + filter(out, state, Target::HaRoom(index), rect, &room.name, selected); + tab_x += width + 7.2; } // ── Connection indicator ───────────────────────────────────── @@ -1667,19 +1664,9 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { } let room = &config.rooms[state.ha.room]; - let mut y = 230. - s; + let 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 (supports 2-row tall media cards) ──────────── + // ── Card grid (supports 2-row tall cards) ──────────────────── let cols = 4usize; let card_gap = 16.; let card_w = (1752. - (cols - 1) as f32 * card_gap) / cols as f32; @@ -1688,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); + let tall = matches!(card.card_type, HaCardType::Media | HaCardType::Weather); let row_span = if tall { 2 } else { 1 }; // Find next available cell that fits @@ -1744,6 +1731,11 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { HaCardType::Blinds => { ha_cover_card(out, state, assets, rect, card, entity, focused, false); } + HaCardType::Weather => { + if let Some(e) = entity { + ha_weather_card(out, state, assets, rect, e, focused); + } + } } out.control(Target::HaCard(state.ha.room, index), rect, true); } @@ -1771,10 +1763,10 @@ fn ha_glass(out: &mut Scene, rect: Rect, focused: bool) { 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 })); + out.shape(rect, 22., c(0x242830, if focused { 0.82 } else { 0.75 })); + out.border(rect, 22., 1., c(0xffffff, if focused { 0.18 } else { 0.10 })); if focused { - out.border(rect.inset(-4.), 26., 2., c(0xffffff, 0.12)); + out.border(rect.inset(-4.), 26., 2., c(0xffffff, 0.10)); } } @@ -1797,6 +1789,7 @@ fn ha_entity_icon(card_type: &crate::state::HaCardType, entity_id: &str) -> &'st HaCardType::Media => "spotify", HaCardType::Curtain => "curtain", HaCardType::Blinds => "curtain", + HaCardType::Weather => "weather-sun", } } @@ -1893,6 +1886,12 @@ fn ha_card_fill(out: &mut Scene, rect: Rect, fill_pct: f32, color: Color) { out.shape(Rect::new(rect.x, rect.y, fill_w, rect.h), radius, color); } +fn card_glow(out: &mut Scene, card: Rect, cx: f32, cy: f32, color: Color) { + out.clip_rounded(card, 22.); + out.glow(Rect::new(cx - 100., cy - 100., 200., 200.), 100., 70., color); + out.unclip_rounded(); +} + // ── Toggle card ────────────────────────────────────────────────── fn ha_toggle_card( @@ -1904,21 +1903,15 @@ fn ha_toggle_card( 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" || e.state == "open"); - // Radial glow from icon (inset clip to hide bleed past rounded corners) + ha_glass(out, rect, focused); + + // Glow inside card, clipped to card bounds if is_on { - let fill_color = ha_fill_color(&card.card_type, entity); - let icon_cx = rect.x + 40.; - let icon_cy = rect.y + 40.; - let glow_size = 80.; - let glow_rect = Rect::new(icon_cx - glow_size, icon_cy - glow_size, glow_size * 2., glow_size * 2.); - out.draw.push(Draw::Clip(Some(rect.inset(6.)))); - out.glow(glow_rect, glow_size, glow_size * 0.8, fill_color); - out.draw.push(Draw::Clip(None)); + card_glow(out, rect, rect.x + 40., rect.y + 40., ha_fill_color(&card.card_type, entity)); } // Icon in tinted circle @@ -1988,7 +1981,6 @@ fn ha_range_card( 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")) @@ -2001,17 +1993,12 @@ fn ha_range_card( c(0xbbbbbb, 0.5) }; - // Radial glow from icon center, clipped to card (inset to hide corner bleed) + ha_glass(out, rect, focused); + + // Glow inside card, clipped to card bounds if is_on && pct > 0 { let glow_alpha = (pct as f32 / 100.).clamp(0.1, 0.45); - let glow_color = [accent[0], accent[1], accent[2], glow_alpha]; - let icon_cx = rect.x + 40.; - let icon_cy = rect.y + 40.; - let glow_size = 80.; - let glow_rect = Rect::new(icon_cx - glow_size, icon_cy - glow_size, glow_size * 2., glow_size * 2.); - out.draw.push(Draw::Clip(Some(rect.inset(6.)))); - out.glow(glow_rect, glow_size, glow_size * 0.8, glow_color); - out.draw.push(Draw::Clip(None)); + card_glow(out, rect, rect.x + 40., rect.y + 40., [accent[0], accent[1], accent[2], glow_alpha]); } // Icon in tinted circle @@ -2074,7 +2061,6 @@ fn ha_climate_card( 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")) @@ -2087,18 +2073,13 @@ fn ha_climate_card( let hvac_mode = entity.map_or("off", |e| e.state.as_str()); let is_active = hvac_mode != "off" && hvac_mode != "unavailable"; - // Radial glow from icon + ha_glass(out, rect, focused); + + // Glow inside card, clipped to card bounds if is_active { let fill_color = ha_fill_color(&card.card_type, entity); let glow_alpha = ((target_temp - 15.) / 20.).clamp(0.1, 0.40) as f32; - let glow_color = [fill_color[0], fill_color[1], fill_color[2], glow_alpha]; - let icon_cx = rect.x + 40.; - let icon_cy = rect.y + 40.; - let glow_size = 80.; - let glow_rect = Rect::new(icon_cx - glow_size, icon_cy - glow_size, glow_size * 2., glow_size * 2.); - out.draw.push(Draw::Clip(Some(rect.inset(6.)))); - out.glow(glow_rect, glow_size, glow_size * 0.8, glow_color); - out.draw.push(Draw::Clip(None)); + card_glow(out, rect, rect.x + 40., rect.y + 40., [fill_color[0], fill_color[1], fill_color[2], glow_alpha]); } // Icon in tinted circle @@ -2388,7 +2369,6 @@ fn ha_media_card( entity: Option<&crate::state::HaEntity>, focused: bool, ) { - ha_glass(out, rect, focused); let accent = ha_state_color(entity); let is_active = entity.is_some_and(|e| e.state == "playing" || e.state == "paused"); @@ -2403,16 +2383,12 @@ fn ha_media_card( let art_x = rect.x + (rect.w - art_size) / 2.; let art_y = rect.y + 24.; - // Radial glow from album art center + 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); - let art_cx = art_x + art_size / 2.; - let art_cy = art_y + art_size / 2.; - let glow_r = art_size; - let glow_rect = Rect::new(art_cx - glow_r, art_cy - glow_r, glow_r * 2., glow_r * 2.); - out.draw.push(Draw::Clip(Some(rect.inset(6.)))); - out.glow(glow_rect, glow_r, glow_r * 0.7, [accent[0], accent[1], accent[2], glow_alpha]); - out.draw.push(Draw::Clip(None)); + card_glow(out, rect, art_x + art_size / 2., art_y + art_size / 2., [accent[0], accent[1], accent[2], glow_alpha]); } out.shape( @@ -2510,40 +2486,38 @@ fn ha_cover_card( focused: bool, is_curtain: bool, ) { - ha_glass(out, rect, focused); let position = entity .and_then(|e| e.attributes.get("current_position")) .and_then(|v| v.as_f64()) .unwrap_or(0.) as f32; let is_open = position > 5.; - // Dark navy overlay when closed (proportional to how closed it is) - let closed_pct = 1. - (position / 100.).clamp(0., 1.); - if closed_pct > 0.05 { - out.shape(rect, 22., c(0x0c1024, 0.5 * closed_pct)); - } + ha_glass(out, rect, focused); - // Daylight radial glow when open + // Large glow clipped inside card if is_open { let open_pct = (position / 100.).clamp(0., 1.); let fill_color = ha_fill_color(&card.card_type, entity); let glow_alpha = (open_pct * fill_color[3]).clamp(0.05, 0.40); let glow_color = [fill_color[0], fill_color[1], fill_color[2], glow_alpha]; - out.draw.push(Draw::Clip(Some(rect.inset(6.)))); + let glow_r = 100.; + out.clip_rounded(rect, 22.); if is_curtain { - // Curtain: glow from center of card let cx = rect.x + rect.w / 2.; let cy = rect.y + rect.h / 2.; - let r = rect.w * 0.5; - out.glow(Rect::new(cx - r, cy - r, r * 2., r * 2.), r, r * 0.7, glow_color); + out.glow(Rect::new(cx - glow_r, cy - glow_r, glow_r * 2., glow_r * 2.), glow_r, glow_r * 0.7, glow_color); } else { - // Blinds: glow from top center let cx = rect.x + rect.w / 2.; - let cy = rect.y; - let r = rect.w * 0.5; - out.glow(Rect::new(cx - r, cy - r, r * 2., r * 2.), r, r * 0.7, glow_color); + let cy = rect.y + 20.; + out.glow(Rect::new(cx - glow_r, cy - glow_r, glow_r * 2., glow_r * 2.), glow_r, glow_r * 0.7, glow_color); } - out.draw.push(Draw::Clip(None)); + out.unclip_rounded(); + } + + // Dark navy overlay when closed (proportional to how closed it is) + let closed_pct = 1. - (position / 100.).clamp(0., 1.); + if closed_pct > 0.05 { + out.shape(rect, 22., c(0x0c1024, 0.5 * closed_pct)); } let accent = if is_open { c(0xdcd2b4, 1.) } else { c(0xbbbbbb, 0.5) }; @@ -2606,9 +2580,11 @@ fn ha_weather_card( assets: &Assets, rect: Rect, entity: &crate::state::HaEntity, + focused: bool, ) { - let focused = _state.focus == Target::HaWeather; + out.clip_rounded(rect, 22.); ha_glass(out, rect, focused); + let condition = entity.state.as_str(); let temp = entity .attributes @@ -2636,46 +2612,55 @@ fn ha_weather_card( "hail" => "weather-drizzle", _ => "weather-sun", }; + + let cx = rect.x + rect.w * 0.5; + // Large icon centered near top out.icon( assets, weather_icon, - Rect::new(rect.x + 24., rect.y + 22., 56., 56.), + Rect::new(cx - 32., rect.y + 24., 64., 64.), c(0xf0d080, 1.), ); + // Large temperature out.text( &format!("{:.0}°", temp), - Rect::new(rect.x + 100., rect.y + 18., 120., 40.), - text(36., 600, -0.04), + Rect::new(rect.x + 16., rect.y + 100., rect.w - 32., 48.), + text(40., 600, -0.04), WHITE, - false, + true, ); + // Condition label out.text( condition, - Rect::new(rect.x + 100., rect.y + 62., 200., 18.), - text(14.4, 400, 0.), + Rect::new(rect.x + 16., rect.y + 156., rect.w - 32., 20.), + text(15.2, 400, 0.), c(0xe8e8e8, 0.60), - false, + true, ); + // Humidity row out.icon( assets, "humidity", - Rect::new(rect.x + 340., rect.y + 34., 20., 20.), + Rect::new(rect.x + 24., rect.y + 200., 20., 20.), c(0x60b0f0, 0.7), ); out.text( &format!("{:.0}%", humidity), - Rect::new(rect.x + 368., rect.y + 36., 80., 20.), + Rect::new(rect.x + 50., rect.y + 200., 80., 20.), text(15.2, 400, 0.), c(0xe8e8e8, 0.60), false, ); + // Wind row out.text( - &format!("{:.0} km/h", wind), - Rect::new(rect.x + 480., rect.y + 36., 120., 20.), + &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), false, ); + + out.unclip_rounded(); } // ── Vacuum modal ───────────────────────────────────────────────── @@ -2847,15 +2832,35 @@ fn ha_slider_modal(out: &mut Scene, state: &State, assets: &Assets) { let entity = state.ha.entities.get(entity_id); let card_type = &state.ha.slider_card_type; + // Subtle dimming behind modal let overlay_rect = Rect::new(0., 0., 1920., 1080.); - out.shape(overlay_rect, 0., c(0x080b12, 0.75)); + out.shape(overlay_rect, 0., c(0x080b12, 0.45)); out.modal = Some(overlay_rect); + // Show color temp slider for any Range light that supports color_temp let has_color_temp = matches!(card_type, HaCardType::Range) - && entity.and_then(|e| e.attributes.get("color_temp")).is_some(); + && entity.is_some_and(|e| { + e.attributes.get("color_temp").is_some() + || e.attributes.get("supported_color_modes") + .and_then(|v| v.as_array()) + .is_some_and(|modes| modes.iter().any(|m| m.as_str() == Some("color_temp"))) + }); let modal_h = if has_color_temp { 340. } else { 220. }; let modal = Rect::new(480., (1080. - modal_h) / 2., 960., modal_h); - ha_glass(out, modal, false); + // Solid dark modal — nearly opaque + out.shadow(modal, 22., 20., 6., 0.25); + out.shape(modal, 22., c(0x1c2028, 0.95)); + out.border(modal, 22., 1., c(0xffffff, 0.10)); + + // X close button (top right) + let close_focused = state.focus == Target::HaSliderClose; + let close_rect = Rect::new(modal.x + modal.w - 56., modal.y + 12., 40., 40.); + out.shape(close_rect, 20., c(0xffffff, if close_focused { 0.18 } else { 0.08 })); + out.icon(assets, "close", close_rect.inset(8.), c(0xeeeeee, 0.73)); + if close_focused { + out.border(close_rect.inset(-2.), 22., 2., WHITE); + } + out.control(Target::HaSliderClose, close_rect, false); // Title let title = match card_type { @@ -2865,7 +2870,6 @@ fn ha_slider_modal(out: &mut Scene, state: &State, assets: &Assets) { HaCardType::Blinds => "Blinds", _ => "Control", }; - // Find card name from config let card_name = state.ha.config.rooms.iter() .flat_map(|r| r.cards.iter()) .find(|c| c.entity_id == *entity_id) @@ -2887,29 +2891,27 @@ fn ha_slider_modal(out: &mut Scene, state: &State, assets: &Assets) { let slider_rect = Rect::new(slider_x, slider_y, slider_w, slider_h); let primary_focused = state.focus == Target::HaSliderBrightness; - out.shape(slider_rect, 16., c(0xffffff, 0.06)); - out.border(slider_rect, 16., 1., c(0xffffff, 0.10)); + out.shape(slider_rect, 16., c(0xffffff, 0.10)); + out.border(slider_rect, 16., 1., c(0xffffff, 0.15)); if primary_focused { out.border(slider_rect.inset(-4.), 20., 2., WHITE); } - // Value bar and label + // Value bar and label — read from local slider state for instant feedback + let val = state.ha.slider_value; let (label, fraction) = match card_type { HaCardType::Range => { - let brightness = state.ha_entity_brightness(entity_id); - let pct = (brightness / 255. * 100.).round() as u8; - (format!("Brightness: {}%", pct), brightness / 255.) + let pct = (val / 255. * 100.).round() as u8; + (format!("Brightness: {}%", pct), val / 255.) } HaCardType::Climate => { - let temp = state.ha_entity_target_temp(entity_id); - let min = 16_f32; - let max = 30_f32; - let frac = ((temp - min) / (max - min)).clamp(0., 1.); - (format!("Temperature: {:.1}°C", temp), frac) + let min = 5_f32; + let max = 35_f32; + let frac = ((val - min) / (max - min)).clamp(0., 1.); + (format!("Temperature: {:.1}°C", val), frac) } HaCardType::Curtain | HaCardType::Blinds => { - let pos = state.ha_entity_position(entity_id); - (format!("Position: {}%", pos.round() as u8), pos / 100.) + (format!("Position: {}%", val.round() as u8), val / 100.) } _ => ("Value".to_string(), 0.), }; @@ -2923,7 +2925,7 @@ fn ha_slider_modal(out: &mut Scene, state: &State, assets: &Assets) { slider_rect.h - bar_inset * 2., ); let accent = ha_entity_accent_color(entity); - let bar_color = [accent[0], accent[1], accent[2], 0.35]; + let bar_color = [accent[0], accent[1], accent[2], 1.0]; out.shape(bar_rect, 12., bar_color); out.text( @@ -2947,7 +2949,7 @@ fn ha_slider_modal(out: &mut Scene, state: &State, assets: &Assets) { out.border(ct_rect.inset(-4.), 20., 2., WHITE); } - let ct = state.ha_entity_color_temp(entity_id); + let ct = state.ha.slider_ct_value; let min_m = state.ha_entity_min_mireds(entity_id); let max_m = state.ha_entity_max_mireds(entity_id); let range = (max_m - min_m).max(1) as f32; @@ -2962,7 +2964,7 @@ fn ha_slider_modal(out: &mut Scene, state: &State, assets: &Assets) { ct_rect.h - bar_inset * 2., ); // Warm amber color for the filled portion - out.shape(ct_bar, 12., c(0xffaa44, 0.35)); + out.shape(ct_bar, 12., c(0xffaa44, 0.75)); out.text( &format!("Color Temp: {}K", kelvin), @@ -2988,9 +2990,16 @@ fn ha_slider_modal(out: &mut Scene, state: &State, assets: &Assets) { fn settings(out: &mut Scene, state: &State, assets: &Assets) { let language = state.preferences.language; let s = state.scroll; - for (index, key) in ["interface", "background", "weather", "inputs", "system"] - .into_iter() - .enumerate() + for (index, key) in [ + "interface", + "background", + "weather", + "inputs", + "system", + "homeAssistant", + ] + .into_iter() + .enumerate() { let label = language.t(key); let rect = Rect::new(160., 164.8 + index as f32 * 84. - s, 403.2, 72.); @@ -3031,6 +3040,7 @@ fn settings(out: &mut Scene, state: &State, assets: &Assets) { state.preferences.input_title(input) }), Some(Subpage::WeatherLocation) => language.t("location"), + Some(Subpage::HaPairing) => language.t("homeAssistant"), Some(Subpage::Temperature) => language.t("temperature"), Some(Subpage::Language) => language.t("language"), None => "", @@ -3108,6 +3118,7 @@ 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), @@ -3310,6 +3321,9 @@ fn settings(out: &mut Scene, state: &State, assets: &Assets) { ); } } + HA_SECTION => { + ha_settings(out, state, assets); + } _ => { for (index, input) in state.inventory.inputs.iter().enumerate() { let mode = state @@ -3379,6 +3393,96 @@ fn settings(out: &mut Scene, state: &State, assets: &Assets) { } } +fn ha_settings(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + let s = state.scroll; + + if state.ha_paired { + let status = if state.ha.connected { + language.t("haConnected") + } else { + "…" + }; + row( + out, + state, + assets, + Row { + target: Target::HaPairDisconnect, + rect: Rect::new(640., 164.8 - s, 1120., 90.7), + title: language.t("haDisconnect"), + icon: Some("power"), + value: Some(status), + description: None, + selected: false, + chevron: false, + }, + ); + } else if let Some(Subpage::HaPairing) = state.subpage { + let pin = state.ha_pairing_pin.as_deref().unwrap_or("------"); + out.text( + language.t("haPairingPin"), + Rect::new(664., 172. - s, 1000., 26.), + text(16.8, 400, 0.).leading(1.5), + c(0xf5f5f5, 0.72), + false, + ); + out.text( + pin, + Rect::new(664., 240. - s, 1000., 80.), + text(64., 700, 0.08), + c(0x7EB8DA, 1.), + false, + ); + out.text( + language.t("haWaiting"), + Rect::new(664., 340. - s, 1000., 26.), + text(16.8, 400, 0.).leading(1.5), + c(0xf5f5f5, 0.48), + false, + ); + } else { + let url_display = if state.text_input_active() && state.keyboard_field == TextField::HaUrl + { + format!("{}|", state.ha_url_draft) + } else if state.ha_url_draft.is_empty() { + language.t("haUrlHint").into() + } else { + state.ha_url_draft.clone() + }; + row( + out, + state, + assets, + Row { + target: Target::HaPairUrl, + rect: Rect::new(640., 164.8 - s, 1120., 90.7), + title: language.t("haServerUrl"), + icon: Some("globe"), + value: None, + description: Some(&url_display), + selected: false, + chevron: true, + }, + ); + row( + out, + state, + assets, + Row { + target: Target::HaPairConnect, + rect: Rect::new(640., 164.8 + 102.7 - s, 1120., 72.), + title: language.t("haConnect"), + icon: Some("link"), + value: None, + description: None, + selected: false, + chevron: false, + }, + ); + } +} + fn greeting_settings(out: &mut Scene, state: &State, assets: &Assets) { let language = state.preferences.language; let description = if state.text_input_active() && state.keyboard_field == TextField::Name { @@ -3900,6 +4004,7 @@ fn keyboard(out: &mut Scene, state: &State, assets: &Assets) { TextField::Name => language.t("greetingName"), TextField::InputName => language.t("inputName"), TextField::Apps => language.t("findApp"), + TextField::HaUrl => language.t("haServerUrl"), }, Rect::new(389., 324., 1000., 44.), text(37.6, 500, -0.055).leading(1.1),