Add WS-based pairing flow, replacing config.json with token file
Replace the static config.json with a dynamic pairing flow: the TV shows a 6-digit PIN, the user enters it on the HA pairing web page, and the TV polls for the provisioned token. On success the token is persisted and the WS connection fetches entity config via luma/get_config. New modules: - ha_token: atomic read/write/remove of HA credentials file - ha_pairing: background HTTP poll service with getrandom PIN generation Key changes: - Settings UI: new HA section with URL entry, connect/disconnect, PIN display - ha_client: WS config fetch (luma/get_config), Option<u64> pending tracking - main.rs: token-based startup, pairing effects consolidated into effects() - renderer: cached GL uniform locations (u_aa, u_texture, u_clip_rounded, u_clip_radius, u_edge_fade) — no more per-batch GetUniformLocation calls - view.rs: extracted card_glow() helper, slider_card_target() method - Pairing thread detached (no join in Drop) to avoid blocking the render loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
483b19f286
commit
ba4a5d2346
13 files changed
+923
-275
No files matched your search
Generated
+1
@@ -358,6 +358,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
name = "luma"
|
||||
version = "2.3.1"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"gl",
|
||||
"image",
|
||||
"libc",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+29
-41
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+51
-37
@@ -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<HaResponse>,
|
||||
commands: mpsc::Receiver<HaCommand>,
|
||||
closed: Arc<AtomicBool>,
|
||||
) {
|
||||
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<HaResponse>,
|
||||
commands: &mpsc::Receiver<HaCommand>,
|
||||
closed: &Arc<AtomicBool>,
|
||||
@@ -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<u64, u64> = HashMap::new(); // ha_id -> caller_id
|
||||
let mut pending_history: HashMap<u64, String> = HashMap::new(); // ha_id -> entity_id
|
||||
let mut pending_refresh: Option<u64> = None;
|
||||
let mut pending_config: Option<u64> = 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<EntityState> = 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<HistoryPoint> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collect all entity IDs referenced in the HA config JSON.
|
||||
pub fn entity_ids_from_config(config: &Value) -> Vec<String> {
|
||||
/// Collect entity IDs from a v2 config (as returned by luma/get_config).
|
||||
pub fn entity_ids_from_v2_config(config: &Value) -> Vec<String> {
|
||||
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) {
|
||||
if !id.is_empty() {
|
||||
ids.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
@@ -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<PairingResponse>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<PairingResponse>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) {
|
||||
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::<serde_json::Value>(&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")
|
||||
}
|
||||
}
|
||||
@@ -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<HaCredentials> {
|
||||
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);
|
||||
}
|
||||
+135
-30
@@ -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_client::HaService>,
|
||||
ha_pairing: &mut Option<ha_pairing::PairingService>,
|
||||
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_pairing::PairingService>,
|
||||
ha_service: &mut Option<ha_client::HaService>,
|
||||
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")
|
||||
} 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::<serde_json::Value>(&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);
|
||||
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,
|
||||
token,
|
||||
entity_ids,
|
||||
url: creds.ha_url,
|
||||
token: creds.token,
|
||||
entity_ids: vec![],
|
||||
}))
|
||||
} 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<ha_pairing::PairingService> = 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();
|
||||
|
||||
+49
-16
@@ -69,6 +69,7 @@ pub enum Draw {
|
||||
TintedImage(String, Rect, f32, bool, Color),
|
||||
AppImage(Arc<crate::app_artwork::Artwork>, Rect, Color),
|
||||
Clip(Option<Rect>),
|
||||
ClipRounded(Option<(Rect, f32)>),
|
||||
EdgeFade(Option<[f32; 4]>),
|
||||
}
|
||||
|
||||
@@ -192,6 +193,7 @@ enum BatchKind {
|
||||
struct Batch {
|
||||
kind: BatchKind,
|
||||
clip: Option<Rect>,
|
||||
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<f32>,
|
||||
batches: Vec<Batch>,
|
||||
clip: Option<Rect>,
|
||||
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);
|
||||
}
|
||||
|
||||
+15
-2
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+129
-23
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<Effect> {
|
||||
// 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<Effect> {
|
||||
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,
|
||||
|
||||
+231
-126
@@ -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,7 +2990,14 @@ 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"]
|
||||
for (index, key) in [
|
||||
"interface",
|
||||
"background",
|
||||
"weather",
|
||||
"inputs",
|
||||
"system",
|
||||
"homeAssistant",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user