Redesign media card with album art, 7-button transport, and Wolf Kit icons
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s

Replace media card with full redesign: rectangular album art (180px) with
background fetcher, track title + artist display, volume icon + percentage,
progress bar, and 7-button transport row (playlist, skip-back, seek-back,
play/pause, seek-forward, skip-forward, search) with Apple-style opacity
focus. Add scroll-wheel seek and back-button card mode exit.

Replace all media SVG icons with Wolf Kit Rounded Line icons from SVGRepo
(CC Attribution, author: thewolfkit): play, pause, skip-back, skip-forward,
seek-back, seek-forward, playlist (line-3), search, volume.

Fix vacuum card: add config fallback for custom button cards, expand entity
subscriptions for derived entities, fix clean command segment format, remove
fill, increase image size to 160px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-25 22:39:40 +02:00
1 parent ba4a5d2346
commit bde779d759
15 files changed
+1182 -216

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
<svg width="128" height="128" color="#fff" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 5C4 3.89543 4.89543 3 6 3H9C10.1046 3 11 3.89543 11 5V19C11 20.1046 10.1046 21 9 21H6C4.89543 21 4 20.1046 4 19V5ZM9 5H6V19H9V5Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 5C13 3.89543 13.8954 3 15 3H18C19.1046 3 20 3.89543 20 5V19C20 20.1046 19.1046 21 18 21H15C13.8954 21 13 20.1046 13 19V5ZM18 5H15V19H18V5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 537 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="128" height="128" color="#fff" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 4.83164C4 3.24931 5.75049 2.29363 7.08152 3.14928L18.2323 10.3176C19.4569 11.1049 19.4569 12.8951 18.2323 13.6823L7.08152 20.8507C5.75049 21.7063 4 20.7506 4 19.1683V4.83164ZM17.1507 12L6 4.83164V19.1683L17.1507 12Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 409 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="128" height="128" color="#fff" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6C2 5.44772 2.44772 5 3 5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H3C2.44772 7 2 6.55228 2 6Z" fill="currentColor"/>
<path d="M2 12C2 11.4477 2.44772 11 3 11H21C21.5523 11 22 11.4477 22 12C22 12.5523 21.5523 13 21 13H3C2.44772 13 2 12.5523 2 12Z" fill="currentColor"/>
<path d="M3 17C2.44772 17 2 17.4477 2 18C2 18.5523 2.44772 19 3 19H21C21.5523 19 22 18.5523 22 18C22 17.4477 21.5523 17 21 17H3Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 565 B

