diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index 8d45ee5..e72ca37 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -12,6 +12,8 @@ pub enum ServerMsg { Wake, /// Some(0..=255) = manual PWM. None = restore auto. Fan(Option), + /// Switch to a specific layout by ID (must be present in current bundle). + SwitchLayout(u32), } use gtk4::prelude::{ApplicationExt, ApplicationExtManual}; diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index ffee442..7cc6511 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -16,6 +16,32 @@ fn state_dir() -> PathBuf { fn key_file() -> PathBuf { state_dir().join("kiosk.key") } fn server_file() -> PathBuf { state_dir().join("server.url") } +fn bundle_cache_path() -> PathBuf { state_dir().join("bundle.json") } + +/// Persist the latest bundle to disk for offline boot. +pub fn save_bundle(bundle: &KioskBundle) { + match serde_json::to_string(bundle) { + Ok(text) => { + if let Err(e) = fs::write(bundle_cache_path(), text) { + tracing::warn!("failed to save bundle cache: {e}"); + } + } + Err(e) => tracing::warn!("failed to serialize bundle: {e}"), + } +} + +/// Load a cached bundle from disk. Returns None if file missing or invalid. +pub fn load_cached_bundle() -> Option { + let path = bundle_cache_path(); + let text = fs::read_to_string(&path).ok()?; + match serde_json::from_str::(&text) { + Ok(b) => Some(b), + Err(e) => { + tracing::warn!("cached bundle invalid: {e}"); + None + } + } +} /// Discover the BetterFrame server. pub fn discover_server(override_url: Option<&str>) -> String { @@ -132,20 +158,38 @@ pub fn poll_claim(server: &str, code: &str) -> (String, String) { } } -/// Fetch bundle from server. -pub fn fetch_bundle(server: &str, key: &str) -> KioskBundle { +/// Fetch bundle from server. Returns None on network/HTTP/parse failure. +/// On success, also writes the bundle to the on-disk cache. +pub fn fetch_bundle(server: &str, key: &str) -> Option { let client = reqwest::blocking::Client::new(); - let resp = client + let resp = match client .get(format!("{server}/api/kiosk/bundle")) .header("Authorization", format!("Bearer {key}")) + .timeout(Duration::from_secs(10)) .send() - .expect("bundle fetch failed"); + { + Ok(r) => r, + Err(e) => { + tracing::warn!("bundle fetch failed: {e}"); + return None; + } + }; if !resp.status().is_success() { - panic!("Bundle fetch returned {}", resp.status()); + tracing::warn!("bundle fetch returned {}", resp.status()); + return None; } - resp.json().expect("bad bundle JSON") + match resp.json::() { + Ok(b) => { + save_bundle(&b); + Some(b) + } + Err(e) => { + tracing::warn!("bundle parse failed: {e}"); + None + } + } } /// Send heartbeat with display geometry + hwmon. diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index ee43985..8b0e6ae 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -1,15 +1,8 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::sync::mpsc; +use std::time::{Duration, Instant}; use url::Url; -thread_local! { - /// camera_id → (pipeline, paintable, badge). Pipelines stay warm across - /// layout swaps for cameras still referenced or in preload_camera_ids. - /// badge is 'M' / 'S' / ' ' indicating which stream is active. - static WARM_CAMERAS: RefCell> - = RefCell::new(std::collections::HashMap::new()); -} - use gtk4::prelude::*; use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture}; use tracing::{info, warn}; @@ -22,6 +15,32 @@ use crate::server; use crate::ws_client; use crate::ServerMsg; +thread_local! { + /// camera_id → (pipeline, paintable, badge). Pipelines stay warm across + /// layout swaps for cameras still referenced or in preload_camera_ids. + /// badge is 'M' / 'S' / ' ' indicating which stream is active. + static WARM_CAMERAS: RefCell> + = RefCell::new(std::collections::HashMap::new()); + + /// Most recently rendered bundle. Used for layout-switch + idle revert. + static CURRENT_BUNDLE: RefCell> = const { RefCell::new(None) }; + + /// Server URL + kiosk key for re-rendering on layout-switch. + static CURRENT_AUTH: RefCell> = const { RefCell::new(None) }; + + /// Layout id currently on screen, if any. + static CURRENT_LAYOUT_ID: Cell> = const { Cell::new(None) }; + + /// Timestamp of the last "activity" event (render, switch, wake). + static LAST_ACTIVITY: RefCell = RefCell::new(Instant::now()); + + /// True after we've fired CEC standby due to sleep timeout. + static IS_ASLEEP: Cell = const { Cell::new(false) }; + + /// Has the idle-watchdog already been installed on the main loop? + static WATCHDOG_INSTALLED: Cell = const { Cell::new(false) }; +} + const APP_ID: &str = "dev.betterframe.kiosk"; const BETTERFRAME_LOGO_SVG: &str = include_str!("../../server/src/web-static/betterframe-logo.svg"); const BETTERFRAME_MARK_SVG: &str = include_str!("../../server/src/web-static/betterframe-mark.svg"); @@ -71,9 +90,27 @@ fn activate(app: &Application) { key }; - let bundle = server::fetch_bundle(&server, &key); - info!("bundle: {} cameras, {} layouts", bundle.cameras.len(), bundle.layouts.len()); - let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone())); + // Try fetching live bundle. If server unreachable, fall back to + // cached on-disk bundle and keep retrying every 30s in the background. + let initial = match server::fetch_bundle(&server, &key) { + Some(b) => { + info!("bundle: {} cameras, {} layouts", b.cameras.len(), b.layouts.len()); + Some(b) + } + None => { + if let Some(cached) = server::load_cached_bundle() { + warn!("offline mode: rendering cached bundle"); + Some(cached) + } else { + warn!("no bundle available (server unreachable, no cache)"); + None + } + } + }; + + if let Some(bundle) = initial { + let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone())); + } // Spawn WS client in a separate thread for live updates let server_ws = server.clone(); @@ -87,22 +124,55 @@ fn activate(app: &Application) { ws_client::run(&server_ws, &key_ws, ws_tx); }); + // Background retry thread: if we couldn't fetch a live bundle on boot, + // try again every 30s until we get one. Once fetched, send a render. + let retry_tx = tx.clone(); + let retry_server = server.clone(); + let retry_key = key.clone(); + std::thread::spawn(move || { + // Only loop while we have no live bundle yet — best-effort heuristic: + // we attempt once, then sleep. If server unreachable each time we + // keep waiting; once a fetch succeeds we push a fresh render. + // After first success we exit; subsequent updates flow via WS. + loop { + std::thread::sleep(Duration::from_secs(30)); + if let Some(b) = server::fetch_bundle(&retry_server, &retry_key) { + info!("offline-retry: fresh bundle fetched, rendering"); + let _ = retry_tx.send(WorkerMsg::RenderBundle( + b, + retry_server.clone(), + retry_key.clone(), + )); + return; + } + } + }); + // Listen for WS messages and dispatch std::thread::spawn(move || { for msg in ws_rx { match msg { ServerMsg::ReloadBundle => { info!("reloading bundle"); - let bundle = server::fetch_bundle(&server_for_reload, &key_for_reload); - let _ = tx_for_reload.send(WorkerMsg::RenderBundle( - bundle, - server_for_reload.clone(), - key_for_reload.clone(), - )); + match server::fetch_bundle(&server_for_reload, &key_for_reload) { + Some(bundle) => { + let _ = tx_for_reload.send(WorkerMsg::RenderBundle( + bundle, + server_for_reload.clone(), + key_for_reload.clone(), + )); + } + None => warn!("reload-bundle: fetch failed, keeping current render"), + } } ServerMsg::Standby => cec::standby(), - ServerMsg::Wake => cec::wake(), + ServerMsg::Wake => { + let _ = tx_for_reload.send(WorkerMsg::Wake); + } ServerMsg::Fan(pwm) => { hwmon::set_fan(pwm); } + ServerMsg::SwitchLayout(id) => { + let _ = tx_for_reload.send(WorkerMsg::SwitchLayout(id)); + } } } }); @@ -122,7 +192,18 @@ fn activate(app: &Application) { while let Ok(msg) = rx.try_recv() { match msg { WorkerMsg::ShowPairingCode(code) => show_pairing_code(&window_clone, &code), - WorkerMsg::RenderBundle(bundle, server, key) => render_bundle(&window_clone, bundle, &server, &key), + WorkerMsg::RenderBundle(bundle, server, key) => { + render_bundle(&window_clone, bundle, &server, &key); + install_idle_watchdog(&window_clone); + } + WorkerMsg::SwitchLayout(id) => { + render_layout(&window_clone, id); + } + WorkerMsg::Wake => { + cec::wake(); + IS_ASLEEP.with(|c| c.set(false)); + mark_activity(); + } } } gtk::glib::ControlFlow::Continue @@ -132,6 +213,73 @@ fn activate(app: &Application) { enum WorkerMsg { ShowPairingCode(String), RenderBundle(KioskBundle, String, String), + SwitchLayout(u32), + Wake, +} + +/// Reset activity timer. If we were asleep, wake the display first. +fn mark_activity() { + LAST_ACTIVITY.with(|t| *t.borrow_mut() = Instant::now()); + if IS_ASLEEP.with(|c| c.get()) { + info!("activity while asleep → waking display"); + cec::wake(); + IS_ASLEEP.with(|c| c.set(false)); + } +} + +/// Install the once-per-second watchdog that enforces idle/sleep timeouts. +/// Safe to call multiple times — installs at most once. +fn install_idle_watchdog(window: &ApplicationWindow) { + if WATCHDOG_INSTALLED.with(|c| c.get()) { return; } + WATCHDOG_INSTALLED.with(|c| c.set(true)); + let window = window.clone(); + gtk::glib::timeout_add_local(Duration::from_secs(1), move || { + let elapsed = LAST_ACTIVITY.with(|t| t.borrow().elapsed()); + + // Need the bundle to read display timeouts + default layout. + let (idle_to, sleep_to, default_id) = CURRENT_BUNDLE.with(|b| { + match b.borrow().as_ref() { + Some(bundle) => ( + bundle.display.idle_timeout_seconds as u64, + bundle.display.sleep_timeout_seconds as u64, + bundle.display.default_layout_id, + ), + None => (0, 0, None), + } + }); + + // Idle revert: if elapsed >= idle timeout AND current layout is not + // default AND current layout doesn't itself reset the idle timer. + if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) { + let cur = CURRENT_LAYOUT_ID.with(|c| c.get()); + let cur_resets_idle = CURRENT_BUNDLE.with(|b| { + let bundle = b.borrow(); + let Some(bundle) = bundle.as_ref() else { return false }; + let Some(cur_id) = cur else { return false }; + bundle.layouts.iter().find(|l| l.id == cur_id) + .map(|l| l.resets_idle_timer) + .unwrap_or(false) + }); + if let (Some(cur_id), Some(def_id)) = (cur, default_id) { + if cur_id != def_id && cur_resets_idle { + info!("idle timeout reached → reverting to default layout"); + render_layout(&window, def_id); + } + } + } + + // Sleep: fire CEC standby once, mark asleep. + if sleep_to > 0 + && elapsed >= Duration::from_secs(sleep_to) + && !IS_ASLEEP.with(|c| c.get()) + { + info!("sleep timeout reached → CEC standby"); + cec::standby(); + IS_ASLEEP.with(|c| c.set(true)); + } + + gtk::glib::ControlFlow::Continue + }); } /// Query connected HDMI displays from sysfs. Returns (name, width, height). @@ -184,16 +332,57 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) { } fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &str, kiosk_key: &str) { - let layout = match bundle.display.default_layout_id { - Some(default_layout_id) => bundle.layouts.iter() - .find(|l| l.id == default_layout_id) - .or_else(|| bundle.layouts.iter().find(|l| l.is_default)), - None => None, - }; + // Cache the bundle + auth so layout-switch and idle-revert can re-render + // without needing a full reload. + CURRENT_BUNDLE.with(|b| *b.borrow_mut() = Some(bundle.clone())); + CURRENT_AUTH.with(|a| *a.borrow_mut() = Some((server_url.to_string(), kiosk_key.to_string()))); + mark_activity(); - let Some(layout) = layout else { + let target_layout_id = bundle.display.default_layout_id + .or_else(|| bundle.layouts.iter().find(|l| l.is_default).map(|l| l.id)); + + let Some(target_layout_id) = target_layout_id else { warn!("display has no default layout"); clear_warm_cameras(); + CURRENT_LAYOUT_ID.with(|c| c.set(None)); + show_logo(window); + return; + }; + + render_layout(window, target_layout_id); +} + +/// Render a specific layout id from the cached bundle. If not found, fall back +/// to the display's default layout. If neither exists, show the logo. +fn render_layout(window: &ApplicationWindow, layout_id: u32) { + mark_activity(); + + // Snapshot what we need out of the cached bundle. + let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| { + let bundle = b.borrow(); + let bundle = bundle.as_ref()?.clone(); + let auth = CURRENT_AUTH.with(|a| a.borrow().clone()); + let (server_url, kiosk_key) = auth?; + Some((bundle, server_url, kiosk_key)) + }); + let Some((bundle, server_url, kiosk_key)) = snapshot else { + warn!("render_layout: no cached bundle yet"); + show_logo(window); + return; + }; + + let layout = bundle.layouts.iter().find(|l| l.id == layout_id) + .or_else(|| { + warn!("render_layout: layout {layout_id} not found, falling back to default"); + bundle.display.default_layout_id + .and_then(|did| bundle.layouts.iter().find(|l| l.id == did)) + .or_else(|| bundle.layouts.iter().find(|l| l.is_default)) + }); + + let Some(layout) = layout else { + warn!("render_layout: no usable layout"); + clear_warm_cameras(); + CURRENT_LAYOUT_ID.with(|c| c.set(None)); show_logo(window); return; }; @@ -201,12 +390,15 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s if layout.cells.is_empty() { warn!("layout has no cells"); clear_warm_cameras(); + CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id))); show_logo(window); return; } - info!("rendering layout '{}' with {}x{} grid, {} cells", - layout.name, layout.grid_cols, layout.grid_rows, layout.cells.len()); + CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id))); + + info!("rendering layout '{}' (id {}) with {}x{} grid, {} cells", + layout.name, layout.id, layout.grid_cols, layout.grid_rows, layout.cells.len()); // Compute which cameras are needed: cells with content_type=camera + preload_camera_ids let mut needed: std::collections::HashSet = std::collections::HashSet::new(); @@ -229,6 +421,9 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s } }); + let server_url = server_url.as_str(); + let kiosk_key = kiosk_key.as_str(); + let grid = Grid::new(); grid.set_row_homogeneous(true); grid.set_column_homogeneous(true); diff --git a/kiosk/src/ws_client.rs b/kiosk/src/ws_client.rs index ed2a958..87db955 100644 --- a/kiosk/src/ws_client.rs +++ b/kiosk/src/ws_client.rs @@ -46,6 +46,16 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { } else if text.contains("\"type\":\"wake\"") { info!("ws: wake received"); let _ = tx.send(ServerMsg::Wake); + } else if text.contains("\"type\":\"layout-switch\"") { + info!("ws: layout-switch received: {text}"); + let layout_id: Option = text.split("\"layout_id\":").nth(1) + .and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next()) + .and_then(|s| s.parse::().ok()); + if let Some(id) = layout_id { + let _ = tx.send(ServerMsg::SwitchLayout(id)); + } else { + warn!("ws: layout-switch missing layout_id"); + } } else if text.contains("\"type\":\"fan\"") { info!("ws: fan received: {text}"); let pwm: Option = if text.contains("\"mode\":\"auto\"") { diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 1ea6143..7a793ce 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1052,12 +1052,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { role: kl.role, })); const displays = deps.repo.listDisplaysForKiosk(id); + const firstDisplay = displays[0]; + const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : []; return htmlPage(KioskEditPage({ user: user.username, kiosk, labels: kioskLabels, allLabels: deps.repo.listLabels(), displays, + switchableLayouts, })); }); @@ -1102,6 +1105,16 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); }); + // ---- Layout switch ---------------------------------------------------- + app.post("/admin/kiosks/:id/layout/:layoutId", (event) => { + const id = Number(getRouterParam(event, "id")); + const layoutId = Number(getRouterParam(event, "layoutId")); + if (Number.isFinite(id) && Number.isFinite(layoutId)) { + getCoordinator().sendToKiosk(id, { type: "layout-switch", layout_id: layoutId }); + } + return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); + }); + // ---- CEC power commands ----------------------------------------------- app.post("/admin/kiosks/:id/power/standby", (event) => { const id = Number(getRouterParam(event, "id")); diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 7ebb731..132b516 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1164,6 +1164,7 @@ interface KioskEditProps { labels: Array<{ label_id: number; name: string; role: string }>; allLabels: Label[]; displays?: Display[]; + switchableLayouts?: LayoutType[]; error?: string; success?: string; } @@ -1213,6 +1214,25 @@ export function KioskEditPage(props: KioskEditProps) { + {props.switchableLayouts && props.switchableLayouts.length > 0 ? ( +
+
Switch Layout
+
+ + +
+
+ ) : null} +
Hardware