diff --git a/kiosk/Cargo.toml b/kiosk/Cargo.toml index dc9e3cc..e5cc9b0 100644 --- a/kiosk/Cargo.toml +++ b/kiosk/Cargo.toml @@ -33,3 +33,4 @@ tokio-tungstenite = { version = "0.24", features = ["native-tls"] } futures-util = "0.3" url = "2" webkit6 = "0.4" +gpiod = "0.3" diff --git a/kiosk/src/bundle.rs b/kiosk/src/bundle.rs index 89a0d1a..6d1acf9 100644 --- a/kiosk/src/bundle.rs +++ b/kiosk/src/bundle.rs @@ -4,12 +4,47 @@ use serde::{Deserialize, Serialize}; pub struct KioskBundle { pub kiosk_id: u32, pub kiosk_name: String, - pub display: BundleDisplay, + /// Legacy single-display field (mirrors `displays[0]`). New code should + /// iterate `displays` instead. + #[serde(default)] + pub display: Option, + /// Legacy single-display layouts (mirrors `displays[0].layouts`). Kept for + /// backward compatibility with older bundles that pre-date multi-display. + #[serde(default)] pub layouts: Vec, + /// All physical displays driven by this kiosk. + #[serde(default)] + pub displays: Vec, pub cameras: Vec, + #[serde(default)] + pub gpio_bindings: Vec, pub version: String, } +impl KioskBundle { + /// Normalize the bundle: if `displays` is empty (old server), synthesize it + /// from the legacy single `display` + `layouts` fields so the rest of the + /// kiosk only deals with one shape. + pub fn normalized_displays(&self) -> Vec { + if !self.displays.is_empty() { + return self.displays.clone(); + } + if let Some(d) = &self.display { + return vec![BundleDisplayWithLayouts { + id: d.id, + name: d.name.clone(), + width_px: d.width_px, + height_px: d.height_px, + idle_timeout_seconds: d.idle_timeout_seconds, + sleep_timeout_seconds: d.sleep_timeout_seconds, + default_layout_id: d.default_layout_id, + layouts: self.layouts.clone(), + }]; + } + Vec::new() + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleDisplay { pub id: u32, @@ -21,6 +56,19 @@ pub struct BundleDisplay { pub default_layout_id: Option, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BundleDisplayWithLayouts { + pub id: u32, + pub name: String, + pub width_px: u32, + pub height_px: u32, + pub idle_timeout_seconds: u32, + pub sleep_timeout_seconds: u32, + pub default_layout_id: Option, + #[serde(default)] + pub layouts: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleLayout { pub id: u32, @@ -76,6 +124,17 @@ pub struct BundleStream { pub framerate: Option, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BundleGpioBinding { + pub id: u32, + pub chip: String, + pub pin: u32, + pub direction: String, + pub pull: Option, + pub edge: Option, + pub topic: String, +} + impl BundleCamera { /// Pick stream URI + role tag for this camera given selector and cell area fraction. /// Heuristic: when selector=auto, cell ≥20% of grid → main, else sub. diff --git a/kiosk/src/gpio.rs b/kiosk/src/gpio.rs new file mode 100644 index 0000000..4ef9f1f --- /dev/null +++ b/kiosk/src/gpio.rs @@ -0,0 +1,152 @@ +//! GPIO worker threads — one per binding. +//! +//! Each binding spawns a worker that opens the configured gpio chip, requests +//! the configured line, waits for edge events, and posts to +//! `/api/kiosk/event` on each trigger. Output bindings are opened but idle — +//! reserved for future server-driven set operations. +//! +//! The whole worker pool is rebuilt on every bundle reload: `start_workers` +//! replaces any previously running set. Workers shut down when their +//! `running` flag flips to `false`. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tracing::{info, warn}; + +use crate::bundle::BundleGpioBinding; + +struct WorkerHandle { + running: Arc, +} + +static WORKERS: Mutex> = Mutex::new(Vec::new()); + +/// Tear down the previous worker set (if any) and start a fresh set for the +/// given bindings. Safe to call repeatedly on bundle reload. +pub fn start_workers(bindings: &[BundleGpioBinding], server_url: &str, kiosk_key: &str) { + stop_workers(); + + if bindings.is_empty() { + return; + } + + let mut handles = Vec::new(); + for b in bindings { + let running = Arc::new(AtomicBool::new(true)); + let r2 = running.clone(); + let binding = b.clone(); + let server = server_url.to_string(); + let key = kiosk_key.to_string(); + + std::thread::spawn(move || run_binding(binding, server, key, r2)); + handles.push(WorkerHandle { running }); + } + + if let Ok(mut w) = WORKERS.lock() { + *w = handles; + } + info!("gpio: {} worker(s) started", bindings.len()); +} + +fn stop_workers() { + if let Ok(mut w) = WORKERS.lock() { + for h in w.drain(..) { + h.running.store(false, Ordering::Relaxed); + } + } +} + +fn run_binding(b: BundleGpioBinding, server: String, key: String, running: Arc) { + if b.direction != "in" { + // Output bindings: open the line so the chip knows it's claimed but + // there's nothing to wait on. Bail early — output set ops will be + // added later via WS commands. + return; + } + + let chip = match gpiod::Chip::new(&b.chip) { + Ok(c) => c, + Err(e) => { + warn!("gpio: open chip {} failed: {e}", b.chip); + return; + } + }; + + let mut opts = gpiod::Options::input([b.pin as u32]) + .consumer("betterframe-kiosk"); + + if let Some(edge) = b.edge.as_deref() { + let edge_detect = match edge { + "rising" => gpiod::EdgeDetect::Rising, + "falling" => gpiod::EdgeDetect::Falling, + _ => gpiod::EdgeDetect::Both, + }; + opts = opts.edge(edge_detect); + } else { + opts = opts.edge(gpiod::EdgeDetect::Both); + } + + if let Some(pull) = b.pull.as_deref() { + let bias = match pull { + "up" => gpiod::Bias::PullUp, + "down" => gpiod::Bias::PullDown, + _ => gpiod::Bias::Disable, + }; + opts = opts.bias(bias); + } + + let lines = match chip.request_lines(opts) { + Ok(l) => l, + Err(e) => { + warn!("gpio: request {}:{} failed: {e}", b.chip, b.pin); + return; + } + }; + + info!( + "gpio: watching {} pin {} ({}/{}/topic={})", + b.chip, + b.pin, + b.direction, + b.edge.as_deref().unwrap_or("both"), + b.topic + ); + + let http = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client build"); + + while running.load(Ordering::Relaxed) { + // Poll with a timeout so the running flag is checked periodically. + match lines.read_event() { + Ok(event) => { + let edge_str = match event.edge { + gpiod::Edge::Rising => "rising", + gpiod::Edge::Falling => "falling", + }; + let payload = serde_json::json!({ + "chip": b.chip, + "pin": b.pin, + "edge": edge_str, + }); + let _ = http + .post(format!("{server}/api/kiosk/event")) + .header("Authorization", format!("Bearer {key}")) + .json(&serde_json::json!({ + "topic": b.topic, + "source_type": "gpio", + "payload": payload, + })) + .send(); + } + Err(e) => { + warn!("gpio: read_event {}:{} failed: {e}", b.chip, b.pin); + std::thread::sleep(Duration::from_secs(1)); + } + } + } + info!("gpio: worker {}:{} stopped", b.chip, b.pin); +} diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index e72ca37..8ea1fcb 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -1,6 +1,7 @@ mod server; mod bundle; mod cec; +mod gpio; mod hwmon; mod pipeline; mod ui; diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 8b0e6ae..a154963 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -1,4 +1,5 @@ use std::cell::{Cell, RefCell}; +use std::collections::HashMap; use std::sync::mpsc; use std::time::{Duration, Instant}; use url::Url; @@ -7,20 +8,32 @@ use gtk4::prelude::*; use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture}; use tracing::{info, warn}; -use crate::bundle::KioskBundle; +use crate::bundle::{BundleDisplayWithLayouts, KioskBundle}; use crate::cec; +use crate::gpio; use crate::hwmon; use crate::pipeline; use crate::server; use crate::ws_client; use crate::ServerMsg; +/// Per-display runtime state. Kept inside a thread-local hashmap keyed by +/// display id, so all the idle/sleep/layout tracking is local to that display +/// even though the GTK main loop is shared. +struct DisplayState { + window: ApplicationWindow, + current_layout_id: Option, + last_activity: Instant, + is_asleep: bool, +} + 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()); + /// Shared across ALL displays — if two displays use the same camera the + /// pipeline is reused. The paintable can be attached to multiple Pictures. + static WARM_CAMERAS: RefCell> + = RefCell::new(HashMap::new()); /// Most recently rendered bundle. Used for layout-switch + idle revert. static CURRENT_BUNDLE: RefCell> = const { RefCell::new(None) }; @@ -28,14 +41,8 @@ thread_local! { /// 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) }; + /// Per-display state, keyed by bundle display id. + static DISPLAYS: RefCell> = RefCell::new(HashMap::new()); /// Has the idle-watchdog already been installed on the main loop? static WATCHDOG_INSTALLED: Cell = const { Cell::new(false) }; @@ -52,7 +59,9 @@ pub fn build_app() -> Application { } fn activate(app: &Application) { - let window = ApplicationWindow::builder() + // Create the initial pairing window. Multi-display windows are spawned + // later once we receive a bundle. + let pairing_window = ApplicationWindow::builder() .application(app) .title("BetterFrame") .fullscreened(true) @@ -61,13 +70,13 @@ fn activate(app: &Application) { let provider = gtk::CssProvider::new(); provider.load_from_string("window { background-color: #000000; }"); gtk::style_context_add_provider_for_display( - &WidgetExt::display(&window), + &WidgetExt::display(&pairing_window), &provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, ); - show_logo(&window); - window.present(); + show_logo(&pairing_window); + pairing_window.present(); let (tx, rx) = mpsc::channel::(); @@ -94,7 +103,7 @@ fn activate(app: &Application) { // 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()); + info!("bundle: {} cameras, {} display(s)", b.cameras.len(), b.normalized_displays().len()); Some(b) } None => { @@ -130,10 +139,6 @@ fn activate(app: &Application) { 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) { @@ -187,22 +192,27 @@ fn activate(app: &Application) { }); // Poll channel from UI thread via timeout - let window_clone = window.clone(); + let app_clone = app.clone(); + let pairing_window_clone = pairing_window.clone(); gtk::glib::timeout_add_local(std::time::Duration::from_millis(100), move || { while let Ok(msg) = rx.try_recv() { match msg { - WorkerMsg::ShowPairingCode(code) => show_pairing_code(&window_clone, &code), + WorkerMsg::ShowPairingCode(code) => show_pairing_code(&pairing_window_clone, &code), WorkerMsg::RenderBundle(bundle, server, key) => { - render_bundle(&window_clone, bundle, &server, &key); - install_idle_watchdog(&window_clone); + render_bundle(&app_clone, &pairing_window_clone, bundle, &server, &key); + install_idle_watchdog(); } WorkerMsg::SwitchLayout(id) => { - render_layout(&window_clone, id); + switch_layout_anywhere(id); } WorkerMsg::Wake => { cec::wake(); - IS_ASLEEP.with(|c| c.set(false)); - mark_activity(); + DISPLAYS.with(|ds| { + for st in ds.borrow_mut().values_mut() { + st.is_asleep = false; + st.last_activity = Instant::now(); + } + }); } } } @@ -217,65 +227,77 @@ enum WorkerMsg { 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)); - } +/// Reset activity timer for one display. If asleep, wake it. +fn mark_activity(display_id: u32) { + DISPLAYS.with(|ds| { + if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + st.last_activity = Instant::now(); + if st.is_asleep { + info!("activity while asleep → waking display {display_id}"); + cec::wake(); + st.is_asleep = 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) { +/// Install the once-per-second watchdog that enforces idle/sleep timeouts +/// per display. Safe to call multiple times — installs at most once. +fn install_idle_watchdog() { 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()); + let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone()); + let Some(bundle) = bundle else { return gtk::glib::ControlFlow::Continue }; - // 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), + // Snapshot per-display timing decisions so we can act outside the borrow. + struct Action { display_id: u32, revert_to: Option, sleep: bool } + let mut actions: Vec = Vec::new(); + + DISPLAYS.with(|ds| { + for (display_id, st) in ds.borrow().iter() { + let Some(d) = bundle.normalized_displays().into_iter().find(|d| d.id == *display_id) else { continue }; + let idle_to = d.idle_timeout_seconds as u64; + let sleep_to = d.sleep_timeout_seconds as u64; + let elapsed = st.last_activity.elapsed(); + let default_id = d.default_layout_id; + + let mut act = Action { display_id: *display_id, revert_to: None, sleep: false }; + + if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) { + let cur_resets_idle = st.current_layout_id + .and_then(|cur_id| d.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)) = (st.current_layout_id, default_id) { + if cur_id != def_id && cur_resets_idle { + act.revert_to = Some(def_id); + } + } + } + if sleep_to > 0 && elapsed >= Duration::from_secs(sleep_to) && !st.is_asleep { + act.sleep = true; + } + if act.revert_to.is_some() || act.sleep { + actions.push(act); + } } }); - // 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); - } + for a in actions { + if let Some(layout_id) = a.revert_to { + info!("idle timeout reached → reverting display {} to default", a.display_id); + render_layout(a.display_id, layout_id); + } + if a.sleep { + info!("sleep timeout reached on display {} → CEC standby", a.display_id); + cec::standby(); + DISPLAYS.with(|ds| { + if let Some(st) = ds.borrow_mut().get_mut(&a.display_id) { + st.is_asleep = true; + } + }); } - } - - // 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 @@ -289,20 +311,17 @@ fn query_displays() -> Vec<(String, u32, u32)> { let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { return out }; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); - // Skip non-HDMI connectors and the "card" parents if !name.contains("-HDMI-") && !name.contains("-DP-") { continue; } let path = entry.path(); let status = std::fs::read_to_string(path.join("status")).unwrap_or_default(); if status.trim() != "connected" { continue; } let modes = std::fs::read_to_string(path.join("modes")).unwrap_or_default(); - // First line = preferred mode let mode = modes.lines().next().unwrap_or(""); let parts: Vec<&str> = mode.split('x').collect(); if parts.len() != 2 { continue; } let w: u32 = parts[0].parse().unwrap_or(0); let h: u32 = parts[1].trim().parse().unwrap_or(0); if w == 0 || h == 0 { continue; } - // Strip "cardN-" prefix for cleaner name let clean_name = name.split_once('-').map(|(_, rest)| rest.to_string()).unwrap_or(name); out.push((clean_name, w, h)); } @@ -331,33 +350,160 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) { window.set_child(Some(&vbox)); } -fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &str, kiosk_key: &str) { - // Cache the bundle + auth so layout-switch and idle-revert can re-render - // without needing a full reload. +/// Render a fresh bundle: rebuild the per-display window set, restart GPIO +/// workers, recompute warm-camera needs across all displays. +fn render_bundle( + app: &Application, + pairing_window: &ApplicationWindow, + bundle: KioskBundle, + server_url: &str, + kiosk_key: &str, +) { 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 target_layout_id = bundle.display.default_layout_id - .or_else(|| bundle.layouts.iter().find(|l| l.is_default).map(|l| l.id)); + // Restart GPIO workers (always — even if list is empty, this drops the old set). + gpio::start_workers(&bundle.gpio_bindings, server_url, kiosk_key); - 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); + let displays = bundle.normalized_displays(); + if displays.is_empty() { + warn!("bundle has no displays"); + show_logo(pairing_window); return; - }; + } - render_layout(window, target_layout_id); + // Match GDK monitors to bundle displays by index. Bundle display 0 → GDK + // monitor 0, etc. v1 simple ordering — re-binding will land if/when the + // admin UI exposes a mapping. Falls back to overlapping windows on a + // single physical screen if the kiosk has fewer monitors than bundle + // displays (rare on Pi5). + let gdk_monitors: Vec = WidgetExt::display(pairing_window) + .monitors() + .iter::() + .flatten() + .collect(); + + // Tear down any previous per-display windows we no longer need. + let keep_ids: std::collections::HashSet = displays.iter().map(|d| d.id).collect(); + let to_remove: Vec = DISPLAYS.with(|ds| { + ds.borrow().keys().filter(|id| !keep_ids.contains(id)).copied().collect() + }); + for id in to_remove { + if let Some(st) = DISPLAYS.with(|ds| ds.borrow_mut().remove(&id)) { + st.window.close(); + } + } + + // Compute global warm-camera set: union across all displays' current/needed cameras. + let mut globally_needed: std::collections::HashSet = std::collections::HashSet::new(); + for (i, bd) in displays.iter().enumerate() { + let target_id = pick_initial_layout(bd); + if let Some(target_id) = target_id { + if let Some(layout) = bd.layouts.iter().find(|l| l.id == target_id) { + for cell in &layout.cells { + if cell.content_type == "camera" { + if let Some(id) = cell.camera_id { globally_needed.insert(id); } + } + } + for id in &layout.preload_camera_ids { globally_needed.insert(*id); } + } + } + // Pre-touch the monitor binding to silence warnings about unused var. + let _ = gdk_monitors.get(i); + } + + // Stop pipelines for cameras no longer needed by any display. + WARM_CAMERAS.with(|w| { + let mut warm = w.borrow_mut(); + let stale: Vec = warm.keys().filter(|id| !globally_needed.contains(id)).copied().collect(); + for id in stale { + if let Some((pipe, _, _)) = warm.remove(&id) { + info!("stopping pipeline for camera {id} (no longer needed by any display)"); + pipeline::stop(&pipe); + } + } + }); + + // Build/reuse window per bundle display, then render its initial layout. + let mut new_state: HashMap = HashMap::new(); + for (i, bd) in displays.iter().enumerate() { + let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id)); + let window = match existing { + Some(st) => st.window, + None => { + let w = ApplicationWindow::builder() + .application(app) + .title(format!("BetterFrame — {}", bd.name)) + .fullscreened(true) + .build(); + let provider = gtk::CssProvider::new(); + provider.load_from_string("window { background-color: #000000; }"); + gtk::style_context_add_provider_for_display( + &WidgetExt::display(&w), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + w.present(); + if let Some(monitor) = gdk_monitors.get(i) { + w.fullscreen_on_monitor(monitor); + } + w + } + }; + new_state.insert(bd.id, DisplayState { + window, + current_layout_id: None, + last_activity: Instant::now(), + is_asleep: false, + }); + } + DISPLAYS.with(|ds| *ds.borrow_mut() = new_state); + + // Hide the pairing window now that real displays are up (if we created any). + if !displays.is_empty() { + pairing_window.set_visible(false); + } + + // Now render each display's initial layout. + for bd in &displays { + let target = pick_initial_layout(bd); + if let Some(layout_id) = target { + render_layout(bd.id, layout_id); + } else { + warn!("display {} has no default layout", bd.id); + DISPLAYS.with(|ds| { + if let Some(st) = ds.borrow_mut().get_mut(&bd.id) { + show_logo(&st.window); + st.current_layout_id = None; + } + }); + } + } } -/// 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(); +fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option { + bd.default_layout_id + .or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id)) + .or_else(|| bd.layouts.first().map(|l| l.id)) +} + +/// Find which display owns a given layout_id and render it there. +fn switch_layout_anywhere(layout_id: u32) { + let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone()); + let Some(bundle) = bundle else { return }; + for bd in bundle.normalized_displays() { + if bd.layouts.iter().any(|l| l.id == layout_id) { + render_layout(bd.id, layout_id); + return; + } + } + warn!("switch_layout: layout {layout_id} not found on any display"); +} + +/// Render a specific layout id on a specific display. +fn render_layout(display_id: u32, layout_id: u32) { + mark_activity(display_id); - // 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(); @@ -367,59 +513,59 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) { }); 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) + let displays = bundle.normalized_displays(); + let Some(bd) = displays.iter().find(|d| d.id == display_id) else { + warn!("render_layout: display {display_id} not in bundle"); + return; + }; + + let layout = bd.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)) + warn!("render_layout: layout {layout_id} not on display {display_id}, falling back to default"); + bd.default_layout_id + .and_then(|did| bd.layouts.iter().find(|l| l.id == did)) + .or_else(|| bd.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); + warn!("render_layout: no usable layout on display {display_id}"); + DISPLAYS.with(|ds| { + if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + show_logo(&st.window); + st.current_layout_id = None; + } + }); return; }; + // Update per-display layout id BEFORE recomputing warm-cameras so the + // union across displays is correct. + DISPLAYS.with(|ds| { + if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + st.current_layout_id = Some(layout.id); + } + }); + + info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)", + layout.name, layout.id, display_id, layout.grid_cols, layout.grid_rows, layout.cells.len()); + 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); + recompute_warm_cameras(&bundle); + DISPLAYS.with(|ds| { + if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + show_logo(&st.window); + } + }); return; } - 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(); - for cell in &layout.cells { - if cell.content_type == "camera" { - if let Some(id) = cell.camera_id { needed.insert(id); } - } - } - for id in &layout.preload_camera_ids { needed.insert(*id); } - - // Stop pipelines for cameras no longer needed - WARM_CAMERAS.with(|w| { - let mut warm = w.borrow_mut(); - let stale: Vec = warm.keys().filter(|id| !needed.contains(id)).copied().collect(); - for id in stale { - if let Some((pipe, _, _)) = warm.remove(&id) { - info!("stopping pipeline for camera {id} (no longer needed)"); - pipeline::stop(&pipe); - } - } - }); + // Recompute warm-camera set across ALL displays (the union), then drop + // pipelines no longer needed anywhere. + recompute_warm_cameras(&bundle); let server_url = server_url.as_str(); let kiosk_key = kiosk_key.as_str(); @@ -430,13 +576,12 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) { grid.set_vexpand(true); grid.set_hexpand(true); - let cam_map: std::collections::HashMap = + let cam_map: HashMap = bundle.cameras.iter().map(|c| (c.id, c)).collect(); - // Total grid area for the heuristic let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32; - // Ensure preloaded cameras have pipelines even if not visible (use sub for warmth) + // Ensure preloaded cameras have pipelines even if not visible. for cam_id in &layout.preload_camera_ids { if let Some(cam) = cam_map.get(cam_id) { ensure_warm(*cam_id, cam, None, 0.0); @@ -458,7 +603,6 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) { }); picture.set_vexpand(true); picture.set_hexpand(true); - // Wrap in Overlay so we can stack a stream-role badge on top let overlay = gtk::Overlay::new(); overlay.set_child(Some(&picture)); overlay.set_vexpand(true); @@ -520,13 +664,40 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) { ); } - window.set_child(Some(&grid)); + DISPLAYS.with(|ds| { + if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + st.window.set_child(Some(&grid)); + } + }); } -fn clear_warm_cameras() { +/// Compute the union of cameras needed across all displays' current layouts + +/// preload sets, then drop any warm pipelines outside that set. +fn recompute_warm_cameras(bundle: &KioskBundle) { + let mut needed: std::collections::HashSet = std::collections::HashSet::new(); + let displays = bundle.normalized_displays(); + DISPLAYS.with(|ds| { + for (display_id, st) in ds.borrow().iter() { + let Some(bd) = displays.iter().find(|d| d.id == *display_id) else { continue }; + let Some(cur_id) = st.current_layout_id else { continue }; + let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) else { continue }; + for cell in &layout.cells { + if cell.content_type == "camera" { + if let Some(id) = cell.camera_id { needed.insert(id); } + } + } + for id in &layout.preload_camera_ids { needed.insert(*id); } + } + }); WARM_CAMERAS.with(|w| { - for (_, (pipe, _, _)) in w.borrow().iter() { pipeline::stop(pipe); } - w.borrow_mut().clear(); + let mut warm = w.borrow_mut(); + let stale: Vec = warm.keys().filter(|id| !needed.contains(id)).copied().collect(); + for id in stale { + if let Some((pipe, _, _)) = warm.remove(&id) { + info!("stopping pipeline for camera {id} (no longer needed)"); + pipeline::stop(&pipe); + } + } }); } @@ -568,7 +739,6 @@ fn ensure_warm( ) -> Option<(gtk::gdk::Paintable, char)> { let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?; - // Check cached: if badge matches desired, reuse. Else swap. let cached = WARM_CAMERAS.with(|w| { w.borrow().get(&cam_id).map(|(p, paint, b)| (p.clone(), paint.clone(), *b)) }); diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 7a793ce..afe238f 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -24,10 +24,14 @@ import { LayoutEditPage, DisplaysPage, DisplayEditPage, + SystemHealthPage, + NoderedEmbedPage, renderCell, renderGrid, } from "../../web-templates/admin-pages.js"; import { discover as onvifDiscover } from "../../shared/onvif.js"; +import { generateBundle } from "../../shared/bundle.js"; +import { captureSnapshot } from "../../shared/snapshot.js"; interface DiscoverAddStream { profile_name: string; @@ -252,6 +256,44 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 301, headers: { location: "/admin/" } }); }); + // ---- System Health -------------------------------------------------------- + + app.get("/admin/health", (event) => { + const user = event.context.user!; + const kiosks = deps.repo.listKiosks(); + const now = Date.now(); + let clusterKey: string | undefined; + try { + const enc = deps.repo.getSetupExtra("cluster_key_encrypted") as string | undefined; + if (enc) clusterKey = deps.secrets.decryptString(enc, "cluster"); + } catch { /* ignore */ } + + const rows = kiosks.map((k) => { + const online = k.last_seen_at + ? now - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000 + : false; + const displays = deps.repo.listDisplaysForKiosk(k.id); + let expectedBundleVersion: string | null = null; + try { + const b = generateBundle(deps.repo, deps.secrets, k.id, clusterKey); + expectedBundleVersion = b?.version ?? null; + } catch { /* ignore */ } + const bundleMismatch = + expectedBundleVersion != null + && k.last_bundle_version != null + && k.last_bundle_version !== expectedBundleVersion; + return { + kiosk: k, + online, + bundleMismatch, + expectedBundleVersion, + displays, + }; + }); + + return htmlPage(SystemHealthPage({ user: user.username, rows })); + }); + // ---- Cameras -------------------------------------------------------------- app.get("/admin/cameras", (event) => { @@ -513,6 +555,33 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); }); + // Camera snapshot — pulls one frame from the entity's main stream and + // returns it as JPEG. Used by the EntityEditPage "Test" preview. + app.get("/admin/entities/:id/snapshot", async (event) => { + const id = Number(getRouterParam(event, "id")); + const ent = deps.repo.getEntityById(id); + if (!ent || ent.type !== "camera" || ent.camera_id == null) { + return new Response("Not a camera entity", { status: 404 }); + } + const streams = deps.repo.listCameraStreams(ent.camera_id); + const main = streams.find((s) => s.role === "main") ?? streams[0]; + const cam = deps.repo.getCameraById(ent.camera_id); + const rtsp = main?.rtsp_uri ?? cam?.rtsp_url ?? null; + if (!rtsp) return new Response("No RTSP URL", { status: 404 }); + + const jpeg = await captureSnapshot(rtsp, { timeoutMs: 8000 }); + if (!jpeg) { + return new Response("Snapshot failed (camera unreachable or ffmpeg/gst missing)", { status: 502 }); + } + return new Response(jpeg, { + status: 200, + headers: { + "content-type": "image/jpeg", + "cache-control": "no-store", + }, + }); + }); + // ---- Kiosks --------------------------------------------------------------- app.get("/admin/kiosks", (event) => { @@ -1054,6 +1123,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const displays = deps.repo.listDisplaysForKiosk(id); const firstDisplay = displays[0]; const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : []; + const gpioBindings = deps.repo.listGpioBindings(id); return htmlPage(KioskEditPage({ user: user.username, kiosk, @@ -1061,9 +1131,45 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { allLabels: deps.repo.listLabels(), displays, switchableLayouts, + gpioBindings, })); }); + // ---- GPIO bindings ---------------------------------------------------- + app.post("/admin/kiosks/:id/gpio", async (event) => { + const kioskId = Number(getRouterParam(event, "id")); + const body = await readBody>(event); + const pin = Number(body?.["pin"]); + const direction = (body?.["direction"] ?? "in") === "out" ? "out" : "in"; + const pullRaw = body?.["pull"]; + const pull = pullRaw === "up" || pullRaw === "down" || pullRaw === "none" ? pullRaw : null; + const edgeRaw = body?.["edge"]; + const edge = edgeRaw === "rising" || edgeRaw === "falling" || edgeRaw === "both" ? edgeRaw : null; + const chip = (body?.["chip"] ?? "gpiochip0").trim() || "gpiochip0"; + const topic = (body?.["topic"] ?? "").trim(); + if (Number.isFinite(pin) && topic) { + deps.repo.createGpioBinding({ + kiosk_id: kioskId, + chip, + pin, + direction, + pull, + edge, + topic, + }); + notifyKiosks(); + } + return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); + }); + + app.post("/admin/kiosks/:id/gpio/:bindingId/delete", (event) => { + const kioskId = Number(getRouterParam(event, "id")); + const bindingId = Number(getRouterParam(event, "bindingId")); + deps.repo.deleteGpioBinding(bindingId); + notifyKiosks(); + return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); + }); + app.post("/admin/kiosks/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const body = await readBody>(event); @@ -1106,13 +1212,39 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); // ---- Layout switch ---------------------------------------------------- - app.post("/admin/kiosks/:id/layout/:layoutId", (event) => { + const kioskLayoutSwitch = (event: any) => { 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}` } }); + }; + app.post("/admin/kiosks/:id/layout/:layoutId", kioskLayoutSwitch); + app.get("/admin/kiosks/:id/layout/:layoutId", kioskLayoutSwitch); + + const displayLayoutSwitch = (event: any) => { + const displayId = Number(getRouterParam(event, "displayId")); + const layoutId = Number(getRouterParam(event, "layoutId")); + if (Number.isFinite(displayId) && Number.isFinite(layoutId)) { + const display = deps.repo.getDisplayById(displayId); + if (display?.kiosk_id) { + getCoordinator().sendToKiosk(display.kiosk_id, { + type: "layout-switch", + display_id: displayId, + layout_id: layoutId, + }); + } + } + return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); + }; + app.post("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); + app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); + + // Node-RED embedded page + app.get("/admin/nodered", (event) => { + const user = event.context.user!; + return htmlPage(NoderedEmbedPage({ user: user.username })); }); // ---- CEC power commands ----------------------------------------------- diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index a3a5962..ee29ea6 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -18,7 +18,11 @@ import type { EntityType, EventLog, EventSourceType, + GpioDirection, + GpioEdge, + GpioPull, Kiosk, + KioskGpioBinding, KioskLabel, Label, LabelRole, @@ -280,6 +284,22 @@ export function rowToPairingCode(r: Row): PairingCode { }; } +export function rowToKioskGpioBinding(r: Row): KioskGpioBinding { + const pullRaw = sn(r["pull"]); + const edgeRaw = sn(r["edge"]); + return { + id: n(r["id"]), + kiosk_id: n(r["kiosk_id"]), + chip: s(r["chip"]) || "gpiochip0", + pin: n(r["pin"]), + direction: s(r["direction"]) as GpioDirection, + pull: pullRaw ? (pullRaw as GpioPull) : null, + edge: edgeRaw ? (edgeRaw as GpioEdge) : null, + topic: s(r["topic"]), + created_at: s(r["created_at"]), + }; +} + export function rowToEventLog(r: Row): EventLog { return { id: n(r["id"]), diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 313e6e6..4c24608 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -601,4 +601,18 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ (db: DatabaseSync) => { addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'"); }, + + // ---- kiosk GPIO bindings ---- + `CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, + chip TEXT NOT NULL DEFAULT 'gpiochip0', + pin INTEGER NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('in', 'out')), + pull TEXT CHECK(pull IN ('up', 'down', 'none')), + edge TEXT CHECK(edge IN ('rising', 'falling', 'both')), + topic TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) STRICT`, + `CREATE INDEX IF NOT EXISTS idx_kiosk_gpio_bindings_kiosk ON kiosk_gpio_bindings(kiosk_id)`, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 320b1bd..4d1c265 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -22,7 +22,11 @@ import type { EntityType, EventLog, EventSourceType, + GpioDirection, + GpioEdge, + GpioPull, Kiosk, + KioskGpioBinding, KioskLabel, Label, LabelRole, @@ -45,6 +49,7 @@ import { rowToEntity, rowToEventLog, rowToKiosk, + rowToKioskGpioBinding, rowToLabel, rowToLayout, rowToLayoutCell, @@ -1377,6 +1382,55 @@ export class Repository { void this.notify("labels", "delete", id); } + // =========================================================================== + // kiosk GPIO bindings + // =========================================================================== + + listGpioBindings(kioskId: number): KioskGpioBinding[] { + const rs = this.prep( + "SELECT * FROM kiosk_gpio_bindings WHERE kiosk_id = ? ORDER BY chip, pin", + ).all(kioskId); + return rs.map((r) => rowToKioskGpioBinding(r as Record)); + } + + getGpioBindingById(id: number): KioskGpioBinding | null { + const r = this.prep("SELECT * FROM kiosk_gpio_bindings WHERE id = ?").get(id); + return r ? rowToKioskGpioBinding(r as Record) : null; + } + + createGpioBinding(input: { + kiosk_id: number; + chip?: string; + pin: number; + direction: GpioDirection; + pull?: GpioPull | null; + edge?: GpioEdge | null; + topic: string; + }): KioskGpioBinding { + const result = this.prep( + `INSERT INTO kiosk_gpio_bindings (kiosk_id, chip, pin, direction, pull, edge, topic) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.kiosk_id, + input.chip ?? "gpiochip0", + input.pin, + input.direction, + input.pull ?? null, + input.edge ?? null, + input.topic, + ); + const id = Number(result.lastInsertRowid); + void this.notify("kiosk_gpio_bindings", "create", id); + const b = this.getGpioBindingById(id); + if (!b) throw new Error("gpio binding vanished after insert"); + return b; + } + + deleteGpioBinding(id: number): void { + this.db.prepare(`DELETE FROM kiosk_gpio_bindings WHERE id = ?`).run(id); + void this.notify("kiosk_gpio_bindings", "delete", id); + } + updateLabel(id: number, patch: { name?: string; description?: string | null; color?: string | null }): void { const sets: string[] = []; const vals: unknown[] = []; diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index a70d71f..eaebc33 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -70,12 +70,37 @@ export interface BundleDisplay { default_layout_id: number | null; } +export interface BundleDisplayWithLayouts extends BundleDisplay { + layouts: BundleLayout[]; +} + +export interface BundleGpioBinding { + id: number; + chip: string; + pin: number; + direction: "in" | "out"; + pull: "up" | "down" | "none" | null; + edge: "rising" | "falling" | "both" | null; + topic: string; +} + export interface KioskBundle { kiosk_id: number; kiosk_name: string; + /** + * @deprecated Use `displays` (array). Kept for backward compat with older + * kiosk builds that consume a single display. Mirrors `displays[0]`. + */ display: BundleDisplay; + /** + * @deprecated Use `displays[N].layouts`. Mirrors `displays[0].layouts` for + * older kiosk builds. + */ layouts: BundleLayout[]; + /** All physical displays driven by this kiosk. New (multi-display) shape. */ + displays: BundleDisplayWithLayouts[]; cameras: BundleCamera[]; + gpio_bindings: BundleGpioBinding[]; version: string; } @@ -88,76 +113,90 @@ export function generateBundle( const kiosk = repo.getKioskById(kioskId); if (!kiosk) return null; - // Find display for this kiosk (displays now point to kiosks via kiosk_id) + // Find all displays for this kiosk (displays now point to kiosks via kiosk_id) const kioskDisplays = repo.listDisplaysForKiosk(kioskId); // Fall back to legacy kiosk.display_id if no displays point to this kiosk yet - let display = kioskDisplays[0] ?? null; - if (!display && kiosk.display_id) { - display = repo.getDisplayById(kiosk.display_id); + const displays = kioskDisplays.length > 0 + ? kioskDisplays + : (kiosk.display_id ? [repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable => d != null) : []); + + if (displays.length === 0) return null; + + // Collect camera IDs across ALL displays' layouts (de-duped). + const allLayoutIds = new Set(); + for (const d of displays) { + for (const l of repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id); } - if (!display) return null; + const cameras = repo.camerasForLayoutIds([...allLayoutIds]); - const layouts = repo.layoutsForDisplayId(display.id); - const layoutIds = layouts.map((l) => l.id); - - // Collect all cameras referenced by cells in these layouts - const cameras = repo.camerasForLayoutIds(layoutIds); - - const defaultLayoutId = display.default_layout_id; - const bundleLayouts: BundleLayout[] = layouts.map((l) => { - const cells = repo.layoutCells(l.id); - let gridCols = 1; - let gridRows = 1; - for (const c of cells) { - const right = c.col + c.col_span; - const bottom = c.row + c.row_span; - if (right > gridCols) gridCols = right; - if (bottom > gridRows) gridRows = bottom; - } - return { - id: l.id, - name: l.name, - grid_cols: gridCols, - grid_rows: gridRows, - priority: l.priority, - cooling_timeout_seconds: l.cooling_timeout_seconds, - preload_camera_ids: l.preload_camera_ids, - resets_idle_timer: l.resets_idle_timer, - is_default: defaultLayoutId === l.id, - cells: cells.map((c) => { - // If the cell has an entity, prefer its current content so admin - // edits to the entity propagate without forcing a cell-touch. The - // bundle still ships the legacy camera_id/web_url/html_content shape - // so the existing Rust kiosk consumes it unchanged. - let contentType = c.content_type; - let cameraId = c.camera_id; - let webUrl = c.web_url; - let htmlContent = c.html_content; - if (c.entity_id != null) { - const ent = repo.getEntityById(c.entity_id); - if (ent) { - contentType = ent.type; - cameraId = ent.type === "camera" ? ent.camera_id : null; - webUrl = ent.type === "web" ? ent.web_url : null; - htmlContent = ent.type === "html" ? ent.html_content : null; + function buildLayouts(displayId: number, defaultLayoutId: number | null): BundleLayout[] { + const layouts = repo.layoutsForDisplayId(displayId); + return layouts.map((l) => { + const cells = repo.layoutCells(l.id); + let gridCols = 1; + let gridRows = 1; + for (const c of cells) { + const right = c.col + c.col_span; + const bottom = c.row + c.row_span; + if (right > gridCols) gridCols = right; + if (bottom > gridRows) gridRows = bottom; + } + return { + id: l.id, + name: l.name, + grid_cols: gridCols, + grid_rows: gridRows, + priority: l.priority, + cooling_timeout_seconds: l.cooling_timeout_seconds, + preload_camera_ids: l.preload_camera_ids, + resets_idle_timer: l.resets_idle_timer, + is_default: defaultLayoutId === l.id, + cells: cells.map((c) => { + // If the cell has an entity, prefer its current content so admin + // edits to the entity propagate without forcing a cell-touch. The + // bundle still ships the legacy camera_id/web_url/html_content shape + // so the existing Rust kiosk consumes it unchanged. + let contentType = c.content_type; + let cameraId = c.camera_id; + let webUrl = c.web_url; + let htmlContent = c.html_content; + if (c.entity_id != null) { + const ent = repo.getEntityById(c.entity_id); + if (ent) { + contentType = ent.type; + cameraId = ent.type === "camera" ? ent.camera_id : null; + webUrl = ent.type === "web" ? ent.web_url : null; + htmlContent = ent.type === "html" ? ent.html_content : null; + } } - } - return { - row: c.row, - col: c.col, - row_span: c.row_span, - col_span: c.col_span, - content_type: contentType, - camera_id: cameraId, - stream_selector: c.stream_selector, - web_url: webUrl, - html_content: htmlContent, - cooling_timeout_seconds: c.cooling_timeout_seconds, - fit: c.fit, - }; - }), - }; - }); + return { + row: c.row, + col: c.col, + row_span: c.row_span, + col_span: c.col_span, + content_type: contentType, + camera_id: cameraId, + stream_selector: c.stream_selector, + web_url: webUrl, + html_content: htmlContent, + cooling_timeout_seconds: c.cooling_timeout_seconds, + fit: c.fit, + }; + }), + }; + }); + } + + const bundleDisplays: BundleDisplayWithLayouts[] = displays.map((display) => ({ + id: display.id, + name: display.name, + width_px: display.width_px, + height_px: display.height_px, + idle_timeout_seconds: display.idle_timeout_seconds, + sleep_timeout_seconds: display.sleep_timeout_seconds, + default_layout_id: display.default_layout_id, + layouts: buildLayouts(display.id, display.default_layout_id), + })); const bundleCameras: BundleCamera[] = cameras.map((cam) => { const streams = repo.listCameraStreams(cam.id); @@ -188,20 +227,36 @@ export function generateBundle( }; }); + const gpioBindings: BundleGpioBinding[] = repo.listGpioBindings(kioskId).map((g) => ({ + id: g.id, + chip: g.chip, + pin: g.pin, + direction: g.direction, + pull: g.pull, + edge: g.edge, + topic: g.topic, + })); + + // Mirror first display into the legacy top-level `display` + `layouts` so + // older kiosk builds keep working unchanged. New builds should read + // `displays`. + const primary = bundleDisplays[0]!; const bundle: KioskBundle = { kiosk_id: kioskId, kiosk_name: kiosk.name, display: { - id: display.id, - name: display.name, - width_px: display.width_px, - height_px: display.height_px, - idle_timeout_seconds: display.idle_timeout_seconds, - sleep_timeout_seconds: display.sleep_timeout_seconds, - default_layout_id: display.default_layout_id, + id: primary.id, + name: primary.name, + width_px: primary.width_px, + height_px: primary.height_px, + idle_timeout_seconds: primary.idle_timeout_seconds, + sleep_timeout_seconds: primary.sleep_timeout_seconds, + default_layout_id: primary.default_layout_id, }, - layouts: bundleLayouts, + layouts: primary.layouts, + displays: bundleDisplays, cameras: bundleCameras, + gpio_bindings: gpioBindings, version: "", }; diff --git a/server/src/shared/snapshot.ts b/server/src/shared/snapshot.ts new file mode 100644 index 0000000..8f1fe30 --- /dev/null +++ b/server/src/shared/snapshot.ts @@ -0,0 +1,123 @@ +/** + * Camera snapshot capture. + * + * Spawns ffmpeg (preferred) or gst-launch-1.0 to pull one frame from an RTSP + * URL and return it as a JPEG buffer. Bounded by a hard timeout so a stuck + * connection can't pile up subprocesses. + */ +import { spawn } from "node:child_process"; +import { unlink, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; + +const DEFAULT_TIMEOUT_MS = 8000; + +export interface SnapshotOptions { + timeoutMs?: number; +} + +/** + * Capture a single frame from an RTSP URL. Returns a JPEG buffer, or null on + * any failure (timeout, missing binary, non-zero exit, empty file). + * + * Tries ffmpeg first (fast, widely installed). Falls back to gst-launch-1.0. + */ +export async function captureSnapshot( + rtspUrl: string, + opts: SnapshotOptions = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!rtspUrl || !rtspUrl.startsWith("rtsp://")) return null; + + const result = await tryFfmpeg(rtspUrl, timeoutMs); + if (result) return result; + return tryGstLaunch(rtspUrl, timeoutMs); +} + +async function tryFfmpeg(rtspUrl: string, timeoutMs: number): Promise { + const out = join(tmpdir(), `bf-snap-${randomBytes(8).toString("hex")}.jpg`); + // -y overwrite, -rtsp_transport tcp (most reliable), -frames:v 1 single frame, + // -an no audio. Use mjpeg via filename extension. + const args = [ + "-y", + "-rtsp_transport", "tcp", + "-i", rtspUrl, + "-frames:v", "1", + "-q:v", "5", + "-an", + out, + ]; + const ok = await runWithTimeout("ffmpeg", args, timeoutMs); + if (!ok) { + void unlink(out).catch(() => undefined); + return null; + } + try { + const buf = await readFile(out); + return buf.length > 0 ? buf : null; + } catch { + return null; + } finally { + void unlink(out).catch(() => undefined); + } +} + +async function tryGstLaunch(rtspUrl: string, timeoutMs: number): Promise { + const out = join(tmpdir(), `bf-snap-${randomBytes(8).toString("hex")}.jpg`); + // rtspsrc → decodebin → videoconvert → jpegenc → single-frame filesink + const args = [ + "-q", + "rtspsrc", `location=${rtspUrl}`, "protocols=tcp", "latency=200", "!", + "decodebin", "!", + "videoconvert", "!", + "jpegenc", "!", + "filesink", `location=${out}`, + "num-buffers=1", + ]; + const ok = await runWithTimeout("gst-launch-1.0", args, timeoutMs); + if (!ok) { + void unlink(out).catch(() => undefined); + return null; + } + try { + const buf = await readFile(out); + return buf.length > 0 ? buf : null; + } catch { + return null; + } finally { + void unlink(out).catch(() => undefined); + } +} + +function runWithTimeout(bin: string, args: string[], timeoutMs: number): Promise { + return new Promise((resolve) => { + let child; + try { + child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] }); + } catch { + resolve(false); + return; + } + let settled = false; + const t = setTimeout(() => { + if (settled) return; + settled = true; + try { child.kill("SIGKILL"); } catch { /* ignore */ } + resolve(false); + }, timeoutMs); + + child.on("error", () => { + if (settled) return; + settled = true; + clearTimeout(t); + resolve(false); + }); + child.on("exit", (code) => { + if (settled) return; + settled = true; + clearTimeout(t); + resolve(code === 0); + }); + }); +} diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index e8b961f..7d18583 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -237,6 +237,22 @@ export interface PairingCode { extras: Record; } +export type GpioDirection = "in" | "out"; +export type GpioPull = "up" | "down" | "none"; +export type GpioEdge = "rising" | "falling" | "both"; + +export interface KioskGpioBinding { + id: number; + kiosk_id: number; + chip: string; + pin: number; + direction: GpioDirection; + pull: GpioPull | null; + edge: GpioEdge | null; + topic: string; + created_at: string; +} + export interface EventLog { id: number; source_kiosk_id: number | null; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 132b516..c8201ee 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -8,6 +8,7 @@ import type { Display, Entity, Kiosk, + KioskGpioBinding, Label, Layout as LayoutType, LayoutCell, @@ -691,6 +692,31 @@ export function EntityEditPage(props: EntityEditPageProps) { )} + {e.type === "camera" && e.camera_id != null && ( +
+ +
+ Camera snapshot + Snapshot failed — camera unreachable or RTSP not configured +
+
+ + Pulls one frame via ffmpeg/gst (up to ~8s). +
+
+ )} {e.type === "web" && (
@@ -1165,6 +1191,7 @@ interface KioskEditProps { allLabels: Label[]; displays?: Display[]; switchableLayouts?: LayoutType[]; + gpioBindings?: KioskGpioBinding[]; error?: string; success?: string; } @@ -1284,6 +1311,93 @@ export function KioskEditPage(props: KioskEditProps) { )}
+ {/* GPIO bindings */} +
+

GPIO Bindings

+

+ Each input binding fires an event with the configured topic when the + pin's edge triggers. Pi 5's main GPIO chip is gpiochip4; + older Pis use gpiochip0. +

+ {props.gpioBindings && props.gpioBindings.length > 0 ? ( +
+ + + + + + + + + + + + + + {props.gpioBindings.map((g) => ( + + + + + + + + + + ))} + +
ChipPinDirPullEdgeTopic
{g.chip}{String(g.pin)}{g.direction}{g.pull ?? "—"}{g.edge ?? "—"}{g.topic} +
+ +
+
+
+ ) : ( +

No GPIO bindings configured

+ )} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+

Labels

{props.labels.length > 0 ? ( @@ -1926,6 +2040,25 @@ export function DisplayEditPage(props: DisplayEditPageProps) { )}
+ {props.attachedLayouts.length > 0 && d.kiosk_id ? ( +
+
Switch Layout Now
+
+ + +
+
+ ) : null} +
@@ -2090,3 +2223,135 @@ function formatTime(iso: string): string { return iso; } } + +// ---- System Health ---------------------------------------------------------- + +interface SystemHealthRow { + kiosk: Kiosk; + online: boolean; + bundleMismatch: boolean; + expectedBundleVersion: string | null; + displays: Display[]; +} + +interface SystemHealthPageProps { + user: string; + rows: SystemHealthRow[]; +} + +function tempBadge(temp: number | null) { + if (temp == null) return ; + const txt = `${temp.toFixed(1)}°C`; + if (temp >= 80) return {txt}; + if (temp >= 70) return {txt}; + return {txt}; +} + +// ---- Node-RED Embed --------------------------------------------------- + +export function NoderedEmbedPage(props: { user: string }) { + return ( + +
+