From 3ffaf780e377c86fedb288f73a768351c10c510a Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Thu, 21 May 2026 02:03:05 +0200 Subject: [PATCH] feat(kiosk): improve display controls and health --- kiosk/src/hwmon.rs | 98 +++++ kiosk/src/local_server.rs | 25 +- kiosk/src/main.rs | 15 +- kiosk/src/server.rs | 30 +- kiosk/src/ui.rs | 373 +++++++++++++----- kiosk/src/ws_client.rs | 80 ++-- .../service-admin-http/routes-admin.ts | 34 +- server/src/plugins/service-api-http/index.ts | 18 + .../plugins/service-coordinator-ws/index.ts | 7 + server/src/plugins/service-store/mappers.ts | 6 + .../src/plugins/service-store/migrations.ts | 22 ++ .../src/plugins/service-store/repository.ts | 26 +- server/src/schemas/wire/events.ts | 6 + server/src/shared/bundle.ts | 16 +- server/src/shared/types.ts | 6 + server/src/web-templates/admin-pages.tsx | 74 +++- 16 files changed, 646 insertions(+), 190 deletions(-) diff --git a/kiosk/src/hwmon.rs b/kiosk/src/hwmon.rs index 3f57fb8..601438d 100644 --- a/kiosk/src/hwmon.rs +++ b/kiosk/src/hwmon.rs @@ -12,20 +12,36 @@ use std::fs; use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; use tracing::warn; #[derive(Debug, Clone, Default)] pub struct HwInfo { pub cpu_temp_c: Option, + pub cpu_load_percent: Option, pub fan_rpm: Option, pub fan_pwm: Option, + pub memory_total_mb: Option, + pub memory_used_mb: Option, + pub disk_total_mb: Option, + pub disk_free_mb: Option, + pub disk_used_percent: Option, } pub fn read() -> HwInfo { + let memory = read_memory(); + let disk = read_disk(); HwInfo { cpu_temp_c: read_temp(), + cpu_load_percent: read_cpu_load_percent(), fan_rpm: read_u32_in_hwmon("fan1_input"), fan_pwm: read_u32_in_hwmon("pwm1"), + memory_total_mb: memory.map(|m| m.0), + memory_used_mb: memory.map(|m| m.1), + disk_total_mb: disk.map(|d| d.0), + disk_free_mb: disk.map(|d| d.1), + disk_used_percent: disk.map(|d| d.2), } } @@ -61,6 +77,88 @@ fn read_temp() -> Option { Some(m as f32 / 1000.0) } +fn read_cpu_load_percent() -> Option { + let a = read_proc_stat_cpu()?; + std::thread::sleep(Duration::from_millis(100)); + let b = read_proc_stat_cpu()?; + let idle_delta = b.idle.saturating_sub(a.idle); + let total_delta = b.total.saturating_sub(a.total); + if total_delta == 0 { + return None; + } + Some(((total_delta - idle_delta) as f32 / total_delta as f32) * 100.0) +} + +struct CpuSample { + idle: u64, + total: u64, +} + +fn read_proc_stat_cpu() -> Option { + let raw = fs::read_to_string("/proc/stat").ok()?; + let line = raw.lines().find(|l| l.starts_with("cpu "))?; + let nums: Vec = line + .split_whitespace() + .skip(1) + .filter_map(|v| v.parse().ok()) + .collect(); + if nums.len() < 5 { + return None; + } + let idle = nums.get(3).copied().unwrap_or(0) + nums.get(4).copied().unwrap_or(0); + let total = nums.iter().copied().sum(); + Some(CpuSample { idle, total }) +} + +fn read_memory() -> Option<(u64, u64)> { + let raw = fs::read_to_string("/proc/meminfo").ok()?; + let mut total_kb = None; + let mut available_kb = None; + for line in raw.lines() { + if let Some(v) = line.strip_prefix("MemTotal:") { + total_kb = v + .split_whitespace() + .next() + .and_then(|n| n.parse::().ok()); + } else if let Some(v) = line.strip_prefix("MemAvailable:") { + available_kb = v + .split_whitespace() + .next() + .and_then(|n| n.parse::().ok()); + } + } + let total = total_kb? / 1024; + let available = available_kb? / 1024; + Some((total, total.saturating_sub(available))) +} + +fn read_disk() -> Option<(u64, u64, f32)> { + let path = if std::path::Path::new("/var/lib/betterframe").exists() { + "/var/lib/betterframe" + } else { + "/" + }; + let out = Command::new("df").args(["-kP", path]).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8(out.stdout).ok()?; + let line = text.lines().nth(1)?; + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 5 { + return None; + } + let total_mb = cols[1].parse::().ok()? / 1024; + let used_mb = cols[2].parse::().ok()? / 1024; + let free_mb = cols[3].parse::().ok()? / 1024; + let used_percent = if total_mb == 0 { + 0.0 + } else { + (used_mb as f32 / total_mb as f32) * 100.0 + }; + Some((total_mb, free_mb, used_percent)) +} + fn read_u32_in_hwmon(file: &str) -> Option { let dir = find_fan_hwmon()?; let raw = fs::read_to_string(dir.join(file)).ok()?; diff --git a/kiosk/src/local_server.rs b/kiosk/src/local_server.rs index a939cdb..ca01169 100644 --- a/kiosk/src/local_server.rs +++ b/kiosk/src/local_server.rs @@ -22,12 +22,12 @@ use std::sync::mpsc::Sender as StdSender; use std::sync::{Arc, Mutex}; use axum::{ + Json, Router, body::{Body, Bytes}, extract::{Path, Query, Request, State}, http::{HeaderMap, Method, StatusCode, Uri}, response::{IntoResponse, Response}, routing::{any, get}, - Json, Router, }; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; @@ -50,7 +50,9 @@ pub struct LocalServerState { } #[derive(Deserialize)] -pub struct LocalAuth { key: String } +pub struct LocalAuth { + key: String, +} #[derive(Serialize)] pub struct LocalInfo { @@ -122,7 +124,10 @@ async fn local_layout_handler( let Some(tx) = tx else { return (StatusCode::SERVICE_UNAVAILABLE, "ui not ready").into_response(); }; - if let Err(e) = tx.send(WorkerMsg::SwitchLayout(id)) { + if let Err(e) = tx.send(WorkerMsg::SwitchLayout { + display_id: None, + layout_id: id, + }) { warn!("local-server: send SwitchLayout failed: {e}"); return (StatusCode::INTERNAL_SERVER_ERROR, "send failed").into_response(); } @@ -187,9 +192,9 @@ async fn proxy_handler( return (StatusCode::BAD_GATEWAY, "proxy upstream body error").into_response(); } }; - builder - .body(Body::from(bytes)) - .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bad proxy response").into_response()) + builder.body(Body::from(bytes)).unwrap_or_else(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "bad proxy response").into_response() + }) } fn reqwest_method(m: &Method) -> reqwest::Method { @@ -197,9 +202,13 @@ fn reqwest_method(m: &Method) -> reqwest::Method { } fn constant_time_eq(a: &str, b: &str) -> bool { - if a.len() != b.len() { return false; } + if a.len() != b.len() { + return false; + } let mut diff = 0u8; - for (x, y) in a.bytes().zip(b.bytes()) { diff |= x ^ y; } + for (x, y) in a.bytes().zip(b.bytes()) { + diff |= x ^ y; + } diff == 0 } diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index 9ad3cb4..3057a15 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -1,4 +1,3 @@ -mod server; mod bundle; mod cec; mod firmware; @@ -6,6 +5,7 @@ mod gpio; mod hwmon; mod local_server; mod pipeline; +mod server; mod ui; mod ws_client; @@ -17,20 +17,25 @@ 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), + /// Switch to a specific layout by ID, optionally scoped to one display. + SwitchLayout { + display_id: Option, + layout_id: u32, + }, /// Server-pushed "go check for a firmware update now". FirmwareCheck, } -use gtk4::prelude::{ApplicationExt, ApplicationExtManual}; use gstreamer::prelude::PluginFeatureExtManual; +use gtk4::prelude::{ApplicationExt, ApplicationExtManual}; use tracing::info; use tracing_subscriber::EnvFilter; fn main() { tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env().add_directive("betterframe_kiosk=info".parse().unwrap())) + .with_env_filter( + EnvFilter::from_default_env().add_directive("betterframe_kiosk=info".parse().unwrap()), + ) .init(); gstreamer::init().expect("Failed to init GStreamer"); diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 6448bc2..e4a3af7 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -14,17 +14,27 @@ fn state_dir() -> PathBuf { dir } -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") } -fn local_key_file() -> PathBuf { state_dir().join("local.key") } +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") +} +fn local_key_file() -> PathBuf { + state_dir().join("local.key") +} /// Load (or generate) the kiosk-local API key used by the LAN-side GET /// layout-switch endpoint. Persisted hex, 32 bytes random. pub fn load_or_create_local_key() -> String { if let Ok(s) = fs::read_to_string(local_key_file()) { let trimmed = s.trim().to_string(); - if trimmed.len() >= 16 { return trimmed; } + if trimmed.len() >= 16 { + return trimmed; + } } use rand::RngCore; let mut buf = [0u8; 32]; @@ -255,7 +265,9 @@ pub fn heartbeat( // copy-paste URL for bookmark-style layout switches. let local_key = load_or_create_local_key(); let local_port: u16 = std::env::var("BF_KIOSK_LOCAL_PORT") - .ok().and_then(|s| s.parse().ok()).unwrap_or(18090); + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(18090); client .post(format!("{server}/api/kiosk/heartbeat")) .header("Authorization", format!("Bearer {key}")) @@ -263,8 +275,14 @@ pub fn heartbeat( "kiosk_app_version": env!("CARGO_PKG_VERSION"), "displays": display_info, "cpu_temp_c": hw.cpu_temp_c, + "cpu_load_percent": hw.cpu_load_percent, "fan_rpm": hw.fan_rpm, "fan_pwm": hw.fan_pwm, + "memory_total_mb": hw.memory_total_mb, + "memory_used_mb": hw.memory_used_mb, + "disk_total_mb": hw.disk_total_mb, + "disk_free_mb": hw.disk_free_mb, + "disk_used_percent": hw.disk_used_percent, "local_key": local_key, "local_port": local_port, })) diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 79cd003..aa289c8 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -6,19 +6,21 @@ use std::time::{Duration, Instant}; use url::Url; use gtk4::prelude::*; -use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture}; +use gtk4::{ + self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture, +}; use tracing::{info, warn}; +use crate::ServerMsg; use crate::bundle::{BundleDisplayWithLayouts, KioskBundle}; use crate::cec; -use crate::gpio; use crate::firmware; +use crate::gpio; use crate::hwmon; use crate::local_server; 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 @@ -116,7 +118,7 @@ fn activate(app: &Application) { .build(); let provider = gtk::CssProvider::new(); - provider.load_from_string("window { background-color: #000000; }"); + provider.load_from_string("window { background-color: #000000; } .kiosk-hidden-cursor, .kiosk-hidden-cursor * { cursor: none; }"); gtk::style_context_add_provider_for_display( &WidgetExt::display(&pairing_window), &provider, @@ -129,7 +131,8 @@ fn activate(app: &Application) { let (tx, rx) = mpsc::channel::(); - let server_url = std::env::var("BETTERFRAME_SERVER").ok() + let server_url = std::env::var("BETTERFRAME_SERVER") + .ok() .or_else(|| std::env::args().nth(1)); std::thread::spawn(move || { let server = server::discover_server(server_url.as_deref()); @@ -152,7 +155,11 @@ 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, {} display(s)", b.cameras.len(), b.normalized_displays().len()); + info!( + "bundle: {} cameras, {} display(s)", + b.cameras.len(), + b.normalized_displays().len() + ); Some(b) } None => { @@ -241,8 +248,14 @@ fn activate(app: &Application) { } send_heartbeat_now(&server_for_reload, &key_for_reload); } - ServerMsg::SwitchLayout(id) => { - let _ = tx_for_reload.send(WorkerMsg::SwitchLayout(id)); + ServerMsg::SwitchLayout { + display_id, + layout_id, + } => { + let _ = tx_for_reload.send(WorkerMsg::SwitchLayout { + display_id, + layout_id, + }); } ServerMsg::FirmwareCheck => { maybe_apply_firmware_update(&server_for_reload, &key_for_reload); @@ -280,8 +293,15 @@ fn activate(app: &Application) { render_bundle(&app_clone, &pairing_window_clone, bundle, &server, &key); install_idle_watchdog(); } - WorkerMsg::SwitchLayout(id) => { - switch_layout_anywhere(id); + WorkerMsg::SwitchLayout { + display_id, + layout_id, + } => { + if let Some(display_id) = display_id { + render_layout(display_id, layout_id); + } else { + switch_layout_anywhere(layout_id); + } } WorkerMsg::Wake => { cec::wake(); @@ -301,7 +321,10 @@ fn activate(app: &Application) { pub enum WorkerMsg { ShowPairingCode(String), RenderBundle(KioskBundle, String, String), - SwitchLayout(u32), + SwitchLayout { + display_id: Option, + layout_id: u32, + }, Wake, } @@ -340,7 +363,9 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) { return; } let current = env!("CARGO_PKG_VERSION"); - let Some(info) = firmware::check(server_url, kiosk_key, current) else { return }; + let Some(info) = firmware::check(server_url, kiosk_key, current) else { + return; + }; info!("firmware: update {} → {} available", current, info.version); if let Err(err) = firmware::apply(server_url, kiosk_key, &info) { warn!("firmware: apply failed: {err}"); @@ -356,7 +381,9 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) { /// 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; } + if WATCHDOG_INSTALLED.with(|c| c.get()) { + return; + } WATCHDOG_INSTALLED.with(|c| c.set(true)); gtk::glib::timeout_add_local(Duration::from_secs(1), move || { // Drop any pipelines / webviews whose cooling window has elapsed. @@ -364,24 +391,41 @@ fn install_idle_watchdog() { expire_cooling_webviews(); let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone()); - let Some(bundle) = bundle else { return gtk::glib::ControlFlow::Continue }; + let Some(bundle) = bundle else { + return gtk::glib::ControlFlow::Continue; + }; // Snapshot per-display timing decisions so we can act outside the borrow. - struct Action { display_id: u32, revert_to: Option, sleep: bool } + 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 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 }; + 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 + 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); @@ -402,11 +446,17 @@ fn install_idle_watchdog() { for a in actions { if let Some(layout_id) = a.revert_to { - info!("idle timeout reached → reverting display {} to default", a.display_id); + 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); + 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) { @@ -424,21 +474,34 @@ fn install_idle_watchdog() { /// Reads /sys/class/drm/*/status and /sys/class/drm/*/modes. fn query_displays() -> Vec<(String, u32, u32)> { let mut out = Vec::new(); - let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { return out }; + 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(); - if !name.contains("-HDMI-") && !name.contains("-DP-") { continue; } + 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; } + if status.trim() != "connected" { + continue; + } let modes = std::fs::read_to_string(path.join("modes")).unwrap_or_default(); let mode = modes.lines().next().unwrap_or(""); let parts: Vec<&str> = mode.split('x').collect(); - if parts.len() != 2 { continue; } + 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; } - let clean_name = name.split_once('-').map(|(_, rest)| rest.to_string()).unwrap_or(name); + if w == 0 || h == 0 { + continue; + } + let clean_name = name + .split_once('-') + .map(|(_, rest)| rest.to_string()) + .unwrap_or(name); out.push((clean_name, w, h)); } out @@ -453,7 +516,10 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) { let title = logo_picture(BETTERFRAME_LOGO_SVG, 360, 88, "pairing-logo"); let code_label = Label::new(Some(code)); - add_css(&code_label, ".code { font-size: 72px; color: #fff; font-weight: 700; letter-spacing: 12px; font-family: monospace; }"); + add_css( + &code_label, + ".code { font-size: 72px; color: #fff; font-weight: 700; letter-spacing: 12px; font-family: monospace; }", + ); code_label.add_css_class("code"); let hint = Label::new(Some("Enter this code in BetterFrame admin to pair")); @@ -504,7 +570,11 @@ fn render_bundle( // 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() + 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)) { @@ -530,7 +600,7 @@ fn render_bundle( .fullscreened(true) .build(); let provider = gtk::CssProvider::new(); - provider.load_from_string("window { background-color: #000000; }"); + provider.load_from_string("window { background-color: #000000; } .kiosk-hidden-cursor, .kiosk-hidden-cursor * { cursor: none; }"); gtk::style_context_add_provider_for_display( &WidgetExt::display(&w), &provider, @@ -544,12 +614,15 @@ fn render_bundle( w } }; - new_state.insert(bd.id, DisplayState { - window, - current_layout_id: None, - last_activity: Instant::now(), - is_asleep: false, - }); + 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); @@ -616,13 +689,14 @@ fn render_layout(display_id: u32, layout_id: u32) { return; }; - let layout = bd.layouts.iter().find(|l| l.id == layout_id) - .or_else(|| { - 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 layout = bd.layouts.iter().find(|l| l.id == layout_id).or_else(|| { + 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 on display {display_id}"); @@ -638,15 +712,25 @@ fn render_layout(display_id: u32, layout_id: u32) { // Update per-display layout id BEFORE recomputing warm-cameras so the // union across displays is correct. let previous_layout_id = DISPLAYS.with(|ds| { - let prev = ds.borrow().get(&display_id).and_then(|s| s.current_layout_id); + let prev = ds + .borrow() + .get(&display_id) + .and_then(|s| s.current_layout_id); if let Some(st) = ds.borrow_mut().get_mut(&display_id) { st.current_layout_id = Some(layout.id); } prev }); - info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)", - layout.name, layout.id, display_id, layout.grid_cols, layout.grid_rows, layout.cells.len()); + info!( + "rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)", + layout.name, + layout.id, + display_id, + layout.grid_cols, + layout.grid_rows, + layout.cells.len() + ); // Notify the server when the active layout actually changes so Node-RED // sees idle reverts + any other kiosk-initiated switch. Skip when the @@ -657,7 +741,13 @@ fn render_layout(display_id: u32, layout_id: u32) { let server = server_url.clone(); let key = kiosk_key.clone(); std::thread::spawn(move || { - server::report_layout_change(&server, &key, display_id, layout_id_for_report, &layout_name); + server::report_layout_change( + &server, + &key, + display_id, + layout_id_for_report, + &layout_name, + ); }); } @@ -701,10 +791,17 @@ fn render_layout(display_id: u32, layout_id: u32) { for cell in &layout.cells { let cell_key: Option = match cell.content_type.as_str() { "camera" => cell.camera_id.map(|id| { - format!("cam:{id}:{}", cell.stream_selector.as_deref().unwrap_or("auto")) + format!( + "cam:{id}:{}", + cell.stream_selector.as_deref().unwrap_or("auto") + ) }), "web" => cell.web_url.as_deref().map(|u| format!("web:{}", u.trim())), - "html" => cell.html_content.as_deref().filter(|h| !h.trim().is_empty()).map(html_key), + "html" => cell + .html_content + .as_deref() + .filter(|h| !h.trim().is_empty()) + .map(html_key), _ => None, }; let widget: gtk::Widget = match cell.content_type.as_str() { @@ -712,7 +809,9 @@ fn render_layout(display_id: u32, layout_id: u32) { if let Some(cam_id) = cell.camera_id { if let Some(cam) = cam_map.get(&cam_id) { let area = (cell.col_span * cell.row_span) as f32 / total_area; - if let Some((paintable, badge)) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) { + if let Some((paintable, badge)) = + ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) + { let picture = Picture::for_paintable(&paintable); picture.set_content_fit(match cell.fit.as_str() { "contain" => gtk::ContentFit::Contain, @@ -731,7 +830,10 @@ fn render_layout(display_id: u32, layout_id: u32) { label.set_valign(gtk::Align::Start); label.set_margin_start(4); label.set_margin_top(4); - add_css(&label, "label { background: rgba(0,0,0,0.6); color: #fff; font-size: 11px; font-weight: 600; padding: 2px 6px; border-radius: 4px; min-width: 14px; }"); + add_css( + &label, + "label { background: rgba(0,0,0,0.6); color: #fff; font-size: 11px; font-weight: 600; padding: 2px 6px; border-radius: 4px; min-width: 14px; }", + ); overlay.add_overlay(&label); } overlay.upcast() @@ -814,7 +916,13 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: >k::Grid) { if let Some(b) = c.compute_bounds(&old_child) { let paintable: gtk::gdk::Paintable = gtk::WidgetPaintable::new(Some(&c)).upcast(); - snaps.insert(key.to_string(), CellSnap { paintable, bounds: b }); + snaps.insert( + key.to_string(), + CellSnap { + paintable, + bounds: b, + }, + ); } } child = c.next_sibling(); @@ -841,9 +949,11 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: >k::Grid) { let window_weak = window.downgrade(); gtk::glib::idle_add_local_once(move || { // Swap back to plain grid as window child (drop the overlay). - if let (Some(grid), Some(win), Some(ov)) = - (new_grid_weak.upgrade(), window_weak.upgrade(), overlay_weak.upgrade()) - { + if let (Some(grid), Some(win), Some(ov)) = ( + new_grid_weak.upgrade(), + window_weak.upgrade(), + overlay_weak.upgrade(), + ) { if grid.parent().as_ref() == Some(ov.upcast_ref::()) { ov.set_child(None::<>k::Widget>); win.set_child(Some(&grid)); @@ -864,7 +974,8 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: >k::Grid) { let mut child = new_grid_clone.first_child(); while let Some(c) = child { let key = c.widget_name(); - let new_bounds = c.compute_bounds(&new_grid_clone) + let new_bounds = c + .compute_bounds(&new_grid_clone) .unwrap_or_else(gtk::graphene::Rect::zero); if !key.is_empty() { if let Some(snap) = snaps.remove(key.as_str()) { @@ -909,9 +1020,11 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: >k::Grid) { gtk::glib::timeout_add_local_once( Duration::from_millis((LAYOUT_ANIM_MS + 50) as u64), move || { - if let (Some(grid), Some(win), Some(ov)) = - (grid_weak.upgrade(), window_weak.upgrade(), overlay_weak.upgrade()) - { + if let (Some(grid), Some(win), Some(ov)) = ( + grid_weak.upgrade(), + window_weak.upgrade(), + overlay_weak.upgrade(), + ) { if grid.parent().as_ref() == Some(ov.upcast_ref::()) { ov.set_child(None::<>k::Widget>); win.set_child(Some(&grid)); @@ -939,7 +1052,9 @@ fn animate_picture_to_bounds( let fixed_weak = fixed.downgrade(); let target_weak = target.downgrade(); pic.add_tick_callback(move |_, _| { - let Some(pic) = pic_weak.upgrade() else { return gtk::glib::ControlFlow::Break; }; + let Some(pic) = pic_weak.upgrade() else { + return gtk::glib::ControlFlow::Break; + }; let elapsed = start.elapsed().as_millis() as f64; let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0); let e = ease_out_cubic(t); @@ -966,11 +1081,17 @@ fn fade_in(widget: >k::Widget) { let start = Instant::now(); let weak = widget.downgrade(); widget.add_tick_callback(move |_, _| { - let Some(w) = weak.upgrade() else { return gtk::glib::ControlFlow::Break; }; + let Some(w) = weak.upgrade() else { + return gtk::glib::ControlFlow::Break; + }; let elapsed = start.elapsed().as_millis() as f64; let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0); w.set_opacity(t); - if t >= 1.0 { gtk::glib::ControlFlow::Break } else { gtk::glib::ControlFlow::Continue } + if t >= 1.0 { + gtk::glib::ControlFlow::Break + } else { + gtk::glib::ControlFlow::Continue + } }); } @@ -979,12 +1100,16 @@ fn fade_out_and_drop(pic: >k::Picture, fixed: >k::Fixed) { let pic_weak = pic.downgrade(); let fixed_weak = fixed.downgrade(); pic.add_tick_callback(move |_, _| { - let Some(p) = pic_weak.upgrade() else { return gtk::glib::ControlFlow::Break; }; + let Some(p) = pic_weak.upgrade() else { + return gtk::glib::ControlFlow::Break; + }; let elapsed = start.elapsed().as_millis() as f64; let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0); p.set_opacity(1.0 - t); if t >= 1.0 { - if let Some(_f) = fixed_weak.upgrade() { p.unparent(); } + if let Some(_f) = fixed_weak.upgrade() { + p.unparent(); + } return gtk::glib::ControlFlow::Break; } gtk::glib::ControlFlow::Continue @@ -1017,7 +1142,10 @@ fn recompute_global_state() { // Snapshot per-display active layout id outside any borrow of WARM_CAMERAS. let active: Vec<(u32, Option)> = DISPLAYS.with(|ds| { - ds.borrow().iter().map(|(id, st)| (*id, st.current_layout_id)).collect() + ds.borrow() + .iter() + .map(|(id, st)| (*id, st.current_layout_id)) + .collect() }); // Helper: compute the pool key (camera_id, badge) for a given cell in a @@ -1030,9 +1158,15 @@ fn recompute_global_state() { ) { let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32; for cell in &layout.cells { - if cell.content_type != "camera" { continue; } - let Some(cam_id) = cell.camera_id else { continue }; - let Some(cam) = cam_map.get(&cam_id) else { continue }; + if cell.content_type != "camera" { + continue; + } + let Some(cam_id) = cell.camera_id else { + continue; + }; + let Some(cam) = cam_map.get(&cam_id) else { + continue; + }; let area = (cell.col_span * cell.row_span) as f32 / total_area; if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) { out.insert((cam_id, badge)); @@ -1051,7 +1185,10 @@ fn recompute_global_state() { } for bd in &displays { - let active_id = active.iter().find(|(id, _)| *id == bd.id).and_then(|(_, l)| *l); + let active_id = active + .iter() + .find(|(id, _)| *id == bd.id) + .and_then(|(_, l)| *l); if let Some(cur_id) = active_id { if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) { cell_keys(layout, &cam_map, &mut warm_set); @@ -1071,7 +1208,10 @@ fn recompute_global_state() { let mut warm_webs: std::collections::HashSet = std::collections::HashSet::new(); let mut hot_webs: std::collections::HashSet = std::collections::HashSet::new(); for bd in &displays { - let active_id = active.iter().find(|(id, _)| *id == bd.id).and_then(|(_, l)| *l); + let active_id = active + .iter() + .find(|(id, _)| *id == bd.id) + .and_then(|(_, l)| *l); if let Some(cur_id) = active_id { if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) { web_keys_for_layout(layout, &mut warm_webs); @@ -1084,7 +1224,9 @@ fn recompute_global_state() { } } - if max_cooling_secs == 0 { max_cooling_secs = DEFAULT_COOLING_SECS; } + if max_cooling_secs == 0 { + max_cooling_secs = DEFAULT_COOLING_SECS; + } recompute_pool_states(&warm_set, &hot_set, max_cooling_secs); recompute_web_states(&warm_webs, &hot_webs, max_cooling_secs); } @@ -1123,9 +1265,8 @@ fn recompute_pool_states( to_stop.push(entry.pipeline.clone()); } else { entry.state = WarmthState::Cooling; - entry.cooling_until = Some( - Instant::now() + Duration::from_secs(max_cooling_secs as u64), - ); + entry.cooling_until = + Some(Instant::now() + Duration::from_secs(max_cooling_secs as u64)); info!( "camera {} ({}): cooling for {}s before drop", key.0, key.1, max_cooling_secs @@ -1133,7 +1274,9 @@ fn recompute_pool_states( } } } - for k in &to_remove { warm.remove(k); } + for k in &to_remove { + warm.remove(k); + } }); for pipe in to_stop { @@ -1151,8 +1294,7 @@ fn expire_cooling_pipelines() { let keys: Vec = warm .iter() .filter(|(_, e)| { - e.state == WarmthState::Cooling - && e.cooling_until.is_some_and(|t| now >= t) + e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t) }) .map(|(k, _)| *k) .collect(); @@ -1163,7 +1305,10 @@ fn expire_cooling_pipelines() { } }); for (key, pipe) in expired { - info!("camera {} ({}): cooling expired → stopping pipeline", key.0, key.1); + info!( + "camera {} ({}): cooling expired → stopping pipeline", + key.0, key.1 + ); pipeline::stop(&pipe); } } @@ -1182,8 +1327,12 @@ fn load_webview_url(webview: &webkit6::WebView, url: &str, server_url: &str, kio } fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool { - let Ok(target) = Url::parse(url) else { return false }; - let Ok(server) = Url::parse(server_url) else { return false }; + let Ok(target) = Url::parse(url) else { + return false; + }; + let Ok(server) = Url::parse(server_url) else { + return false; + }; if target.scheme() != server.scheme() || target.host_str() != server.host_str() || target.port_or_known_default() != server.port_or_known_default() @@ -1210,14 +1359,19 @@ fn ensure_warm( let key: PoolKey = (cam_id, desired_badge); let cached = WARM_CAMERAS.with(|w| { - w.borrow().get(&key).map(|e| (e.pipeline.clone(), e.paintable.clone())) + w.borrow() + .get(&key) + .map(|e| (e.pipeline.clone(), e.paintable.clone())) }); if let Some((_pipe, paintable)) = cached { // Promote out of Cooling if we're rendering it again. WARM_CAMERAS.with(|w| { if let Some(e) = w.borrow_mut().get_mut(&key) { if e.state == WarmthState::Cooling { - info!("camera {} ({}): rescued from cooling → warm", cam_id, desired_badge); + info!( + "camera {} ({}): rescued from cooling → warm", + cam_id, desired_badge + ); e.state = WarmthState::Warm; e.cooling_until = None; } @@ -1230,12 +1384,15 @@ fn ensure_warm( let paintable = sink.property::("paintable"); pipeline::play(&pipe); WARM_CAMERAS.with(|w| { - w.borrow_mut().insert(key, PipelineEntry { - pipeline: pipe, - paintable: paintable.clone(), - state: WarmthState::Warm, - cooling_until: None, - }); + w.borrow_mut().insert( + key, + PipelineEntry { + pipeline: pipe, + paintable: paintable.clone(), + state: WarmthState::Warm, + cooling_until: None, + }, + ); }); info!("warmed pipeline for camera {cam_id} (stream: {desired_badge})"); Some((paintable, desired_badge)) @@ -1264,9 +1421,7 @@ fn ensure_web( server_url: &str, kiosk_key: &str, ) -> webkit6::WebView { - let cached = WARM_WEBVIEWS.with(|m| { - m.borrow().get(&key).map(|e| e.webview.clone()) - }); + let cached = WARM_WEBVIEWS.with(|m| m.borrow().get(&key).map(|e| e.webview.clone())); if let Some(wv) = cached { WARM_WEBVIEWS.with(|m| { if let Some(e) = m.borrow_mut().get_mut(&key) { @@ -1296,11 +1451,14 @@ fn ensure_web( } } WARM_WEBVIEWS.with(|m| { - m.borrow_mut().insert(key.clone(), WebEntry { - webview: wv.clone(), - state: WarmthState::Warm, - cooling_until: None, - }); + m.borrow_mut().insert( + key.clone(), + WebEntry { + webview: wv.clone(), + state: WarmthState::Warm, + cooling_until: None, + }, + ); }); info!("warmed webview {key}"); wv @@ -1359,16 +1517,17 @@ fn recompute_web_states( to_remove.push(key.clone()); } else { entry.state = WarmthState::Cooling; - entry.cooling_until = Some( - Instant::now() + Duration::from_secs(max_cooling_secs as u64), - ); + entry.cooling_until = + Some(Instant::now() + Duration::from_secs(max_cooling_secs as u64)); info!("webview {key}: cooling for {max_cooling_secs}s before drop"); } } } for k in &to_remove { if let Some(e) = warm.remove(k) { - if e.webview.parent().is_some() { e.webview.unparent(); } + if e.webview.parent().is_some() { + e.webview.unparent(); + } } } }); @@ -1383,14 +1542,15 @@ fn expire_cooling_webviews() { let keys: Vec = warm .iter() .filter(|(_, e)| { - e.state == WarmthState::Cooling - && e.cooling_until.is_some_and(|t| now >= t) + e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t) }) .map(|(k, _)| k.clone()) .collect(); for k in keys { if let Some(e) = warm.remove(&k) { - if e.webview.parent().is_some() { e.webview.unparent(); } + if e.webview.parent().is_some() { + e.webview.unparent(); + } expired.push(k); } } @@ -1400,13 +1560,10 @@ fn expire_cooling_webviews() { } } -/// Hide the mouse pointer on a window. Kiosks have no input device the user -/// should see — the cursor is just visual noise sitting in the middle of the -/// content. GDK's "none" cursor name maps to a hidden cursor on Wayland. +/// Hide the mouse pointer on a window. Avoid GDK's "none" cursor here because +/// some GTK/Wayland stacks render it as a small square in the top-left corner. fn hide_cursor_on(window: &ApplicationWindow) { - if let Some(cursor) = gtk::gdk::Cursor::from_name("none", None) { - window.set_cursor(Some(&cursor)); - } + window.add_css_class("kiosk-hidden-cursor"); } fn show_logo(window: &ApplicationWindow) { diff --git a/kiosk/src/ws_client.rs b/kiosk/src/ws_client.rs index d3ca326..c34fe20 100644 --- a/kiosk/src/ws_client.rs +++ b/kiosk/src/ws_client.rs @@ -46,13 +46,17 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { match msg { Ok(Message::Text(text)) => { if text.contains("\"type\":\"ping\"") { - let _ = ws.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await; + let _ = ws + .send(Message::Text(r#"{"type":"pong"}"#.to_string())) + .await; } else if text.contains("\"type\":\"onvif-soap-request\"") { - let Ok(msg) = serde_json::from_str::(&text) else { + let Ok(msg) = serde_json::from_str::(&text) + else { warn!("ws: onvif request was not valid JSON"); continue; }; - let Ok(req) = serde_json::from_value::(msg) else { + let Ok(req) = serde_json::from_value::(msg) + else { warn!("ws: onvif request missing fields"); continue; }; @@ -69,11 +73,22 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { 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)); + let msg = serde_json::from_str::(&text).ok(); + let layout_id = msg + .as_ref() + .and_then(|m| m.get("layout_id")) + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + let display_id = msg + .as_ref() + .and_then(|m| m.get("display_id")) + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + if let Some(layout_id) = layout_id { + let _ = tx.send(ServerMsg::SwitchLayout { + display_id, + layout_id, + }); } else { warn!("ws: layout-switch missing layout_id"); } @@ -82,18 +97,23 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { let _ = tx.send(ServerMsg::FirmwareCheck); } else if text.contains("\"type\":\"fan\"") { info!("ws: fan received: {text}"); - let Ok(msg) = serde_json::from_str::(&text) else { + let Ok(msg) = serde_json::from_str::(&text) + else { warn!("ws: fan command was not valid JSON"); continue; }; - let pwm: Option = if msg.get("mode").and_then(|v| v.as_str()) == Some("auto") { - None - } else if let Some(value) = msg.get("pwm").and_then(|v| v.as_u64()) { - Some(value.min(255) as u32) - } else { - warn!("ws: fan command missing mode=auto or pwm"); - continue; - }; + let pwm: Option = + if msg.get("mode").and_then(|v| v.as_str()) == Some("auto") + { + None + } else if let Some(value) = + msg.get("pwm").and_then(|v| v.as_u64()) + { + Some(value.min(255) as u32) + } else { + warn!("ws: fan command missing mode=auto or pwm"); + continue; + }; let _ = tx.send(ServerMsg::Fan(pwm)); } else { info!("ws: msg: {text}"); @@ -132,7 +152,8 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { "type": "onvif-soap-response", "request_id": req.request_id, "error": format!("kiosk ONVIF client init failed: {err}"), - }).to_string(); + }) + .to_string(); } }; @@ -143,7 +164,8 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { "type": "onvif-soap-response", "request_id": req.request_id, "error": format!("invalid ONVIF URL: {err}"), - }).to_string(); + }) + .to_string(); } }; if parsed.scheme() != "http" && parsed.scheme() != "https" { @@ -151,12 +173,19 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { "type": "onvif-soap-response", "request_id": req.request_id, "error": "ONVIF URL must use http or https", - }).to_string(); + }) + .to_string(); } let result = client .post(parsed) - .header("Content-Type", format!("application/soap+xml; charset=utf-8; action=\"{}\"", req.action)) + .header( + "Content-Type", + format!( + "application/soap+xml; charset=utf-8; action=\"{}\"", + req.action + ), + ) .header("SOAPAction", req.action) .body(req.body) .send() @@ -171,20 +200,23 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { "request_id": req.request_id, "status": status, "body": body, - }).to_string(), + }) + .to_string(), Err(err) => serde_json::json!({ "type": "onvif-soap-response", "request_id": req.request_id, "status": status, "error": format!("kiosk ONVIF response read failed: {err}"), - }).to_string(), + }) + .to_string(), } } Err(err) => serde_json::json!({ "type": "onvif-soap-response", "request_id": req.request_id, "error": format!("kiosk ONVIF request failed: {err}"), - }).to_string(), + }) + .to_string(), } } diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index ffa2426..c1a30b6 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1231,6 +1231,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const mainStream = streams.find((s) => s.role === "main"); if (mainStream) { deps.repo.updateCameraStream(mainStream.id, { rtsp_uri: rtspUrl }); + } else { + deps.repo.createCameraStream({ + camera_id: id, + role: "main", + name: "Main", + rtsp_uri: rtspUrl, + }); } } notifyKiosks(); @@ -1290,8 +1297,10 @@ 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) : []; + const displayLayouts = displays.map((display) => ({ + display, + layouts: deps.repo.listLayoutsForDisplay(display.id), + })); const gpioBindings = deps.repo.listGpioBindings(id); const firmwareReleases = deps.repo.listFirmwareReleases(); return htmlPage(KioskEditPage({ @@ -1300,7 +1309,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { labels: kioskLabels, allLabels: deps.repo.listLabels(), displays, - switchableLayouts, + displayLayouts, gpioBindings, firmwareReleases, })); @@ -1489,32 +1498,21 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); }; - 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 }); - const displays = deps.repo.listDisplaysForKiosk(id); - emitLayoutChanged(displays[0]?.id ?? null, 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) { + const attached = deps.repo.listLayoutsForDisplay(displayId); + const isAttached = attached.some((l) => l.id === layoutId); + if (display?.kiosk_id && isAttached) { getCoordinator().sendToKiosk(display.kiosk_id, { type: "layout-switch", display_id: displayId, layout_id: layoutId, }); + emitLayoutChanged(displayId, display.kiosk_id, layoutId); } - emitLayoutChanged(displayId, display?.kiosk_id ?? null, layoutId); } return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); }; diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 3ef0fa0..51735ce 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -304,8 +304,14 @@ function registerKioskRoutes( os_version?: string; displays?: Array<{ index?: number; name: string; width_px: number; height_px: number }>; cpu_temp_c?: number | null; + cpu_load_percent?: number | null; fan_rpm?: number | null; fan_pwm?: number | null; + memory_total_mb?: number | null; + memory_used_mb?: number | null; + disk_total_mb?: number | null; + disk_free_mb?: number | null; + disk_used_percent?: number | null; local_key?: string | null; local_port?: number | null; // Managed-image kiosk echoes back the version it last applied, and the @@ -326,8 +332,14 @@ function registerKioskRoutes( kiosk_app_version: body?.kiosk_app_version ?? null, os_version: body?.os_version ?? null, cpu_temp_c: body?.cpu_temp_c ?? null, + cpu_load_percent: body?.cpu_load_percent ?? null, fan_rpm: body?.fan_rpm ?? null, fan_pwm: body?.fan_pwm ?? null, + memory_total_mb: body?.memory_total_mb ?? null, + memory_used_mb: body?.memory_used_mb ?? null, + disk_total_mb: body?.disk_total_mb ?? null, + disk_free_mb: body?.disk_free_mb ?? null, + disk_used_percent: body?.disk_used_percent ?? null, local_key: body?.local_key ?? null, local_port: body?.local_port ?? null, local_last_ip: remoteIp, @@ -354,8 +366,14 @@ function registerKioskRoutes( kiosk_app_version: body?.kiosk_app_version, bundle_version: body?.bundle_version, cpu_temp_c: body?.cpu_temp_c, + cpu_load_percent: body?.cpu_load_percent, fan_rpm: body?.fan_rpm, fan_pwm: body?.fan_pwm, + memory_total_mb: body?.memory_total_mb, + memory_used_mb: body?.memory_used_mb, + disk_total_mb: body?.disk_total_mb, + disk_free_mb: body?.disk_free_mb, + disk_used_percent: body?.disk_used_percent, ip: remoteIp, }); diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index 9f66414..a3f3944 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -239,14 +239,21 @@ export class Plugin extends BSBService, typeof Event if (msg["type"] === "status") { obs.log.info("kiosk status: {data}", { data: data.toString() }); const cpu = typeof msg["cpu_temp_c"] === "number" ? msg["cpu_temp_c"] : null; + const cpuLoad = typeof msg["cpu_load_percent"] === "number" ? msg["cpu_load_percent"] : null; const fanRpm = typeof msg["fan_rpm"] === "number" ? msg["fan_rpm"] : null; const fanPwm = typeof msg["fan_pwm"] === "number" ? msg["fan_pwm"] : null; const telemetry = { kiosk_id: kiosk.id, kiosk_name: kioskData.name, cpu_temp_c: cpu, + cpu_load_percent: cpuLoad, fan_rpm: fanRpm, fan_pwm: fanPwm, + memory_total_mb: typeof msg["memory_total_mb"] === "number" ? msg["memory_total_mb"] : null, + memory_used_mb: typeof msg["memory_used_mb"] === "number" ? msg["memory_used_mb"] : null, + disk_total_mb: typeof msg["disk_total_mb"] === "number" ? msg["disk_total_mb"] : null, + disk_free_mb: typeof msg["disk_free_mb"] === "number" ? msg["disk_free_mb"] : null, + disk_used_percent: typeof msg["disk_used_percent"] === "number" ? msg["disk_used_percent"] : null, }; nodered.forward("kiosk.changed", { ...telemetry, diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index bd32997..9b27e94 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -258,8 +258,14 @@ export function rowToKiosk(r: Row): Kiosk { last_bundle_version: sn(r["last_bundle_version"]), display_id: nn(r["display_id"]), cpu_temp_c: nn(r["cpu_temp_c"]), + cpu_load_percent: nn(r["cpu_load_percent"]), fan_rpm: nn(r["fan_rpm"]), fan_pwm: nn(r["fan_pwm"]), + memory_total_mb: nn(r["memory_total_mb"]), + memory_used_mb: nn(r["memory_used_mb"]), + disk_total_mb: nn(r["disk_total_mb"]), + disk_free_mb: nn(r["disk_free_mb"]), + disk_used_percent: nn(r["disk_used_percent"]), firmware_channel: (s(r["firmware_channel"] ?? "stable")) as FirmwareChannel, firmware_target_version: sn(r["firmware_target_version"]), firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]), diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 1403ea4..7359ad0 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -593,8 +593,14 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ // ---- hwmon columns on kiosks: cpu_temp_c, fan_rpm, fan_pwm ------ (db: DatabaseSync) => { addColumnIfNotExists(db, "kiosks", "cpu_temp_c", "REAL"); + addColumnIfNotExists(db, "kiosks", "cpu_load_percent", "REAL"); addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER"); addColumnIfNotExists(db, "kiosks", "fan_pwm", "INTEGER"); + addColumnIfNotExists(db, "kiosks", "memory_total_mb", "INTEGER"); + addColumnIfNotExists(db, "kiosks", "memory_used_mb", "INTEGER"); + addColumnIfNotExists(db, "kiosks", "disk_total_mb", "INTEGER"); + addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER"); + addColumnIfNotExists(db, "kiosks", "disk_used_percent", "REAL"); }, // ---- per-cell content fit (cover|contain|fill) ---- @@ -833,4 +839,20 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "kiosks", "managed_config_applied_at", "TEXT"); addColumnIfNotExists(db, "kiosks", "managed_config_error", "TEXT"); }, + + // Backfill RTSP cameras created before camera_streams became mandatory for + // rendering. Without this, the kiosk sees a camera but no playable stream. + (db: DatabaseSync) => { + db.exec(` + INSERT INTO camera_streams (camera_id, role, name, rtsp_uri, is_discovered) + SELECT c.id, 'main', 'Main', c.rtsp_url, 0 + FROM cameras c + WHERE c.type = 'rtsp' + AND c.rtsp_url IS NOT NULL + AND c.rtsp_url != '' + AND NOT EXISTS ( + SELECT 1 FROM camera_streams s WHERE s.camera_id = c.id + ) + `); + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index d25b7ee..033f8c1 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -1025,8 +1025,14 @@ export class Repository { kiosk_app_version = NULL, os_version = NULL, cpu_temp_c = NULL, + cpu_load_percent = NULL, fan_rpm = NULL, - fan_pwm = NULL + fan_pwm = NULL, + memory_total_mb = NULL, + memory_used_mb = NULL, + disk_total_mb = NULL, + disk_free_mb = NULL, + disk_used_percent = NULL WHERE id = ?`, ).run( input.key_hash, @@ -1046,8 +1052,14 @@ export class Repository { kiosk_app_version?: string | null; os_version?: string | null; cpu_temp_c?: number | null; + cpu_load_percent?: number | null; fan_rpm?: number | null; fan_pwm?: number | null; + memory_total_mb?: number | null; + memory_used_mb?: number | null; + disk_total_mb?: number | null; + disk_free_mb?: number | null; + disk_used_percent?: number | null; local_key?: string | null; local_port?: number | null; local_last_ip?: string | null; @@ -1060,8 +1072,14 @@ export class Repository { kiosk_app_version = COALESCE(?, kiosk_app_version), os_version = COALESCE(?, os_version), cpu_temp_c = ?, + cpu_load_percent = ?, fan_rpm = ?, fan_pwm = ?, + memory_total_mb = ?, + memory_used_mb = ?, + disk_total_mb = ?, + disk_free_mb = ?, + disk_used_percent = ?, local_key = COALESCE(?, local_key), local_port = COALESCE(?, local_port), local_last_ip = COALESCE(?, local_last_ip) @@ -1072,8 +1090,14 @@ export class Repository { patch.kiosk_app_version ?? null, patch.os_version ?? null, patch.cpu_temp_c ?? null, + patch.cpu_load_percent ?? null, patch.fan_rpm ?? null, patch.fan_pwm ?? null, + patch.memory_total_mb ?? null, + patch.memory_used_mb ?? null, + patch.disk_total_mb ?? null, + patch.disk_free_mb ?? null, + patch.disk_used_percent ?? null, patch.local_key ?? null, patch.local_port ?? null, patch.local_last_ip ?? null, diff --git a/server/src/schemas/wire/events.ts b/server/src/schemas/wire/events.ts index c1c33d1..28bad8a 100644 --- a/server/src/schemas/wire/events.ts +++ b/server/src/schemas/wire/events.ts @@ -17,7 +17,13 @@ export const kioskHeartbeat = av.object( os_version: av.optional(av.string().maxLength(128)), uptime_seconds: av.optional(av.int().min(0)), cpu_load: av.optional(av.number().min(0).max(100)), + cpu_load_percent: av.optional(av.number().min(0).max(100)), + cpu_temp_c: av.optional(av.number()), memory_used_mb: av.optional(av.int().min(0)), + memory_total_mb: av.optional(av.int().min(0)), + disk_total_mb: av.optional(av.int().min(0)), + disk_free_mb: av.optional(av.int().min(0)), + disk_used_percent: av.optional(av.number().min(0).max(100)), active_layout_id: av.optional(av.int().min(1)), streams_warm: av.optional(av.int().min(0)), streams_hot: av.optional(av.int().min(0)), diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index 1f6218d..c65a59f 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -208,6 +208,20 @@ export function generateBundle( const bundleCameras: BundleCamera[] = cameras.map((cam) => { const streams = repo.listCameraStreams(cam.id); + const effectiveStreams = streams.length > 0 ? streams : ( + cam.type === "rtsp" && cam.rtsp_url + ? [{ + id: 0, + role: "main" as const, + name: "Main", + rtsp_uri: cam.rtsp_url, + width: null, + height: null, + encoding: null, + framerate: null, + }] + : [] + ); let onvifPwEncrypted: string | null = null; if (cam.onvif_password && clusterKey) { onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, clusterKey); @@ -222,7 +236,7 @@ export function generateBundle( onvif_username: cam.onvif_username, onvif_password_encrypted: onvifPwEncrypted, stream_policy: cam.stream_policy, - streams: streams.map((s) => ({ + streams: effectiveStreams.map((s) => ({ id: s.id, role: s.role, name: s.name, diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index a160360..b2ef705 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -209,8 +209,14 @@ export interface Kiosk { last_bundle_version: string | null; display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id cpu_temp_c: number | null; + cpu_load_percent: number | null; fan_rpm: number | null; fan_pwm: number | null; + memory_total_mb: number | null; + memory_used_mb: number | null; + disk_total_mb: number | null; + disk_free_mb: number | null; + disk_used_percent: number | null; firmware_channel: FirmwareChannel; firmware_target_version: string | null; firmware_last_attempt_at: string | null; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index bc8a59a..914c17c 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1316,7 +1316,7 @@ interface KioskEditProps { labels: Array<{ label_id: number; name: string; role: string }>; allLabels: Label[]; displays?: Display[]; - switchableLayouts?: LayoutType[]; + displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>; gpioBindings?: KioskGpioBinding[]; firmwareReleases?: FirmwareRelease[]; error?: string; @@ -1556,24 +1556,37 @@ export function KioskEditPage(props: KioskEditProps) { >Standby - {props.switchableLayouts && props.switchableLayouts.length > 0 ? ( + {props.displayLayouts && props.displayLayouts.length > 0 ? (
-
Switch Layout
-
- - +
Switch Layout By Display
+
+ {props.displayLayouts.map(({ display, layouts }) => ( +
+
+ {display.name} +
{String(display.width_px)}x{String(display.height_px)}
+
+ {layouts.length > 0 ? ( + + ) : ( + No attached layouts + )} + +
+ )).join("")}
) : null} @@ -1583,6 +1596,9 @@ export function KioskEditPage(props: KioskEditProps) {
CPU: {k.cpu_temp_c != null ? `${k.cpu_temp_c.toFixed(1)}°C` : "—"}
Fan: {k.fan_rpm != null ? `${k.fan_rpm} RPM` : "—"}
+
CPU Load: {percentText(k.cpu_load_percent)}
+
RAM: {mbPair(k.memory_used_mb, k.memory_total_mb)}
+
Disk: {k.disk_free_mb != null && k.disk_total_mb != null ? `${String(k.disk_free_mb)} MB free / ${String(k.disk_total_mb)} MB` : "—"} {k.disk_used_percent != null ? `(${k.disk_used_percent.toFixed(1)}%)` : ""}
PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}
@@ -2683,6 +2699,15 @@ function tempBadge(temp: number | null) { return {txt}; } +function percentText(value: number | null): string { + return value == null ? "—" : `${value.toFixed(1)}%`; +} + +function mbPair(used: number | null, total: number | null): string { + if (used == null || total == null) return "—"; + return `${String(used)} / ${String(total)} MB`; +} + // ---- Node-RED Embed --------------------------------------------------- export function NoderedEmbedPage(props: { user: string }) { @@ -2736,6 +2761,9 @@ export function SystemHealthPage(props: SystemHealthPageProps) { Status Last Seen CPU Temp + CPU Load + RAM + Disk Fan Bundle Displays @@ -2743,7 +2771,7 @@ export function SystemHealthPage(props: SystemHealthPageProps) { {props.rows.length === 0 ? ( - No kiosks paired + No kiosks paired ) : ( props.rows.map((row) => { const k = row.kiosk; @@ -2757,6 +2785,14 @@ export function SystemHealthPage(props: SystemHealthPageProps) { {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"} {tempBadge(k.cpu_temp_c)} + {percentText(k.cpu_load_percent)} + {mbPair(k.memory_used_mb, k.memory_total_mb)} + + {k.disk_free_mb != null && k.disk_total_mb != null + ? `${String(k.disk_free_mb)} MB free / ${String(k.disk_total_mb)} MB` + : "—"} + {k.disk_used_percent != null ? ({k.disk_used_percent.toFixed(1)}%) : ""} + {k.fan_rpm != null ? `${String(k.fan_rpm)} RPM` : "—"} {k.fan_pwm != null && (