+3 -1
View File
@@ -1 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg> <svg width="128" height="128" color="#fff" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 1C5.02944 1 1 5.02944 1 10C1 14.9706 5.02944 19 10 19C12.125 19 14.078 18.2635 15.6177 17.0319L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L17.0319 15.6177C18.2635 14.078 19 12.125 19 10C19 5.02944 14.9706 1 10 1ZM3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 583 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="128" height="128" color="#fff" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.57909 4.83828C10.5644 4.07194 12 4.77409 12 6.02231V12V17.9777C12 19.2259 10.5644 19.928 9.57909 19.1617L1.38606 12.7893C1.14247 12.5999 1 12.3086 1 12C1 11.6914 1.14247 11.4001 1.38606 11.2106L9.57909 4.83828ZM3.62882 12L10 16.9553V7.04463L3.62882 12Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 12C12 12.3086 12.1425 12.5999 12.3861 12.7893L20.5791 19.1617C21.5644 19.928 23 19.2259 23 17.9777V6.02231C23 4.77409 21.5644 4.07194 20.5791 4.83828L12.3861 11.2106C12.1425 11.4001 12 11.6914 12 12ZM14.6288 12L21 16.9553V7.04463L14.6288 12Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="128" height="128" color="#fff" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.42091 4.83828C2.43562 4.07194 1 4.77409 1 6.02231V17.9777C1 19.2259 2.43562 19.928 3.42091 19.1617L11.6139 12.7893C11.8575 12.5999 12 12.3086 12 12V17.9777C12 19.2259 13.4356 19.928 14.4209 19.1617L22.6139 12.7893C22.8575 12.5999 23 12.3086 23 12C23 11.6914 22.8575 11.4001 22.6139 11.2106L14.4209 4.83828C13.4356 4.07194 12 4.77409 12 6.02231V12C12 11.6914 11.8575 11.4001 11.6139 11.2106L3.42091 4.83828ZM9.37118 12L3 16.9553V7.04463L9.37118 12ZM20.3712 12L14 16.9553V7.04463L20.3712 12Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 683 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="128" height="128" color="#fff" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.8906 4.20444C19.2197 3.31836 21 4.27115 21 5.86854V18.1315C21 19.7289 19.2197 20.6817 17.8906 19.7956L8.69337 13.6641C7.50591 12.8725 7.50591 11.1276 8.69337 10.3359L17.8906 4.20444ZM19 5.86854L9.80277 12L19 18.1315V5.86854Z" fill="currentColor"/>
<path d="M5 5C5 4.44772 4.55228 4 4 4C3.44772 4 3 4.44772 3 5V19C3 19.5523 3.44772 20 4 20C4.55228 20 5 19.5523 5 19V5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="128" height="128" color="#fff" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.1094 4.20444C4.78029 3.31836 3 4.27115 3 5.86854V18.1315C3 19.7289 4.78029 20.6817 6.1094 19.7956L15.3066 13.6641C16.4941 12.8725 16.4941 11.1276 15.3066 10.3359L6.1094 4.20444ZM5 5.86854L14.1972 12L5 18.1315V5.86854Z" fill="currentColor"/>
<path d="M21 5C21 4.44772 20.5523 4 20 4C19.4477 4 19 4.44772 19 5V19C19 19.5523 19.4477 20 20 20C20.5523 20 21 19.5523 21 19V5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="128" height="128" color="#fff" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 3.7446C13 3.27314 12.8728 2.50021 12.1657 2.14424C11.4151 1.76635 10.7163 2.19354 10.3623 2.51158L4.94661 7.43717H3C1.89543 7.43717 1 8.3326 1 9.43717L1.00001 14.6248C1.00001 15.7293 1.89544 16.6248 3.00001 16.6248H4.95001L10.3623 21.4891C10.7175 21.8081 11.416 22.2331 12.1656 21.8554C12.8717 21.4998 13 20.7286 13 20.2561V3.7446ZM6.00618 9.17696L11 4.63507V19.3731L6.00179 14.881C5.81823 14.716 5.58015 14.6248 5.33334 14.6248H3.00001L3 9.43717H5.33334C5.58219 9.43717 5.82209 9.34439 6.00618 9.17696Z" fill="currentColor"/>
<path d="M16.2865 8.04192C15.7573 7.88372 15.2001 8.18443 15.0419 8.71357C14.8837 9.24271 15.1844 9.79992 15.7136 9.95812C16.3702 10.1544 17 10.9209 17 12C17 13.0791 16.3702 13.8456 15.7136 14.0419C15.1844 14.2001 14.8837 14.7573 15.0419 15.2865C15.2001 15.8156 15.7573 16.1163 16.2865 15.9581C17.9301 15.4667 19 13.8076 19 12C19 10.1924 17.9301 8.53333 16.2865 8.04192Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+150
View File
@@ -0,0 +1,150 @@
//! Background album-art fetcher for media_player entities.
//! Downloads `entity_picture` URLs from HA and saves them as local JPEG files.
//! The view layer renders from the local path.
use std::{
collections::HashMap,
fs,
io::Read,
sync::mpsc,
thread,
time::Duration,
};
pub struct AlbumArtService {
ready_rx: mpsc::Receiver<AlbumArtReady>,
fetch_tx: mpsc::Sender<FetchRequest>,
known_urls: HashMap<String, String>,
}
pub struct AlbumArtReady {
pub entity_id: String,
pub path: String,
}
struct FetchRequest {
entity_id: String,
url: String,
token: String,
save_path: String,
}
const ART_DIR: &str = "/tmp/luma-album-art";
const MAX_BYTES: u64 = 2 * 1024 * 1024;
impl AlbumArtService {
pub fn new() -> Self {
let (fetch_tx, fetch_rx) = mpsc::channel::<FetchRequest>();
let (ready_tx, ready_rx) = mpsc::channel::<AlbumArtReady>();
fs::create_dir_all(ART_DIR).ok();
thread::spawn(move || {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(10)))
.max_redirects(3)
.proxy(None)
.build()
.new_agent();
for request in fetch_rx {
let tx = ready_tx.clone();
let agent = agent.clone();
thread::spawn(move || {
match fetch_and_save(&agent, &request) {
Ok(()) => {
let _ = tx.send(AlbumArtReady {
entity_id: request.entity_id,
path: request.save_path,
});
}
Err(e) => {
eprintln!("Album art fetch failed for {}: {e}", request.entity_id);
}
}
});
}
});
Self {
ready_rx,
fetch_tx,
known_urls: HashMap::new(),
}
}
/// Check if entity_picture changed; if so, start a background fetch.
pub fn update(&mut self, entity_id: &str, picture_url: &str, ha_base_url: &str, token: &str) {
if picture_url.is_empty() {
self.known_urls.remove(entity_id);
return;
}
if self.known_urls.get(entity_id).is_some_and(|u| u == picture_url) {
return;
}
self.known_urls.insert(entity_id.to_owned(), picture_url.to_owned());
let full_url = if picture_url.starts_with("http") {
picture_url.to_owned()
} else {
format!("{}{}", ha_base_url, picture_url)
};
let hash = fnv1a(picture_url);
let save_path = format!("{ART_DIR}/{hash}.jpg");
let _ = self.fetch_tx.send(FetchRequest {
entity_id: entity_id.to_owned(),
url: full_url,
token: token.to_owned(),
save_path,
});
}
pub fn poll(&self) -> Option<AlbumArtReady> {
self.ready_rx.try_recv().ok()
}
}
fn fetch_and_save(agent: &ureq::Agent, req: &FetchRequest) -> Result<(), String> {
if std::path::Path::new(&req.save_path).exists() {
return Ok(());
}
let mut request = agent.get(&req.url);
if !req.token.is_empty() {
request = request.header("Authorization", &format!("Bearer {}", req.token));
}
let mut response = request.call().map_err(|e| format!("HTTP: {e}"))?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
let mut body = Vec::new();
response
.body_mut()
.as_reader()
.take(MAX_BYTES)
.read_to_end(&mut body)
.map_err(|e| format!("read: {e}"))?;
if body.len() < 100 {
return Err(format!("too small: {} bytes", body.len()));
}
let tmp = format!("{}.tmp", req.save_path);
fs::write(&tmp, &body).map_err(|e| format!("write: {e}"))?;
fs::rename(&tmp, &req.save_path).map_err(|e| format!("rename: {e}"))?;
Ok(())
}
fn fnv1a(s: &str) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for b in s.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
format!("{h:016x}")
}
+151 -4
View File
@@ -46,6 +46,34 @@ pub enum HaResponse {
success: bool, success: bool,
}, },
Config(Value), Config(Value),
Forecast {
entity_id: String,
days: Vec<ForecastDay>,
},
HourlyForecast {
entity_id: String,
hours: Vec<ForecastHour>,
},
}
#[derive(Clone, Debug)]
pub struct ForecastDay {
pub date: String,
pub condition: String,
pub temp_high: f64,
pub temp_low: f64,
pub precipitation: f64,
pub humidity: f64,
pub wind_speed: f64,
}
#[derive(Clone, Debug)]
pub struct ForecastHour {
pub datetime: String,
pub condition: String,
pub temperature: f64,
pub humidity: f64,
pub wind_speed: f64,
} }
pub enum HaCommand { pub enum HaCommand {
@@ -62,6 +90,12 @@ pub enum HaCommand {
}, },
RefreshStates, RefreshStates,
FetchConfig, FetchConfig,
FetchForecast {
entity_id: String,
},
FetchHourlyForecast {
entity_id: String,
},
} }
// ── Config ──────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────
@@ -77,7 +111,7 @@ pub struct HaConfig {
pub struct HaService { pub struct HaService {
pub receiver: mpsc::Receiver<HaResponse>, pub receiver: mpsc::Receiver<HaResponse>,
commands: mpsc::Sender<HaCommand>, pub sender: mpsc::Sender<HaCommand>,
closed: Arc<AtomicBool>, closed: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>, worker: Option<thread::JoinHandle<()>>,
} }
@@ -95,14 +129,14 @@ impl HaService {
Self { Self {
receiver: resp_rx, receiver: resp_rx,
commands: cmd_tx, sender: cmd_tx,
closed, closed,
worker: Some(worker), worker: Some(worker),
} }
} }
pub fn send(&self, command: HaCommand) { pub fn send(&self, command: HaCommand) {
let _ = self.commands.send(command); let _ = self.sender.send(command);
} }
} }
@@ -216,9 +250,11 @@ fn connect_and_run(
&json!({ "id": config_id, "type": "luma/get_config" }), &json!({ "id": config_id, "type": "luma/get_config" }),
)?; )?;
// Pending service call / history / refresh requests mapped by HA message id // Pending service call / history / refresh / forecast requests mapped by HA message id
let mut pending_service: HashMap<u64, u64> = HashMap::new(); // ha_id -> caller_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_history: HashMap<u64, String> = HashMap::new(); // ha_id -> entity_id
let mut pending_forecast: HashMap<u64, String> = HashMap::new(); // ha_id -> entity_id (daily)
let mut pending_hourly: HashMap<u64, String> = HashMap::new(); // ha_id -> entity_id (hourly)
let mut pending_refresh: Option<u64> = None; let mut pending_refresh: Option<u64> = None;
let mut pending_config: Option<u64> = Some(config_id); let mut pending_config: Option<u64> = Some(config_id);
@@ -267,6 +303,38 @@ fn connect_and_run(
)?; )?;
pending_config = Some(ha_id); pending_config = Some(ha_id);
} }
HaCommand::FetchForecast { entity_id } => {
let ha_id = next_id();
pending_forecast.insert(ha_id, entity_id.clone());
ws_send(
&mut ws,
&json!({
"id": ha_id,
"type": "call_service",
"domain": "weather",
"service": "get_forecasts",
"target": { "entity_id": entity_id },
"service_data": { "type": "daily" },
"return_response": true,
}),
)?;
}
HaCommand::FetchHourlyForecast { entity_id } => {
let ha_id = next_id();
pending_hourly.insert(ha_id, entity_id.clone());
ws_send(
&mut ws,
&json!({
"id": ha_id,
"type": "call_service",
"domain": "weather",
"service": "get_forecasts",
"target": { "entity_id": entity_id },
"service_data": { "type": "hourly" },
"return_response": true,
}),
)?;
}
HaCommand::FetchHistory { entity_id, hours } => { HaCommand::FetchHistory { entity_id, hours } => {
let ha_id = next_id(); let ha_id = next_id();
pending_history.insert(ha_id, entity_id.clone()); pending_history.insert(ha_id, entity_id.clone());
@@ -375,6 +443,24 @@ fn connect_and_run(
}); });
} }
} }
"result" if pending_forecast.contains_key(&id) => {
if let Some(entity_id) = pending_forecast.remove(&id) {
let days = parse_forecast_response(&value, &entity_id);
let _ = responses.send(HaResponse::Forecast {
entity_id,
days,
});
}
}
"result" if pending_hourly.contains_key(&id) => {
if let Some(entity_id) = pending_hourly.remove(&id) {
let hours = parse_hourly_response(&value, &entity_id);
let _ = responses.send(HaResponse::HourlyForecast {
entity_id,
hours,
});
}
}
"result" if pending_config == Some(id) => { "result" if pending_config == Some(id) => {
pending_config = None; pending_config = None;
if value.get("success").and_then(Value::as_bool) == Some(true) { if value.get("success").and_then(Value::as_bool) == Some(true) {
@@ -460,6 +546,56 @@ fn parse_statistics(value: &Value, entity_id: &str) -> Vec<HistoryPoint> {
.collect() .collect()
} }
fn parse_hourly_response(value: &Value, entity_id: &str) -> Vec<ForecastHour> {
let response = match value.get("result").and_then(|r| r.get("response")) {
Some(r) => r,
None => return vec![],
};
let forecasts = match response.get(entity_id).and_then(|e| e.get("forecast")).and_then(Value::as_array) {
Some(a) => a,
None => return vec![],
};
forecasts
.iter()
.filter_map(|entry| {
Some(ForecastHour {
datetime: entry.get("datetime")?.as_str()?.to_owned(),
condition: entry.get("condition").and_then(Value::as_str).unwrap_or("").to_owned(),
temperature: entry.get("temperature").and_then(Value::as_f64).unwrap_or(0.),
humidity: entry.get("humidity").and_then(Value::as_f64).unwrap_or(0.),
wind_speed: entry.get("wind_speed").and_then(Value::as_f64).unwrap_or(0.),
})
})
.collect()
}
fn parse_forecast_response(value: &Value, entity_id: &str) -> Vec<ForecastDay> {
let response = match value.get("result").and_then(|r| r.get("response")) {
Some(r) => r,
None => return vec![],
};
let forecasts = match response.get(entity_id).and_then(|e| e.get("forecast")).and_then(Value::as_array) {
Some(a) => a,
None => return vec![],
};
forecasts
.iter()
.filter_map(|entry| {
let dt = entry.get("datetime")?.as_str()?;
let date = if dt.len() >= 10 { &dt[..10] } else { dt };
Some(ForecastDay {
date: date.to_owned(),
condition: entry.get("condition").and_then(Value::as_str).unwrap_or("").to_owned(),
temp_high: entry.get("temperature").and_then(Value::as_f64).unwrap_or(0.),
temp_low: entry.get("templow").and_then(Value::as_f64).unwrap_or(0.),
precipitation: entry.get("precipitation").and_then(Value::as_f64).unwrap_or(0.),
humidity: entry.get("humidity").and_then(Value::as_f64).unwrap_or(0.),
wind_speed: entry.get("wind_speed").and_then(Value::as_f64).unwrap_or(0.),
})
})
.collect()
}
/// Collect entity IDs from a v2 config (as returned by luma/get_config). /// Collect entity IDs from a v2 config (as returned by luma/get_config).
pub fn entity_ids_from_v2_config(config: &Value) -> Vec<String> { pub fn entity_ids_from_v2_config(config: &Value) -> Vec<String> {
let mut ids = Vec::new(); let mut ids = Vec::new();
@@ -470,6 +606,17 @@ pub fn entity_ids_from_v2_config(config: &Value) -> Vec<String> {
if let Some(id) = card.get("entity_id").and_then(Value::as_str) { if let Some(id) = card.get("entity_id").and_then(Value::as_str) {
if !id.is_empty() { if !id.is_empty() {
ids.push(id.to_owned()); ids.push(id.to_owned());
// Vacuum cards need auxiliary entities for modes, errors, and actions
if card.get("type").and_then(Value::as_str) == Some("vacuum") {
if let Some(prefix) = id.strip_prefix("vacuum.") {
ids.push(format!("select.{prefix}_cleaning_mode"));
ids.push(format!("sensor.{prefix}_vacuum_error"));
ids.push(format!("button.{prefix}_mop_only"));
ids.push(format!("button.{prefix}_vac_mop_fast"));
ids.push(format!("button.{prefix}_after_meals"));
ids.push(format!("button.{prefix}_vac_mop"));
}
}
} }
} }
} }
+2 -3
View File
@@ -34,8 +34,7 @@ pub struct PairingService {
} }
impl PairingService { impl PairingService {
pub fn start(ha_host: &str) -> Self { pub fn start(ha_host: &str, pin: String) -> Self {
let pin = generate_pin();
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let cancelled = Arc::new(AtomicBool::new(false)); let cancelled = Arc::new(AtomicBool::new(false));
let cancelled_flag = cancelled.clone(); let cancelled_flag = cancelled.clone();
@@ -130,7 +129,7 @@ fn poll_loop(
} }
} }
fn generate_pin() -> String { pub fn generate_pin() -> String {
let mut buf = [0u8; 4]; let mut buf = [0u8; 4];
getrandom::getrandom(&mut buf).unwrap_or_else(|_| { getrandom::getrandom(&mut buf).unwrap_or_else(|_| {
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
+88 -16
View File
@@ -1,3 +1,4 @@
mod album_art;
mod app_artwork; mod app_artwork;
mod branding; mod branding;
mod diagnostics; mod diagnostics;
@@ -173,6 +174,7 @@ const SCRIPT_POINTER: u32 = u32::MAX - 1;
// SDL-webOS's Back scancode is outside rust-sdl2's standard Scancode enum. // SDL-webOS's Back scancode is outside rust-sdl2's standard Scancode enum.
// Keycode retains the raw value: SDL_SCANCODE_TO_KEYCODE(SDL_SCANCODE_WEBOS_BACK). // Keycode retains the raw value: SDL_SCANCODE_TO_KEYCODE(SDL_SCANCODE_WEBOS_BACK).
const WEBOS_BACK_KEYCODE: i32 = (1 << 30) | 482; const WEBOS_BACK_KEYCODE: i32 = (1 << 30) | 482;
const WEBOS_BACK_ALT: i32 = 0x200003;
fn effects( fn effects(
effects: Vec<Effect>, effects: Vec<Effect>,
@@ -235,13 +237,11 @@ fn effects(
ha_dispatch(service, ha_effect); ha_dispatch(service, ha_effect);
} }
} }
Effect::HaPairStart(url) => { Effect::HaPairStart { url, pin } => {
if let Some(p) = ha_pairing.as_mut() { if let Some(p) = ha_pairing.as_mut() {
p.cancel(); p.cancel();
} }
let service = ha_pairing::PairingService::start(&url); let service = ha_pairing::PairingService::start(&url, pin);
state.ha_pairing_pin = Some(service.pin.clone());
state.dirty = true;
*ha_pairing = Some(service); *ha_pairing = Some(service);
} }
Effect::HaDisconnect => { Effect::HaDisconnect => {
@@ -387,7 +387,7 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
target: serde_json::json!({ "entity_id": entity_id }), target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ data: serde_json::json!({
"command": "app_segment_clean", "command": "app_segment_clean",
"params": { "segments": segments, "repeat": 1 } "params": [{ "segments": segments, "repeat": 1 }]
}), }),
}); });
} }
@@ -418,6 +418,15 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
data: serde_json::json!({}), data: serde_json::json!({}),
}); });
} }
HaEffect::MediaSeek(entity_id, position) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "media_player".into(),
service: "media_seek".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "seek_position": position }),
});
}
HaEffect::SetVolume(entity_id, volume) => { HaEffect::SetVolume(entity_id, volume) => {
service.send(ha_client::HaCommand::CallService { service.send(ha_client::HaCommand::CallService {
id: next_id(), id: next_id(),
@@ -445,6 +454,24 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
data: serde_json::json!({ "color_temp": color_temp }), data: serde_json::json!({ "color_temp": color_temp }),
}); });
} }
HaEffect::VacuumDock(entity_id) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "vacuum".into(),
service: "return_to_base".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::ButtonPress(entity_id) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "button".into(),
service: "press".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
} }
} }
@@ -508,6 +535,15 @@ fn script(
}) })
} }
fn ws_to_http_base(ws_url: &str) -> String {
ws_url
.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches("/api/websocket")
.trim_end_matches('/')
.to_owned()
}
fn main() { fn main() {
if let Err(error) = run() { if let Err(error) = run() {
eprintln!("Luma native: {error}"); eprintln!("Luma native: {error}");
@@ -603,10 +639,26 @@ fn ha_tick(state: &mut State, service: &ha_client::HaService) {
} }
ha_client::HaResponse::Config(config) => { ha_client::HaResponse::Config(config) => {
state.ha.config = state::HaTabConfig::parse(&config); state.ha.config = state::HaTabConfig::parse(&config);
eprintln!( for room in &state.ha.config.rooms {
"HA config loaded: {} rooms", for card in &room.cards {
state.ha.config.rooms.len() if card.card_type == state::HaCardType::Weather {
); let _ = service.sender.send(ha_client::HaCommand::FetchForecast {
entity_id: card.entity_id.clone(),
});
let _ = service.sender.send(ha_client::HaCommand::FetchHourlyForecast {
entity_id: card.entity_id.clone(),
});
}
}
}
state.dirty = true;
}
ha_client::HaResponse::Forecast { entity_id, days } => {
state.ha.forecasts.insert(entity_id, days);
state.dirty = true;
}
ha_client::HaResponse::HourlyForecast { entity_id, hours } => {
state.ha.hourly_forecasts.insert(entity_id, hours);
state.dirty = true; state.dirty = true;
} }
} }
@@ -745,12 +797,13 @@ fn run() -> Result<(), String> {
weather_cache, weather_cache,
); );
let ha_token_path = ha_token::path(&preferences_path); let ha_token_path = ha_token::path(&preferences_path);
let mut ha_service = if let Some(creds) = ha_token::load(&ha_token_path) { let ha_creds = ha_token::load(&ha_token_path);
let mut ha_service = if let Some(ref creds) = ha_creds {
state.ha_paired = true; state.ha_paired = true;
eprintln!("HA token loaded from {}", ha_token_path.display()); eprintln!("HA token loaded from {}", ha_token_path.display());
Some(ha_client::HaService::new(ha_client::HaConfig { Some(ha_client::HaService::new(ha_client::HaConfig {
url: creds.ha_url, url: creds.ha_url.clone(),
token: creds.token, token: creds.token.clone(),
entity_ids: vec![], entity_ids: vec![],
})) }))
} else { } else {
@@ -758,6 +811,9 @@ fn run() -> Result<(), String> {
None None
}; };
let mut ha_pairing: Option<ha_pairing::PairingService> = None; let mut ha_pairing: Option<ha_pairing::PairingService> = None;
let mut album_art_service = album_art::AlbumArtService::new();
let ha_http_base = ha_creds.as_ref().map(|c| ws_to_http_base(&c.ha_url)).unwrap_or_default();
let ha_token_str = ha_creds.as_ref().map(|c| c.token.clone()).unwrap_or_default();
let platform = Platform::new( let platform = Platform::new(
webos, webos,
options.preferences.is_none() && options.test_extra_apps == 0, options.preferences.is_none() && options.test_extra_apps == 0,
@@ -949,6 +1005,21 @@ fn run() -> Result<(), String> {
last_ha_refresh = Instant::now(); last_ha_refresh = Instant::now();
} }
} }
// Album art: check media entities for picture URL changes
for (eid, entity) in &state.ha.entities {
if eid.starts_with("media_player.") {
let pic = entity.attributes.get("entity_picture")
.and_then(|v| v.as_str())
.unwrap_or("");
if !pic.is_empty() {
album_art_service.update(eid, pic, &ha_http_base, &ha_token_str);
}
}
}
while let Some(ready) = album_art_service.poll() {
state.ha.album_art.insert(ready.entity_id, ready.path);
state.dirty = true;
}
pairing_tick( pairing_tick(
&mut state, &mut state,
&mut ha_pairing, &mut ha_pairing,
@@ -1029,7 +1100,7 @@ fn run() -> Result<(), String> {
} if (state.ha.slider_open || state.ha.vacuum_open) } if (state.ha.slider_open || state.ha.vacuum_open)
&& (matches!(key, Keycode::Escape | Keycode::AcBack | Keycode::Backspace) && (matches!(key, Keycode::Escape | Keycode::AcBack | Keycode::Backspace)
|| i32::from(key) == 461 || i32::from(key) == 461
|| (webos && i32::from(key) == WEBOS_BACK_KEYCODE)) => || (webos && (i32::from(key) == WEBOS_BACK_KEYCODE || i32::from(key) == WEBOS_BACK_ALT))) =>
{ {
press = None; press = None;
if state.ha.slider_open { if state.ha.slider_open {
@@ -1052,7 +1123,8 @@ fn run() -> Result<(), String> {
keycode: Some(key), keycode: Some(key),
repeat, repeat,
.. ..
} => match key { } => {
match key {
Keycode::Up => pending = state.direction(Direction::Up, &scene.controls), Keycode::Up => pending = state.direction(Direction::Up, &scene.controls),
Keycode::Down => pending = state.direction(Direction::Down, &scene.controls), Keycode::Down => pending = state.direction(Direction::Down, &scene.controls),
Keycode::Left => pending = state.direction(Direction::Left, &scene.controls), Keycode::Left => pending = state.direction(Direction::Left, &scene.controls),
@@ -1091,13 +1163,13 @@ fn run() -> Result<(), String> {
Keycode::F5 if !repeat => pending.push(Effect::Reload), Keycode::F5 if !repeat => pending.push(Effect::Reload),
key if !repeat key if !repeat
&& (i32::from(key) == 461 && (i32::from(key) == 461
|| (webos && i32::from(key) == WEBOS_BACK_KEYCODE)) => || (webos && (i32::from(key) == WEBOS_BACK_KEYCODE || i32::from(key) == WEBOS_BACK_ALT))) =>
{ {
press = None; press = None;
pending = state.back(); pending = state.back();
} }
_ => {} _ => {}
}, }},
Event::KeyUp { Event::KeyUp {
keycode: Some(Keycode::Return | Keycode::KpEnter), keycode: Some(Keycode::Return | Keycode::KpEnter),
.. ..
+232 -15
View File
@@ -66,11 +66,13 @@ pub enum Target {
HaRoom(usize), HaRoom(usize),
HaCard(usize, usize), HaCard(usize, usize),
HaWeather, HaWeather,
HaVacuumOpen, HaVacuumAction(usize),
HaVacuumExit,
HaVacuumRoom(usize), HaVacuumRoom(usize),
HaVacuumMode, HaVacuumMode,
HaVacuumStart, HaVacuumStart,
HaVacuumClose, HaVacuumClose,
HaMediaAction(usize),
HaSliderBrightness, HaSliderBrightness,
HaSliderColorTemp, HaSliderColorTemp,
HaSliderClose, HaSliderClose,
@@ -157,7 +159,7 @@ pub enum Effect {
Quit, Quit,
Weather(weather::Command), Weather(weather::Command),
Ha(HaEffect), Ha(HaEffect),
HaPairStart(String), HaPairStart { url: String, pin: String },
HaDisconnect, HaDisconnect,
} }
@@ -172,9 +174,12 @@ pub enum HaEffect {
segments: Vec<u32>, segments: Vec<u32>,
mode: Option<String>, mode: Option<String>,
}, },
VacuumDock(String),
ButtonPress(String),
MediaPlayPause(String), MediaPlayPause(String),
MediaNext(String), MediaNext(String),
MediaPrev(String), MediaPrev(String),
MediaSeek(String, f64),
SetVolume(String, f32), SetVolume(String, f32),
SetCoverPosition(String, u8), SetCoverPosition(String, u8),
SetColorTemp(String, u32), SetColorTemp(String, u32),
@@ -186,7 +191,7 @@ pub const KEYS: [&str; 40] = [
"Delete", "Clear", "Done", "Delete", "Clear", "Done",
]; ];
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default, PartialEq)]
pub enum HaCardType { pub enum HaCardType {
#[default] #[default]
Toggle, Toggle,
@@ -244,11 +249,16 @@ pub struct HaState {
pub connected: bool, pub connected: bool,
pub entities: std::collections::HashMap<String, HaEntity>, pub entities: std::collections::HashMap<String, HaEntity>,
pub history: std::collections::HashMap<String, Vec<(f64, f64)>>, pub history: std::collections::HashMap<String, Vec<(f64, f64)>>,
pub forecasts: std::collections::HashMap<String, Vec<crate::ha_client::ForecastDay>>,
pub hourly_forecasts: std::collections::HashMap<String, Vec<crate::ha_client::ForecastHour>>,
pub album_art: std::collections::HashMap<String, String>,
pub config: HaTabConfig, pub config: HaTabConfig,
pub room: usize, pub room: usize,
pub vacuum_card_active: bool,
pub vacuum_selected: Vec<bool>, pub vacuum_selected: Vec<bool>,
pub vacuum_mode: usize, pub vacuum_mode: usize,
pub vacuum_open: bool, pub vacuum_open: bool,
pub media_card_active: bool,
pub slider_open: bool, pub slider_open: bool,
pub slider_entity: String, pub slider_entity: String,
pub slider_card_type: HaCardType, pub slider_card_type: HaCardType,
@@ -295,7 +305,18 @@ impl HaTabConfig {
if let Some(cme) = v.get("cleaning_mode_entity").and_then(|v| v.as_str()) { if let Some(cme) = v.get("cleaning_mode_entity").and_then(|v| v.as_str()) {
vc.cleaning_mode_entity = cme.to_owned(); vc.cleaning_mode_entity = cme.to_owned();
} }
if let Some(rooms) = v.get("rooms").and_then(|v| v.as_object()) { if let Some(rooms) = v.get("rooms").and_then(|v| v.as_array()) {
for room in rooms {
let seg = room.get("segment_id").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let n = room.get("name").and_then(|v| v.as_str()).unwrap_or("");
if seg > 0 && !n.is_empty() {
vc.rooms.push(HaVacuumRoom {
segment_id: seg,
name: n.to_owned(),
});
}
}
} else if let Some(rooms) = v.get("rooms").and_then(|v| v.as_object()) {
for (id, name) in rooms { for (id, name) in rooms {
if let (Ok(seg), Some(n)) = (id.parse::<u32>(), name.as_str()) { if let (Ok(seg), Some(n)) = (id.parse::<u32>(), name.as_str()) {
vc.rooms.push(HaVacuumRoom { vc.rooms.push(HaVacuumRoom {
@@ -304,8 +325,8 @@ impl HaTabConfig {
}); });
} }
} }
vc.rooms.sort_by_key(|r| r.segment_id);
} }
vc.rooms.sort_by_key(|r| r.segment_id);
if !vc.entity_id.is_empty() { if !vc.entity_id.is_empty() {
config.vacuum = Some(vc); config.vacuum = Some(vc);
} }
@@ -343,6 +364,33 @@ impl HaTabConfig {
} }
} }
// Auto-populate vacuum config from the vacuum card if luma/get_config
// doesn't include a top-level "vacuum" section (it only returns rooms).
if config.vacuum.is_none() {
if let Some(vac_entity) = config.rooms.iter()
.flat_map(|r| &r.cards)
.find(|c| c.card_type == HaCardType::Vacuum)
.map(|c| c.entity_id.clone())
{
let prefix = vac_entity.strip_prefix("vacuum.").unwrap_or(&vac_entity).to_owned();
config.vacuum = Some(HaVacuumConfig {
entity_id: vac_entity,
cleaning_mode_entity: format!("select.{prefix}_cleaning_mode"),
rooms: vec![
HaVacuumRoom { segment_id: 16, name: "Kitchen".to_owned() },
HaVacuumRoom { segment_id: 17, name: "Bedroom".to_owned() },
HaVacuumRoom { segment_id: 18, name: "Dining room".to_owned() },
HaVacuumRoom { segment_id: 19, name: "Corridor".to_owned() },
HaVacuumRoom { segment_id: 20, name: "Bathroom".to_owned() },
HaVacuumRoom { segment_id: 21, name: "Living room".to_owned() },
HaVacuumRoom { segment_id: 22, name: "Master bedroom".to_owned() },
HaVacuumRoom { segment_id: 23, name: "Corridor 2".to_owned() },
HaVacuumRoom { segment_id: 24, name: "Bedroom 2".to_owned() },
],
});
}
}
config config
} }
} }
@@ -411,7 +459,7 @@ impl State {
keyboard_field: TextField::Apps, keyboard_field: TextField::Apps,
greeting_name_draft: String::new(), greeting_name_draft: String::new(),
input_name: String::new(), input_name: String::new(),
ha_url_draft: String::new(), ha_url_draft: "https://ha.xtrm-lab.org".into(),
ha_paired: false, ha_paired: false,
ha_pairing_pin: None, ha_pairing_pin: None,
} }
@@ -848,6 +896,12 @@ impl State {
} }
// ── HA page navigation ──────────────────────────────── // ── HA page navigation ────────────────────────────────
if self.page == Page::Ha && !self.ha.config.rooms.is_empty() { if self.page == Page::Ha && !self.ha.config.rooms.is_empty() {
if self.ha.media_card_active {
return self.ha_media_card_direction(direction, controls);
}
if self.ha.vacuum_card_active {
return self.ha_vacuum_card_direction(direction, controls);
}
if self.ha.vacuum_open { if self.ha.vacuum_open {
return self.ha_vacuum_direction(direction, controls); return self.ha_vacuum_direction(direction, controls);
} }
@@ -1326,6 +1380,16 @@ impl State {
} }
pub fn scroll_ha(&mut self, delta: i32) -> Vec<Effect> { pub fn scroll_ha(&mut self, delta: i32) -> Vec<Effect> {
if self.ha.media_card_active {
let card = self.ha.config.rooms.get(self.ha.room)
.and_then(|r| r.cards.iter().find(|c| c.card_type == HaCardType::Media));
if let Some(card) = card {
let vol = self.ha_entity_volume(&card.entity_id);
let new = (vol + delta as f32 * 0.05).clamp(0., 1.);
return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))];
}
return vec![];
}
// Slider modal — update local value instantly, send HA effect in background // Slider modal — update local value instantly, send HA effect in background
if self.ha.slider_open { if self.ha.slider_open {
self.dirty = true; self.dirty = true;
@@ -1394,6 +1458,67 @@ impl State {
vec![] vec![]
} }
fn ha_media_card_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
// 7 buttons: playlist(0), prev-song(1), seek-back(2), play/pause(3), seek-fwd(4), next-song(5), search(6)
let total = 7usize;
if let Target::HaMediaAction(index) = &self.focus {
let index = *index;
match direction {
Direction::Left if index > 0 => {
self.focus(Target::HaMediaAction(index - 1), controls);
}
Direction::Right if index + 1 < total => {
self.focus(Target::HaMediaAction(index + 1), controls);
}
Direction::Up | Direction::Down => {
let delta = if matches!(direction, Direction::Up) { 1 } else { -1 };
let card = self.ha.config.rooms.get(self.ha.room)
.and_then(|r| r.cards.iter().find(|c| c.card_type == HaCardType::Media));
if let Some(card) = card {
let vol = self.ha_entity_volume(&card.entity_id);
let new = (vol + delta as f32 * 0.05).clamp(0., 1.);
return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))];
}
}
_ => {}
}
}
vec![]
}
fn ha_vacuum_card_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
let cols = 3usize;
let total = 6usize; // 5 actions + 1 custom
match &self.focus {
Target::HaVacuumAction(index) => {
let index = *index;
match direction {
Direction::Left if index % cols > 0 => {
self.focus(Target::HaVacuumAction(index - 1), controls);
}
Direction::Right if index % cols + 1 < cols && index + 1 < total => {
self.focus(Target::HaVacuumAction(index + 1), controls);
}
Direction::Up if index >= cols => {
self.focus(Target::HaVacuumAction(index - cols), controls);
}
Direction::Down => {
let next = index + cols;
if next < total {
self.focus(Target::HaVacuumAction(next), controls);
}
}
_ => {}
}
}
Target::HaVacuumExit => {
self.focus(Target::HaVacuumAction(0), controls);
}
_ => {}
}
vec![]
}
fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> { fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
let vac = match &self.ha.config.vacuum { let vac = match &self.ha.config.vacuum {
Some(v) => v, Some(v) => v,
@@ -1870,15 +1995,12 @@ impl State {
return vec![Effect::Ha(HaEffect::ActivateScene(card.entity_id.clone()))]; return vec![Effect::Ha(HaEffect::ActivateScene(card.entity_id.clone()))];
} }
HaCardType::Vacuum => { HaCardType::Vacuum => {
if let Some(vac) = &self.ha.config.vacuum { self.ha.vacuum_card_active = true;
self.ha.vacuum_open = true; self.focus = Target::HaVacuumAction(0);
self.ha.vacuum_selected = vec![false; vac.rooms.len()];
self.ha.vacuum_mode = 0;
self.focus = Target::HaVacuumRoom(0);
}
} }
HaCardType::Media => { HaCardType::Media => {
return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))]; self.ha.media_card_active = true;
self.focus = Target::HaMediaAction(3); // play/pause in center
} }
HaCardType::Weather => {} HaCardType::Weather => {}
HaCardType::Range | HaCardType::Climate | HaCardType::Curtain | HaCardType::Blinds => { HaCardType::Range | HaCardType::Climate | HaCardType::Curtain | HaCardType::Blinds => {
@@ -1951,7 +2073,84 @@ impl State {
.position(|c| matches!(c.card_type, HaCardType::Vacuum)) .position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0)); .unwrap_or(0));
} }
Target::HaVacuumOpen | Target::HaWeather => {} Target::HaMediaAction(index) => {
let media_card = self.ha.config.rooms.get(self.ha.room)
.and_then(|r| r.cards.iter().find(|c| c.card_type == HaCardType::Media));
if let Some(card) = media_card {
let entity_id = card.entity_id.clone();
match index {
1 => return vec![Effect::Ha(HaEffect::MediaPrev(entity_id))],
2 => {
// Seek back 10 seconds
let pos = self.ha.entities.get(&entity_id)
.and_then(|e| e.attributes.get("media_position"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
return vec![Effect::Ha(HaEffect::MediaSeek(entity_id, (pos - 10.).max(0.)))];
}
3 => return vec![Effect::Ha(HaEffect::MediaPlayPause(entity_id))],
4 => {
// Seek forward 10 seconds
let pos = self.ha.entities.get(&entity_id)
.and_then(|e| e.attributes.get("media_position"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let dur = self.ha.entities.get(&entity_id)
.and_then(|e| e.attributes.get("media_duration"))
.and_then(|v| v.as_f64())
.unwrap_or(f64::MAX);
return vec![Effect::Ha(HaEffect::MediaSeek(entity_id, (pos + 10.).min(dur)))];
}
5 => return vec![Effect::Ha(HaEffect::MediaNext(entity_id))],
0 | 6 => {} // playlist / search — not yet implemented
_ => {}
}
}
}
Target::HaVacuumAction(index) => {
let vac_entity = self.ha.config.rooms.get(self.ha.room)
.and_then(|r| r.cards.iter().find(|c| matches!(c.card_type, HaCardType::Vacuum)))
.map(|c| c.entity_id.clone());
let vac_idx = self.ha.config.rooms.get(self.ha.room)
.and_then(|r| r.cards.iter().position(|c| matches!(c.card_type, HaCardType::Vacuum)))
.unwrap_or(0);
if let Some(entity_id) = vac_entity {
let prefix = entity_id.strip_prefix("vacuum.").unwrap_or(&entity_id);
if index == 5 {
if let Some(vac) = &self.ha.config.vacuum {
self.ha.vacuum_open = true;
self.ha.vacuum_selected = vec![false; vac.rooms.len()];
self.ha.vacuum_mode = 0;
self.ha.vacuum_card_active = false;
self.focus = Target::HaVacuumRoom(0);
}
} else if index == 0 {
self.ha.vacuum_card_active = false;
self.focus = Target::HaCard(self.ha.room, vac_idx);
return vec![Effect::Ha(HaEffect::VacuumDock(entity_id))];
} else {
let button_id = match index {
1 => format!("button.{}_mop_only", prefix),
2 => format!("button.{}_vac_mop_fast", prefix),
3 => format!("button.{}_after_meals", prefix),
4 => format!("button.{}_vac_mop", prefix),
_ => return vec![],
};
self.ha.vacuum_card_active = false;
self.focus = Target::HaCard(self.ha.room, vac_idx);
return vec![Effect::Ha(HaEffect::ButtonPress(button_id))];
}
}
}
Target::HaVacuumExit => {
self.ha.vacuum_card_active = false;
let vac_idx = self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0);
self.focus = Target::HaCard(self.ha.room, vac_idx);
}
Target::HaWeather => {}
Target::HaPairUrl => { Target::HaPairUrl => {
self.open_keyboard(TextField::HaUrl); self.open_keyboard(TextField::HaUrl);
} }
@@ -1960,11 +2159,13 @@ impl State {
if url.is_empty() { if url.is_empty() {
return vec![]; return vec![];
} }
let pin = crate::ha_pairing::generate_pin();
self.ha_pairing_pin = Some(pin.clone());
self.subpage = Some(Subpage::HaPairing); self.subpage = Some(Subpage::HaPairing);
if !self.preferences.reduced_motion { if !self.preferences.reduced_motion {
self.panel_started = Some(Instant::now()); self.panel_started = Some(Instant::now());
} }
return vec![Effect::HaPairStart(url)]; return vec![Effect::HaPairStart { url, pin }];
} }
Target::HaPairDisconnect => { Target::HaPairDisconnect => {
self.ha_paired = false; self.ha_paired = false;
@@ -2212,6 +2413,22 @@ impl State {
self.scroll = 0.; self.scroll = 0.;
return vec![]; return vec![];
} }
if self.page == Page::Ha && self.ha.media_card_active {
self.ha.media_card_active = false;
let media_idx = self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| c.card_type == HaCardType::Media)
.unwrap_or(0);
self.focus = Target::HaCard(self.ha.room, media_idx);
return vec![];
}
if self.page == Page::Ha && self.ha.vacuum_card_active {
self.ha.vacuum_card_active = false;
let vac_idx = self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0);
self.focus = Target::HaCard(self.ha.room, vac_idx);
return vec![];
}
if self.page == Page::Ha && self.ha.vacuum_open { if self.page == Page::Ha && self.ha.vacuum_open {
self.ha.vacuum_open = false; self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room, self.focus = Target::HaCard(self.ha.room,
+525 -177
View File
@@ -1675,7 +1675,7 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
let mut occupied = vec![false; cols * max_rows]; let mut occupied = vec![false; cols * max_rows];
for (index, card) in room.cards.iter().enumerate() { for (index, card) in room.cards.iter().enumerate() {
let tall = matches!(card.card_type, HaCardType::Media | HaCardType::Weather); let tall = matches!(card.card_type, HaCardType::Media | HaCardType::Weather | HaCardType::Vacuum);
let row_span = if tall { 2 } else { 1 }; let row_span = if tall { 2 } else { 1 };
// Find next available cell that fits // Find next available cell that fits
@@ -1717,7 +1717,7 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
ha_climate_card(out, state, assets, rect, card, entity, focused); ha_climate_card(out, state, assets, rect, card, entity, focused);
} }
HaCardType::Action => { HaCardType::Action => {
ha_action_card(out, state, assets, rect, card, focused); ha_action_card(out, state, assets, rect, card, entity, focused);
} }
HaCardType::Vacuum => { HaCardType::Vacuum => {
ha_vacuum_card(out, state, assets, rect, card, entity, focused); ha_vacuum_card(out, state, assets, rect, card, entity, focused);
@@ -1733,7 +1733,7 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
} }
HaCardType::Weather => { HaCardType::Weather => {
if let Some(e) = entity { if let Some(e) = entity {
ha_weather_card(out, state, assets, rect, e, focused); ha_weather_card(out, state, assets, rect, card, e, focused);
} }
} }
} }
@@ -1753,6 +1753,18 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
} }
} }
fn ha_card_name<'a>(card: &'a crate::state::HaCardConfig, entity: Option<&'a crate::state::HaEntity>) -> &'a str {
if !card.name.is_empty() {
return &card.name;
}
if let Some(e) = entity {
if let Some(name) = e.attributes.get("friendly_name").and_then(|v| v.as_str()) {
return name;
}
}
&card.entity_id
}
// ── Glass card base ────────────────────────────────────────────── // ── Glass card base ──────────────────────────────────────────────
fn ha_glass(out: &mut Scene, rect: Rect, focused: bool) { fn ha_glass(out: &mut Scene, rect: Rect, focused: bool) {
@@ -1943,7 +1955,7 @@ fn ha_toggle_card(
); );
// Entity name // Entity name
out.text( out.text(
&card.name, ha_card_name(card, entity),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02), text(18., 600, -0.02),
WHITE, WHITE,
@@ -2021,7 +2033,7 @@ fn ha_range_card(
); );
// Entity name // Entity name
out.text( out.text(
&card.name, ha_card_name(card, entity),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02), text(18., 600, -0.02),
WHITE, WHITE,
@@ -2109,7 +2121,7 @@ fn ha_climate_card(
); );
// Entity name // Entity name
out.text( out.text(
&card.name, ha_card_name(card, entity),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02), text(18., 600, -0.02),
WHITE, WHITE,
@@ -2239,6 +2251,7 @@ fn ha_action_card(
assets: &Assets, assets: &Assets,
rect: Rect, rect: Rect,
card: &crate::state::HaCardConfig, card: &crate::state::HaCardConfig,
entity: Option<&crate::state::HaEntity>,
focused: bool, focused: bool,
) { ) {
ha_glass(out, rect, focused); ha_glass(out, rect, focused);
@@ -2265,7 +2278,7 @@ fn ha_action_card(
); );
// Entity name // Entity name
out.text( out.text(
&card.name, ha_card_name(card, entity),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02), text(18., 600, -0.02),
WHITE, WHITE,
@@ -2284,144 +2297,249 @@ fn ha_action_card(
} }
} }
// ── Vacuum card (summary; opens modal) ─────────────────────────── // ── Vacuum card (tall, with action buttons) ──────────────────────
const VAC_ACTIONS: [&str; 6] = ["Dock", "Mop Only", "V&M Fast", "After Meals", "Vac & Mop", "Custom"];
fn ha_vacuum_card( fn ha_vacuum_card(
out: &mut Scene, out: &mut Scene,
_state: &State, state: &State,
assets: &Assets, assets: &Assets,
rect: Rect, rect: Rect,
card: &crate::state::HaCardConfig, card: &crate::state::HaCardConfig,
entity: Option<&crate::state::HaEntity>, entity: Option<&crate::state::HaEntity>,
focused: bool, focused: bool,
) { ) {
ha_glass(out, rect, focused); let prefix = card.entity_id.strip_prefix("vacuum.").unwrap_or(&card.entity_id);
let status = entity.map_or("unavailable", |e| e.state.as_str()); let status_entity_id = format!("sensor.{}_status", prefix);
let accent = match status { let status_text = state.ha.entities.get(&status_entity_id)
"cleaning" => c(0x7EB8DA, 1.), .map(|e| e.state.as_str())
"returning" => c(0xf0a060, 1.), .unwrap_or_else(|| entity.map_or("unavailable", |e| e.state.as_str()));
_ => c(0xbbbbbb, 0.5), let is_cleaning = matches!(status_text,
"cleaning" | "segment_cleaning" | "zoned_cleaning" | "spot_cleaning"
| "segment_mopping" | "robot_status_mopping");
let is_returning = matches!(status_text, "returning_home" | "docking");
let error_entity_id = format!("sensor.{}_vacuum_error", prefix);
let has_error = state.ha.entities.get(&error_entity_id)
.is_some_and(|e| e.state != "none" && !e.state.is_empty());
let battery_entity_id = format!("sensor.{}_battery", prefix);
let battery = state.ha.entities.get(&battery_entity_id)
.and_then(|e| e.state.parse::<u8>().ok())
.unwrap_or(0);
let accent = if has_error {
c(0xe05050, 1.)
} else if is_cleaning {
c(0x7EB8DA, 1.)
} else if is_returning {
c(0xf0a060, 1.)
} else {
c(0x9daab8, 0.7)
}; };
// Fill for active states // ── Glass background ──
let fill_color = ha_fill_color(&card.card_type, entity); ha_glass(out, rect, focused);
if status == "cleaning" || status == "returning" {
ha_card_fill(out, rect, if status == "cleaning" { 0.6 } else { 0.3 }, fill_color); let pad = 16.;
// ── Vacuum product image (top-right, proportional) ──
let img_s = 160.;
let img_x = rect.x + rect.w - pad - img_s;
let img_y = rect.y + pad + 4.;
// Glow behind image: green when cleaning, red on error
if is_cleaning {
let glow_cx = img_x + img_s / 2.;
let glow_cy = img_y + img_s / 2.;
card_glow(out, rect, glow_cx, glow_cy, c(0x50e070, 0.45));
} else if has_error {
let glow_cx = img_x + img_s / 2.;
let glow_cy = img_y + img_s / 2.;
card_glow(out, rect, glow_cx, glow_cy, c(0xe05050, 0.45));
} }
// Icon in tinted circle out.image(
let icon_bg = if status == "cleaning" || status == "returning" { "/tmp/luma-vacuum.png".to_owned(),
[accent[0], accent[1], accent[2], 0.2] Rect::new(img_x, img_y, img_s, img_s),
} else { 0.,
c(0xffffff, 0.08) true,
};
out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg);
out.icon(
assets,
"vacuum",
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
accent,
); );
// Type label // ── Label + status (left of image) ──
let text_w = rect.w - img_s - pad * 3.;
out.text( out.text(
"Vacuum", "Roborock S7",
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.), Rect::new(rect.x + pad, rect.y + pad, text_w, 22.),
text(12., 400, 0.04),
if status == "cleaning" { accent } else { c(0xe8e8e8, 0.45) },
false,
);
// Entity name
out.text(
&card.name,
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02), text(18., 600, -0.02),
WHITE, WHITE,
false, false,
); );
// Status at bottom let display_status = match status_text {
let display_status = match status { "charging" | "charging_complete" => "Charged",
"cleaning" => "Cleaning", "cleaning" | "segment_cleaning" | "zoned_cleaning" => "Cleaning",
"returning" => "Returning", "returning_home" | "docking" => "Returning",
"washing_the_mop" | "going_to_wash_the_mop" => "Washing mop",
"emptying_the_bin" => "Emptying bin",
"docked" => "Docked", "docked" => "Docked",
"idle" => "Idle", "idle" => "Idle",
"paused" => "Paused", "paused" => "Paused",
"error" => "Error",
other => other, other => other,
}; };
let status_line = if has_error {
let err = state.ha.entities.get(&error_entity_id)
.map(|e| e.state.replace('_', " "))
.unwrap_or_default();
format!("⚠ {} · {}%", err, battery)
} else {
format!("{} · {}%", display_status, battery)
};
out.text( out.text(
display_status, &status_line,
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.), Rect::new(rect.x + pad, rect.y + pad + 26., text_w, 16.),
text(22., 700, -0.02), text(13., 400, 0.02),
if status == "cleaning" || status == "returning" { WHITE } else { c(0xbbbbbb, 0.5) }, if has_error { c(0xe05050, 1.) } else if is_cleaning { accent } else { c(0xe8e8e8, 0.50) },
false, false,
); );
// ── Action buttons grid (3×2) anchored to bottom ──
let btn_gap = 8.;
let cols = 3usize;
let rows = 2usize;
let btn_h = 48_f32;
let grid_h = btn_h * rows as f32 + btn_gap;
let grid_top = rect.y + rect.h - pad - grid_h;
let grid_x = rect.x + pad;
let btn_w = (rect.w - pad * 2. - btn_gap * (cols as f32 - 1.)) / cols as f32;
let card_active = state.ha.vacuum_card_active;
for i in 0..6 {
let col = i % cols;
let row = i / cols;
let bx = grid_x + col as f32 * (btn_w + btn_gap);
let by = grid_top + row as f32 * (btn_h + btn_gap);
let btn_rect = Rect::new(bx, by, btn_w, btn_h);
let btn_focused = card_active && state.focus == Target::HaVacuumAction(i);
let is_custom = i == 5;
let bg = if btn_focused {
c(0x7EB8DA, 0.25)
} else if is_custom {
c(0xffffff, 0.04)
} else {
c(0xffffff, 0.07)
};
out.shape(btn_rect, 12., bg);
out.border(btn_rect, 12., 1., if btn_focused { c(0x7EB8DA, 0.6) } else { c(0xffffff, 0.08) });
if btn_focused {
out.border(btn_rect.inset(-3.), 15., 2., WHITE);
}
let label_color = if btn_focused { WHITE } else if is_custom { c(0xe8e8e8, 0.55) } else { c(0xe8e8e8, 0.80) };
out.text(
VAC_ACTIONS[i],
Rect::new(bx + 8., by + (btn_h - 14.) / 2., btn_w - 16., 18.),
text(13., 500, 0.),
label_color,
true,
);
out.control(Target::HaVacuumAction(i), btn_rect, false);
}
} }
// ── Media card (Music Assistant / media_player) ──────────────── // ── Media card (Music Assistant / media_player) ────────────────
fn ha_media_card( fn ha_media_card(
out: &mut Scene, out: &mut Scene,
_state: &State, state: &State,
assets: &Assets, assets: &Assets,
rect: Rect, rect: Rect,
card: &crate::state::HaCardConfig, card: &crate::state::HaCardConfig,
entity: Option<&crate::state::HaEntity>, entity: Option<&crate::state::HaEntity>,
focused: bool, focused: bool,
) { ) {
let accent = ha_state_color(entity); let is_playing = entity.is_some_and(|e| e.state == "playing");
let is_active = entity.is_some_and(|e| e.state == "playing" || e.state == "paused"); let is_paused = entity.is_some_and(|e| e.state == "paused");
let has_media = entity.is_some_and(|e| {
e.attributes.get("media_title").and_then(|v| v.as_str()).is_some_and(|s| !s.is_empty())
});
let is_active = is_playing || is_paused || has_media;
let in_card = state.ha.media_card_active;
// Volume-driven radial glow from album art center
let volume = entity let volume = entity
.and_then(|e| e.attributes.get("volume_level")) .and_then(|e| e.attributes.get("volume_level"))
.and_then(|v| v.as_f64()) .and_then(|v| v.as_f64())
.unwrap_or(0.) as f32; .unwrap_or(0.) as f32;
// Portrait layout: album art circle centered at top let pad = 16.;
let art_size = 120.;
let art_x = rect.x + (rect.w - art_size) / 2.;
let art_y = rect.y + 24.;
// ── Glass background ──
ha_glass(out, rect, focused); ha_glass(out, rect, focused);
// Glow inside card, clipped to card bounds // ── Volume gradient fill ──
if is_active && volume > 0. { if volume > 0.01 {
let glow_alpha = (volume * 0.3).clamp(0.05, 0.30); let fill_h = rect.h * volume;
card_glow(out, rect, art_x + art_size / 2., art_y + art_size / 2., [accent[0], accent[1], accent[2], glow_alpha]); let fill_rect = Rect::new(rect.x, rect.y + rect.h - fill_h, rect.w, fill_h);
out.clip_rounded(rect, 22.);
out.draw.push(Draw::RoundedGradient(
fill_rect,
0.,
c(0x7050b0, 0.0),
c(0x7050b0, (volume * 0.35).clamp(0.04, 0.30)),
));
out.unclip_rounded();
} }
out.shape( // ── Album art (rectangular, left side, with shadow) ──
Rect::new(art_x, art_y, art_size, art_size), let art_s = 180.;
art_size / 2., let art_r = 12.;
if is_active { [accent[0], accent[1], accent[2], 0.25] } else { c(0xffffff, 0.08) }, let art_x = rect.x + pad;
); let art_y = rect.y + pad;
let art_rect = Rect::new(art_x, art_y, art_s, art_s);
out.shadow(art_rect, art_r, 24., 6., 0.4);
let has_art = if let Some(path) = state.ha.album_art.get(&card.entity_id) {
out.image(path.clone(), art_rect, art_r, true);
true
} else {
false
};
if !has_art {
out.shape(art_rect, art_r, c(0x242830, 0.9));
let icon_s = 56.;
out.icon( out.icon(
assets, assets,
"spotify", "spotify",
Rect::new(art_x + 30., art_y + 30., 60., 60.), Rect::new(art_x + (art_s - icon_s) / 2., art_y + (art_s - icon_s) / 2., icon_s, icon_s),
if is_active { accent } else { c(0xbbbbbb, 0.4) }, if is_active { c(0xb48efa, 0.7) } else { c(0xbbbbbb, 0.3) },
); );
}
// Text area below album art // ── Track info (right of album art) ──
let text_y = art_y + art_size + 16.; let info_x = art_x + art_s + pad;
let text_x = rect.x + 16.; let info_w = rect.x + rect.w - info_x - pad;
let text_w = rect.w - 32.;
// Song title (centered) // Song title
let title = entity let title = entity
.and_then(|e| e.attributes.get("media_title")) .and_then(|e| e.attributes.get("media_title"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(&card.name); .unwrap_or_else(|| ha_card_name(card, entity));
out.text( out.text(
title, title,
Rect::new(text_x, text_y, text_w, 24.), Rect::new(info_x, art_y + 8., info_w, 22.),
text(17., 600, -0.02), text(17., 600, -0.02),
WHITE, WHITE,
true, false,
); );
// Artist (centered) // Artist
let artist = entity let artist = entity
.and_then(|e| e.attributes.get("media_artist")) .and_then(|e| e.attributes.get("media_artist"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -2429,49 +2547,140 @@ fn ha_media_card(
if !artist.is_empty() { if !artist.is_empty() {
out.text( out.text(
artist, artist,
Rect::new(text_x, text_y + 28., text_w, 18.), Rect::new(info_x, art_y + 34., info_w, 18.),
text(13., 400, 0.), text(14., 400, 0.01),
c(0xe8e8e8, 0.6), c(0xe8e8e8, 0.55),
true, false,
); );
} }
// State label // Volume icon + %
let state_str = entity.map_or("off", |e| e.state.as_str()); let vol_pct = (volume * 100.).round() as u8;
let status_label = match state_str { let vol_icon_s = 14.;
"playing" => "Playing", let vol_y = art_y + 58.;
"paused" => "Paused", out.icon(assets, "volume", Rect::new(info_x, vol_y, vol_icon_s, vol_icon_s), c(0xffffff, 0.40));
"idle" => "Idle", out.text(
_ => "Off", &format!("{}%", vol_pct),
Rect::new(info_x + vol_icon_s + 4., vol_y, info_w - vol_icon_s - 4., 16.),
text(13., 500, 0.),
c(0xe8e8e8, 0.40),
false,
);
// ── Progress bar (thin line above controls, Apple Music style) ──
let duration = entity
.and_then(|e| e.attributes.get("media_duration"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let position_base = entity
.and_then(|e| e.attributes.get("media_position"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let bar_pad = pad;
let bar_w = rect.w - bar_pad * 2.;
let bar_h = 3.;
let bar_y = rect.y + rect.h - 82.;
let bar_x = rect.x + bar_pad;
if duration > 0. {
let progress = (position_base / duration).clamp(0., 1.) as f32;
// Track bar (dim)
out.shape(Rect::new(bar_x, bar_y, bar_w, bar_h), 1.5, c(0xffffff, 0.15));
// Played portion
let played_w = (bar_w * progress).max(bar_h);
out.shape(Rect::new(bar_x, bar_y, played_w, bar_h), 1.5, c(0xffffff, 0.70));
// Time labels
let pos_secs = position_base as u32;
let dur_secs = duration as u32;
let fmt = |s: u32| -> String {
let m = s / 60;
let sec = s % 60;
format!("{m}:{sec:02}")
}; };
out.text( out.text(
status_label, &fmt(pos_secs),
Rect::new(text_x, text_y + 52., text_w, 14.), Rect::new(bar_x, bar_y + 6., 60., 14.),
text(11., 400, 0.04), text(11., 400, 0.02),
if is_active { accent } else { c(0xe8e8e8, 0.45) }, c(0xffffff, 0.45),
true, false,
); );
// Volume at bottom
let vol_pct = (volume * 100.).round() as u8;
let vol_label = if is_active { format!("Vol {}%", vol_pct) } else { "Off".into() };
out.text( out.text(
&vol_label, &format!("-{}", fmt(dur_secs.saturating_sub(pos_secs))),
Rect::new(text_x, rect.y + rect.h - 36., text_w, 16.), Rect::new(bar_x + bar_w - 60., bar_y + 6., 60., 14.),
text(13., 500, 0.), text(11., 400, 0.02),
if is_active { WHITE } else { c(0xbbbbbb, 0.5) }, c(0xffffff, 0.45),
true,
);
if focused && is_active {
out.text(
"OK ▶/❚❚ scroll vol",
Rect::new(text_x, rect.y + rect.h - 18., text_w, 12.),
text(10., 400, 0.02),
c(0xeeeeee, 0.30),
true, true,
); );
} }
// ── Transport controls (bottom row, 7 buttons) ──
// [playlist] ... [prev-song] [seek-back] [play/pause] [seek-fwd] [next-song] ... [search]
let ctrl_y = rect.y + rect.h - 48.;
let btn_s = 22.;
let play_s = 30.;
let inner_gap = 36.;
let outer_gap = 54.;
let ctrl_center = rect.x + rect.w / 2.;
let icon_color = |idx: usize| -> Color {
if in_card && state.focus == Target::HaMediaAction(idx) {
WHITE
} else {
c(0xffffff, 0.45)
}
};
// Center cluster positions
let seek_back_cx = ctrl_center - inner_gap;
let seek_fwd_cx = ctrl_center + inner_gap;
let prev_cx = ctrl_center - outer_gap;
let next_cx = ctrl_center + outer_gap;
// Playlist and search pinned to card edges
let playlist_cx = rect.x + pad + btn_s / 2.;
let search_cx = rect.x + rect.w - pad - btn_s / 2.;
let by = ctrl_y + (play_s - btn_s) / 2.;
// Helper to draw a button
let mut draw_btn = |out: &mut Scene, idx: usize, cx: f32, cy: f32, size: f32, icon: &str| {
let bx = cx - size / 2.;
let by = cy;
out.icon(assets, icon, Rect::new(bx, by, size, size), icon_color(idx));
if in_card {
out.control(Target::HaMediaAction(idx), Rect::new(bx - 8., by - 8., size + 16., size + 16.), false);
}
};
// 0: Playlist (left edge)
draw_btn(out, 0, playlist_cx, by, btn_s, "playlist");
// 1: Previous song
draw_btn(out, 1, prev_cx, by, btn_s, "skip-back");
// 2: Seek back
draw_btn(out, 2, seek_back_cx, by, btn_s, "seek-back");
// 3: Play/Pause (larger, centered)
let bx = ctrl_center - play_s / 2.;
let by_play = ctrl_y;
let play_icon = if is_playing { "pause" } else { "play" };
out.icon(assets, play_icon, Rect::new(bx, by_play, play_s, play_s), icon_color(3));
if in_card {
out.control(Target::HaMediaAction(3), Rect::new(bx - 6., by_play - 6., play_s + 12., play_s + 12.), false);
}
// 4: Seek forward
draw_btn(out, 4, seek_fwd_cx, by, btn_s, "seek-forward");
// 5: Next song
draw_btn(out, 5, next_cx, by, btn_s, "skip-forward");
// 6: Search (right edge)
draw_btn(out, 6, search_cx, by, btn_s, "search");
} }
// ── Cover card (curtain / blinds) ──────────────────────────────── // ── Cover card (curtain / blinds) ────────────────────────────────
@@ -2543,7 +2752,7 @@ fn ha_cover_card(
); );
// Entity name // Entity name
out.text( out.text(
&card.name, ha_card_name(card, entity),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02), text(18., 600, -0.02),
WHITE, WHITE,
@@ -2574,11 +2783,89 @@ fn ha_cover_card(
// ── Weather hero card ──────────────────────────────────────────── // ── Weather hero card ────────────────────────────────────────────
fn weather_condition_icon(condition: &str) -> &'static str {
match condition {
"sunny" => "weather-sun",
"clear-night" => "weather-moon",
"cloudy" | "fog" => "weather-cloud",
"partlycloudy" => "weather-partly-cloudy",
"rainy" | "pouring" => "weather-rain",
"snowy" => "weather-snow",
"lightning" | "lightning-rainy" => "weather-storm",
"hail" | "exceptional" => "weather-drizzle",
_ => "weather-sun",
}
}
fn weather_condition_label(condition: &str) -> &'static str {
match condition {
"sunny" => "Sunny",
"clear-night" => "Clear",
"cloudy" => "Cloudy",
"partlycloudy" => "Partly Cloudy",
"fog" => "Fog",
"rainy" => "Rain",
"pouring" => "Heavy Rain",
"snowy" => "Snow",
"lightning" => "Thunderstorm",
"lightning-rainy" => "Thunderstorm",
"hail" => "Hail",
"windy" | "windy-variant" => "Windy",
"exceptional" => "Exceptional",
_ => "Unknown",
}
}
fn weather_day_abbr(date: &str) -> &str {
// date is YYYY-MM-DD; parse weekday
if date.len() < 10 { return "?"; }
let parts: Vec<&str> = date.split('-').collect();
if parts.len() < 3 { return "?"; }
let (y, m, d) = match (parts[0].parse::<i32>(), parts[1].parse::<u8>(), parts[2].parse::<u8>()) {
(Ok(y), Ok(m), Ok(d)) => (y, m, d),
_ => return "?",
};
if let Ok(date) = time::Date::from_calendar_date(y, time::Month::try_from(m).unwrap_or(time::Month::January), d) {
match date.weekday() {
time::Weekday::Monday => "Mon",
time::Weekday::Tuesday => "Tue",
time::Weekday::Wednesday => "Wed",
time::Weekday::Thursday => "Thu",
time::Weekday::Friday => "Fri",
time::Weekday::Saturday => "Sat",
time::Weekday::Sunday => "Sun",
}
} else {
"?"
}
}
fn closest_hourly_temp(
hourly: &std::collections::HashMap<String, Vec<crate::ha_client::ForecastHour>>,
entity_id: &str,
) -> Option<f64> {
let hours = hourly.get(entity_id)?;
if hours.is_empty() { return None; }
let now = time::OffsetDateTime::now_utc();
let now_ts = now.unix_timestamp();
let mut best: Option<(i64, f64)> = None;
for h in hours {
if let Ok(dt) = time::OffsetDateTime::parse(&h.datetime, &time::format_description::well_known::Rfc3339) {
let diff = (dt.unix_timestamp() - now_ts).abs();
if best.map_or(true, |(d, _)| diff < d) {
best = Some((diff, h.temperature));
}
}
}
best.map(|(_, t)| t)
}
fn ha_weather_card( fn ha_weather_card(
out: &mut Scene, out: &mut Scene,
_state: &State, state: &State,
assets: &Assets, assets: &Assets,
rect: Rect, rect: Rect,
card: &crate::state::HaCardConfig,
entity: &crate::state::HaEntity, entity: &crate::state::HaEntity,
focused: bool, focused: bool,
) { ) {
@@ -2586,79 +2873,140 @@ fn ha_weather_card(
ha_glass(out, rect, focused); ha_glass(out, rect, focused);
let condition = entity.state.as_str(); let condition = entity.state.as_str();
let temp = entity let fallback_temp = entity.attributes.get("temperature").and_then(|v| v.as_f64()).unwrap_or(0.);
.attributes let temp = closest_hourly_temp(&state.ha.hourly_forecasts, &card.entity_id).unwrap_or(fallback_temp);
.get("temperature") let humidity = entity.attributes.get("humidity").and_then(|v| v.as_f64()).unwrap_or(0.);
.and_then(|v| v.as_f64()) let wind = entity.attributes.get("wind_speed").and_then(|v| v.as_f64()).unwrap_or(0.);
.unwrap_or(0.); let wind_unit = entity.attributes.get("wind_speed_unit").and_then(|v| v.as_str()).unwrap_or("km/h");
let humidity = entity let friendly_name = entity.attributes.get("friendly_name").and_then(|v| v.as_str()).unwrap_or("");
.attributes let city = if !card.name.is_empty() { card.name.as_str() } else { friendly_name };
.get("humidity")
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let wind = entity
.attributes
.get("wind_speed")
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let weather_icon = match condition { let weather_icon = weather_condition_icon(condition);
"sunny" | "clear-night" => "weather-sun", let condition_label = weather_condition_label(condition);
"cloudy" | "fog" => "weather-cloud",
"partlycloudy" => "weather-partly-cloudy",
"rainy" | "pouring" => "weather-rain",
"snowy" => "weather-snow",
"lightning" | "lightning-rainy" => "weather-storm",
"hail" => "weather-drizzle",
_ => "weather-sun",
};
let cx = rect.x + rect.w * 0.5; let pad = 20.;
// Large icon centered near top let half_w = rect.w * 0.5;
out.icon(
assets, // ── Left side: city, temperature, details ──
weather_icon, // City name
Rect::new(cx - 32., rect.y + 24., 64., 64.), out.icon(assets, "location", Rect::new(rect.x + pad, rect.y + pad, 14., 14.), c(0xa0c0e0, 0.7));
c(0xf0d080, 1.), out.text(
city,
Rect::new(rect.x + pad + 18., rect.y + pad, half_w - pad - 18., 16.),
text(13., 500, 0.02),
c(0xa0c0e0, 0.7),
false,
); );
// Large temperature // Large temperature
out.text( out.text(
&format!("{:.0}°", temp), &format!("{:.0}°", temp),
Rect::new(rect.x + 16., rect.y + 100., rect.w - 32., 48.), Rect::new(rect.x + pad, rect.y + 44., half_w - pad, 56.),
text(40., 600, -0.04), text(52., 600, -0.04),
WHITE,
false,
);
// Condition label
out.text(
condition_label,
Rect::new(rect.x + pad, rect.y + 104., half_w - pad, 18.),
text(14., 400, 0.),
c(0xe8e8e8, 0.55),
false,
);
// Humidity row
let detail_y = rect.y + 134.;
out.icon(assets, "humidity", Rect::new(rect.x + pad, detail_y, 16., 16.), c(0x60b0f0, 0.7));
out.text(
&format!("{:.0}%", humidity),
Rect::new(rect.x + pad + 20., detail_y, 60., 16.),
text(13., 400, 0.),
c(0xe8e8e8, 0.55),
false,
);
// Wind row
let wind_y = detail_y + 22.;
out.text(
&format!("{:.0} {wind_unit}", wind),
Rect::new(rect.x + pad, wind_y, half_w - pad, 16.),
text(13., 400, 0.),
c(0xe8e8e8, 0.55),
false,
);
// ── Right side: large weather icon ──
let icon_size = 80.;
let icon_x = rect.x + half_w + (half_w - icon_size) * 0.5;
let icon_y = rect.y + 30.;
out.icon(
assets,
weather_icon,
Rect::new(icon_x, icon_y, icon_size, icon_size),
c(0xf0d080, 1.),
);
// Temp unit below icon
out.text(
&format!("{:.0}°C", temp),
Rect::new(rect.x + half_w, icon_y + icon_size + 8., half_w, 18.),
text(14., 400, 0.),
c(0xe8e8e8, 0.45),
true,
);
// ── Bottom: 5-day forecast ──
let forecast_y = rect.y + 190.;
// Separator line
out.shape(Rect::new(rect.x + pad, forecast_y, rect.w - pad * 2., 1.), 0., c(0xffffff, 0.08));
let forecasts = state.ha.forecasts.get(&card.entity_id);
if let Some(days) = forecasts {
let count = days.len().min(5);
if count > 0 {
let col_w = (rect.w - pad * 2.) / count as f32;
for (i, day) in days.iter().take(count).enumerate() {
let cx = rect.x + pad + col_w * i as f32 + col_w * 0.5;
let fy = forecast_y + 10.;
// Day abbreviation
let day_label = weather_day_abbr(&day.date);
out.text(
day_label,
Rect::new(cx - col_w * 0.5, fy, col_w, 14.),
text(11., 500, 0.02),
c(0xe8e8e8, 0.5),
true,
);
// Weather icon for this day
let day_icon = weather_condition_icon(&day.condition);
out.icon(
assets,
day_icon,
Rect::new(cx - 10., fy + 18., 20., 20.),
c(0xf0d080, 0.7),
);
// High / Low temps
out.text(
&format!("{:.0}°", day.temp_high),
Rect::new(cx - col_w * 0.5, fy + 42., col_w, 14.),
text(12., 600, 0.),
WHITE, WHITE,
true, true,
); );
// Condition label
out.text( out.text(
condition, &format!("{:.0}°", day.temp_low),
Rect::new(rect.x + 16., rect.y + 156., rect.w - 32., 20.), Rect::new(cx - col_w * 0.5, fy + 58., col_w, 14.),
text(15.2, 400, 0.), text(11., 400, 0.),
c(0xe8e8e8, 0.60), c(0xe8e8e8, 0.4),
true, true,
); );
// Humidity row }
out.icon( }
assets, }
"humidity",
Rect::new(rect.x + 24., rect.y + 200., 20., 20.),
c(0x60b0f0, 0.7),
);
out.text(
&format!("{:.0}%", humidity),
Rect::new(rect.x + 50., rect.y + 200., 80., 20.),
text(15.2, 400, 0.),
c(0xe8e8e8, 0.60),
false,
);
// Wind row
out.text(
&format!("Wind {:.0} km/h", wind),
Rect::new(rect.x + 24., rect.y + 232., rect.w - 48., 20.),
text(15.2, 400, 0.),
c(0xe8e8e8, 0.60),
false,
);
out.unclip_rounded(); out.unclip_rounded();
} }
@@ -3103,13 +3451,14 @@ fn settings(out: &mut Scene, state: &State, assets: &Assets) {
let start = out.draw.len(); let start = out.draw.len();
if matches!( if matches!(
subpage, subpage,
Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock | Subpage::HaPairing
) { ) {
match subpage { match subpage {
Subpage::WeatherLocation => location_settings(out, state, assets), Subpage::WeatherLocation => location_settings(out, state, assets),
Subpage::Greeting => greeting_settings(out, state, assets), Subpage::Greeting => greeting_settings(out, state, assets),
Subpage::AppNames => app_name_settings(out, state, assets), Subpage::AppNames => app_name_settings(out, state, assets),
Subpage::Clock => clock_settings(out, state, assets), Subpage::Clock => clock_settings(out, state, assets),
Subpage::HaPairing => ha_settings(out, state, assets),
_ => unreachable!(), _ => unreachable!(),
} }
if let Some(opened) = state.panel_started { if let Some(opened) = state.panel_started {
@@ -3118,7 +3467,6 @@ fn settings(out: &mut Scene, state: &State, assets: &Assets) {
return; return;
} }
let options: Vec<(&str, Option<&str>, bool)> = match subpage { let options: Vec<(&str, Option<&str>, bool)> = match subpage {
Subpage::HaPairing => return,
Subpage::ClockFormat => vec![ Subpage::ClockFormat => vec![
(language.t("hour12"), None, !state.preferences.hour_24), (language.t("hour12"), None, !state.preferences.hour_24),
(language.t("hour24"), None, state.preferences.hour_24), (language.t("hour24"), None, state.preferences.hour_24),
@@ -3164,7 +3512,7 @@ fn settings(out: &mut Scene, state: &State, assets: &Assets) {
state.preferences.temperature_unit == Unit::Fahrenheit, state.preferences.temperature_unit == Unit::Fahrenheit,
), ),
], ],
Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock => { Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock | Subpage::HaPairing => {
unreachable!() unreachable!()
} }
Subpage::Language => Language::ALL Subpage::Language => Language::ALL