feat(kiosk): improve display controls and health

This commit is contained in:
Mitchell R 2026-05-21 02:03:05 +02:00
parent 251b076b99
commit 3ffaf780e3
No known key found for this signature in database
16 changed files with 646 additions and 190 deletions

View file

@ -12,20 +12,36 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use tracing::warn; use tracing::warn;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct HwInfo { pub struct HwInfo {
pub cpu_temp_c: Option<f32>, pub cpu_temp_c: Option<f32>,
pub cpu_load_percent: Option<f32>,
pub fan_rpm: Option<u32>, pub fan_rpm: Option<u32>,
pub fan_pwm: Option<u32>, pub fan_pwm: Option<u32>,
pub memory_total_mb: Option<u64>,
pub memory_used_mb: Option<u64>,
pub disk_total_mb: Option<u64>,
pub disk_free_mb: Option<u64>,
pub disk_used_percent: Option<f32>,
} }
pub fn read() -> HwInfo { pub fn read() -> HwInfo {
let memory = read_memory();
let disk = read_disk();
HwInfo { HwInfo {
cpu_temp_c: read_temp(), cpu_temp_c: read_temp(),
cpu_load_percent: read_cpu_load_percent(),
fan_rpm: read_u32_in_hwmon("fan1_input"), fan_rpm: read_u32_in_hwmon("fan1_input"),
fan_pwm: read_u32_in_hwmon("pwm1"), 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<f32> {
Some(m as f32 / 1000.0) Some(m as f32 / 1000.0)
} }
fn read_cpu_load_percent() -> Option<f32> {
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<CpuSample> {
let raw = fs::read_to_string("/proc/stat").ok()?;
let line = raw.lines().find(|l| l.starts_with("cpu "))?;
let nums: Vec<u64> = 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::<u64>().ok());
} else if let Some(v) = line.strip_prefix("MemAvailable:") {
available_kb = v
.split_whitespace()
.next()
.and_then(|n| n.parse::<u64>().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::<u64>().ok()? / 1024;
let used_mb = cols[2].parse::<u64>().ok()? / 1024;
let free_mb = cols[3].parse::<u64>().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<u32> { fn read_u32_in_hwmon(file: &str) -> Option<u32> {
let dir = find_fan_hwmon()?; let dir = find_fan_hwmon()?;
let raw = fs::read_to_string(dir.join(file)).ok()?; let raw = fs::read_to_string(dir.join(file)).ok()?;

View file

@ -22,12 +22,12 @@ use std::sync::mpsc::Sender as StdSender;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use axum::{ use axum::{
Json, Router,
body::{Body, Bytes}, body::{Body, Bytes},
extract::{Path, Query, Request, State}, extract::{Path, Query, Request, State},
http::{HeaderMap, Method, StatusCode, Uri}, http::{HeaderMap, Method, StatusCode, Uri},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{any, get}, routing::{any, get},
Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{info, warn}; use tracing::{info, warn};
@ -50,7 +50,9 @@ pub struct LocalServerState {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LocalAuth { key: String } pub struct LocalAuth {
key: String,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct LocalInfo { pub struct LocalInfo {
@ -122,7 +124,10 @@ async fn local_layout_handler(
let Some(tx) = tx else { let Some(tx) = tx else {
return (StatusCode::SERVICE_UNAVAILABLE, "ui not ready").into_response(); 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}"); warn!("local-server: send SwitchLayout failed: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "send failed").into_response(); 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(); return (StatusCode::BAD_GATEWAY, "proxy upstream body error").into_response();
} }
}; };
builder builder.body(Body::from(bytes)).unwrap_or_else(|_| {
.body(Body::from(bytes)) (StatusCode::INTERNAL_SERVER_ERROR, "bad proxy response").into_response()
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bad proxy response").into_response()) })
} }
fn reqwest_method(m: &Method) -> reqwest::Method { 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 { 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; 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 == 0
} }

View file

@ -1,4 +1,3 @@
mod server;
mod bundle; mod bundle;
mod cec; mod cec;
mod firmware; mod firmware;
@ -6,6 +5,7 @@ mod gpio;
mod hwmon; mod hwmon;
mod local_server; mod local_server;
mod pipeline; mod pipeline;
mod server;
mod ui; mod ui;
mod ws_client; mod ws_client;
@ -17,20 +17,25 @@ pub enum ServerMsg {
Wake, Wake,
/// Some(0..=255) = manual PWM. None = restore auto. /// Some(0..=255) = manual PWM. None = restore auto.
Fan(Option<u32>), Fan(Option<u32>),
/// Switch to a specific layout by ID (must be present in current bundle). /// Switch to a specific layout by ID, optionally scoped to one display.
SwitchLayout(u32), SwitchLayout {
display_id: Option<u32>,
layout_id: u32,
},
/// Server-pushed "go check for a firmware update now". /// Server-pushed "go check for a firmware update now".
FirmwareCheck, FirmwareCheck,
} }
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
use gstreamer::prelude::PluginFeatureExtManual; use gstreamer::prelude::PluginFeatureExtManual;
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
use tracing::info; use tracing::info;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
fn main() { fn main() {
tracing_subscriber::fmt() 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(); .init();
gstreamer::init().expect("Failed to init GStreamer"); gstreamer::init().expect("Failed to init GStreamer");

View file

@ -14,17 +14,27 @@ fn state_dir() -> PathBuf {
dir dir
} }
fn key_file() -> PathBuf { state_dir().join("kiosk.key") } fn key_file() -> PathBuf {
fn server_file() -> PathBuf { state_dir().join("server.url") } state_dir().join("kiosk.key")
fn bundle_cache_path() -> PathBuf { state_dir().join("bundle.json") } }
fn local_key_file() -> PathBuf { state_dir().join("local.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 /// Load (or generate) the kiosk-local API key used by the LAN-side GET
/// layout-switch endpoint. Persisted hex, 32 bytes random. /// layout-switch endpoint. Persisted hex, 32 bytes random.
pub fn load_or_create_local_key() -> String { pub fn load_or_create_local_key() -> String {
if let Ok(s) = fs::read_to_string(local_key_file()) { if let Ok(s) = fs::read_to_string(local_key_file()) {
let trimmed = s.trim().to_string(); let trimmed = s.trim().to_string();
if trimmed.len() >= 16 { return trimmed; } if trimmed.len() >= 16 {
return trimmed;
}
} }
use rand::RngCore; use rand::RngCore;
let mut buf = [0u8; 32]; let mut buf = [0u8; 32];
@ -255,7 +265,9 @@ pub fn heartbeat(
// copy-paste URL for bookmark-style layout switches. // copy-paste URL for bookmark-style layout switches.
let local_key = load_or_create_local_key(); let local_key = load_or_create_local_key();
let local_port: u16 = std::env::var("BF_KIOSK_LOCAL_PORT") 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 client
.post(format!("{server}/api/kiosk/heartbeat")) .post(format!("{server}/api/kiosk/heartbeat"))
.header("Authorization", format!("Bearer {key}")) .header("Authorization", format!("Bearer {key}"))
@ -263,8 +275,14 @@ pub fn heartbeat(
"kiosk_app_version": env!("CARGO_PKG_VERSION"), "kiosk_app_version": env!("CARGO_PKG_VERSION"),
"displays": display_info, "displays": display_info,
"cpu_temp_c": hw.cpu_temp_c, "cpu_temp_c": hw.cpu_temp_c,
"cpu_load_percent": hw.cpu_load_percent,
"fan_rpm": hw.fan_rpm, "fan_rpm": hw.fan_rpm,
"fan_pwm": hw.fan_pwm, "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_key": local_key,
"local_port": local_port, "local_port": local_port,
})) }))

View file

@ -6,19 +6,21 @@ use std::time::{Duration, Instant};
use url::Url; use url::Url;
use gtk4::prelude::*; 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 tracing::{info, warn};
use crate::ServerMsg;
use crate::bundle::{BundleDisplayWithLayouts, KioskBundle}; use crate::bundle::{BundleDisplayWithLayouts, KioskBundle};
use crate::cec; use crate::cec;
use crate::gpio;
use crate::firmware; use crate::firmware;
use crate::gpio;
use crate::hwmon; use crate::hwmon;
use crate::local_server; use crate::local_server;
use crate::pipeline; use crate::pipeline;
use crate::server; use crate::server;
use crate::ws_client; use crate::ws_client;
use crate::ServerMsg;
/// Per-display runtime state. Kept inside a thread-local hashmap keyed by /// 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 /// display id, so all the idle/sleep/layout tracking is local to that display
@ -116,7 +118,7 @@ fn activate(app: &Application) {
.build(); .build();
let provider = gtk::CssProvider::new(); 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( gtk::style_context_add_provider_for_display(
&WidgetExt::display(&pairing_window), &WidgetExt::display(&pairing_window),
&provider, &provider,
@ -129,7 +131,8 @@ fn activate(app: &Application) {
let (tx, rx) = mpsc::channel::<WorkerMsg>(); let (tx, rx) = mpsc::channel::<WorkerMsg>();
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)); .or_else(|| std::env::args().nth(1));
std::thread::spawn(move || { std::thread::spawn(move || {
let server = server::discover_server(server_url.as_deref()); 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. // cached on-disk bundle and keep retrying every 30s in the background.
let initial = match server::fetch_bundle(&server, &key) { let initial = match server::fetch_bundle(&server, &key) {
Some(b) => { 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) Some(b)
} }
None => { None => {
@ -241,8 +248,14 @@ fn activate(app: &Application) {
} }
send_heartbeat_now(&server_for_reload, &key_for_reload); send_heartbeat_now(&server_for_reload, &key_for_reload);
} }
ServerMsg::SwitchLayout(id) => { ServerMsg::SwitchLayout {
let _ = tx_for_reload.send(WorkerMsg::SwitchLayout(id)); display_id,
layout_id,
} => {
let _ = tx_for_reload.send(WorkerMsg::SwitchLayout {
display_id,
layout_id,
});
} }
ServerMsg::FirmwareCheck => { ServerMsg::FirmwareCheck => {
maybe_apply_firmware_update(&server_for_reload, &key_for_reload); 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); render_bundle(&app_clone, &pairing_window_clone, bundle, &server, &key);
install_idle_watchdog(); install_idle_watchdog();
} }
WorkerMsg::SwitchLayout(id) => { WorkerMsg::SwitchLayout {
switch_layout_anywhere(id); 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 => { WorkerMsg::Wake => {
cec::wake(); cec::wake();
@ -301,7 +321,10 @@ fn activate(app: &Application) {
pub enum WorkerMsg { pub enum WorkerMsg {
ShowPairingCode(String), ShowPairingCode(String),
RenderBundle(KioskBundle, String, String), RenderBundle(KioskBundle, String, String),
SwitchLayout(u32), SwitchLayout {
display_id: Option<u32>,
layout_id: u32,
},
Wake, Wake,
} }
@ -340,7 +363,9 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) {
return; return;
} }
let current = env!("CARGO_PKG_VERSION"); 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); info!("firmware: update {} → {} available", current, info.version);
if let Err(err) = firmware::apply(server_url, kiosk_key, &info) { if let Err(err) = firmware::apply(server_url, kiosk_key, &info) {
warn!("firmware: apply failed: {err}"); 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 /// Install the once-per-second watchdog that enforces idle/sleep timeouts
/// per display. Safe to call multiple times — installs at most once. /// per display. Safe to call multiple times — installs at most once.
fn install_idle_watchdog() { 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)); WATCHDOG_INSTALLED.with(|c| c.set(true));
gtk::glib::timeout_add_local(Duration::from_secs(1), move || { gtk::glib::timeout_add_local(Duration::from_secs(1), move || {
// Drop any pipelines / webviews whose cooling window has elapsed. // Drop any pipelines / webviews whose cooling window has elapsed.
@ -364,24 +391,41 @@ fn install_idle_watchdog() {
expire_cooling_webviews(); expire_cooling_webviews();
let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone()); 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. // Snapshot per-display timing decisions so we can act outside the borrow.
struct Action { display_id: u32, revert_to: Option<u32>, sleep: bool } struct Action {
display_id: u32,
revert_to: Option<u32>,
sleep: bool,
}
let mut actions: Vec<Action> = Vec::new(); let mut actions: Vec<Action> = Vec::new();
DISPLAYS.with(|ds| { DISPLAYS.with(|ds| {
for (display_id, st) in ds.borrow().iter() { 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 idle_to = d.idle_timeout_seconds as u64;
let sleep_to = d.sleep_timeout_seconds as u64; let sleep_to = d.sleep_timeout_seconds as u64;
let elapsed = st.last_activity.elapsed(); let elapsed = st.last_activity.elapsed();
let default_id = d.default_layout_id; 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) { 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)) .and_then(|cur_id| d.layouts.iter().find(|l| l.id == cur_id))
.map(|l| l.resets_idle_timer) .map(|l| l.resets_idle_timer)
.unwrap_or(false); .unwrap_or(false);
@ -402,11 +446,17 @@ fn install_idle_watchdog() {
for a in actions { for a in actions {
if let Some(layout_id) = a.revert_to { 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); render_layout(a.display_id, layout_id);
} }
if a.sleep { 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(); cec::standby();
DISPLAYS.with(|ds| { DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&a.display_id) { 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. /// Reads /sys/class/drm/*/status and /sys/class/drm/*/modes.
fn query_displays() -> Vec<(String, u32, u32)> { fn query_displays() -> Vec<(String, u32, u32)> {
let mut out = Vec::new(); 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() { for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string(); 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 path = entry.path();
let status = std::fs::read_to_string(path.join("status")).unwrap_or_default(); 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 modes = std::fs::read_to_string(path.join("modes")).unwrap_or_default();
let mode = modes.lines().next().unwrap_or(""); let mode = modes.lines().next().unwrap_or("");
let parts: Vec<&str> = mode.split('x').collect(); 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 w: u32 = parts[0].parse().unwrap_or(0);
let h: u32 = parts[1].trim().parse().unwrap_or(0); let h: u32 = parts[1].trim().parse().unwrap_or(0);
if w == 0 || h == 0 { continue; } if w == 0 || h == 0 {
let clean_name = name.split_once('-').map(|(_, rest)| rest.to_string()).unwrap_or(name); continue;
}
let clean_name = name
.split_once('-')
.map(|(_, rest)| rest.to_string())
.unwrap_or(name);
out.push((clean_name, w, h)); out.push((clean_name, w, h));
} }
out 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 title = logo_picture(BETTERFRAME_LOGO_SVG, 360, 88, "pairing-logo");
let code_label = Label::new(Some(code)); 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"); code_label.add_css_class("code");
let hint = Label::new(Some("Enter this code in BetterFrame admin to pair")); 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. // Tear down any previous per-display windows we no longer need.
let keep_ids: std::collections::HashSet<u32> = displays.iter().map(|d| d.id).collect(); let keep_ids: std::collections::HashSet<u32> = displays.iter().map(|d| d.id).collect();
let to_remove: Vec<u32> = DISPLAYS.with(|ds| { let to_remove: Vec<u32> = 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 { for id in to_remove {
if let Some(st) = DISPLAYS.with(|ds| ds.borrow_mut().remove(&id)) { if let Some(st) = DISPLAYS.with(|ds| ds.borrow_mut().remove(&id)) {
@ -530,7 +600,7 @@ fn render_bundle(
.fullscreened(true) .fullscreened(true)
.build(); .build();
let provider = gtk::CssProvider::new(); 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( gtk::style_context_add_provider_for_display(
&WidgetExt::display(&w), &WidgetExt::display(&w),
&provider, &provider,
@ -544,12 +614,15 @@ fn render_bundle(
w w
} }
}; };
new_state.insert(bd.id, DisplayState { new_state.insert(
window, bd.id,
current_layout_id: None, DisplayState {
last_activity: Instant::now(), window,
is_asleep: false, current_layout_id: None,
}); last_activity: Instant::now(),
is_asleep: false,
},
);
} }
DISPLAYS.with(|ds| *ds.borrow_mut() = new_state); DISPLAYS.with(|ds| *ds.borrow_mut() = new_state);
@ -616,13 +689,14 @@ fn render_layout(display_id: u32, layout_id: u32) {
return; return;
}; };
let layout = bd.layouts.iter().find(|l| l.id == layout_id) let layout = bd.layouts.iter().find(|l| l.id == layout_id).or_else(|| {
.or_else(|| { warn!(
warn!("render_layout: layout {layout_id} not on display {display_id}, falling back to default"); "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)) bd.default_layout_id
.or_else(|| bd.layouts.iter().find(|l| l.is_default)) .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 { let Some(layout) = layout else {
warn!("render_layout: no usable layout on display {display_id}"); 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 // Update per-display layout id BEFORE recomputing warm-cameras so the
// union across displays is correct. // union across displays is correct.
let previous_layout_id = DISPLAYS.with(|ds| { 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) { if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
st.current_layout_id = Some(layout.id); st.current_layout_id = Some(layout.id);
} }
prev prev
}); });
info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)", info!(
layout.name, layout.id, display_id, layout.grid_cols, layout.grid_rows, layout.cells.len()); "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 // Notify the server when the active layout actually changes so Node-RED
// sees idle reverts + any other kiosk-initiated switch. Skip when the // 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 server = server_url.clone();
let key = kiosk_key.clone(); let key = kiosk_key.clone();
std::thread::spawn(move || { 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 { for cell in &layout.cells {
let cell_key: Option<String> = match cell.content_type.as_str() { let cell_key: Option<String> = match cell.content_type.as_str() {
"camera" => cell.camera_id.map(|id| { "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())), "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, _ => None,
}; };
let widget: gtk::Widget = match cell.content_type.as_str() { 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_id) = cell.camera_id {
if let Some(cam) = cam_map.get(&cam_id) { if let Some(cam) = cam_map.get(&cam_id) {
let area = (cell.col_span * cell.row_span) as f32 / total_area; 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); let picture = Picture::for_paintable(&paintable);
picture.set_content_fit(match cell.fit.as_str() { picture.set_content_fit(match cell.fit.as_str() {
"contain" => gtk::ContentFit::Contain, "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_valign(gtk::Align::Start);
label.set_margin_start(4); label.set_margin_start(4);
label.set_margin_top(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.add_overlay(&label);
} }
overlay.upcast() overlay.upcast()
@ -814,7 +916,13 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: &gtk::Grid) {
if let Some(b) = c.compute_bounds(&old_child) { if let Some(b) = c.compute_bounds(&old_child) {
let paintable: gtk::gdk::Paintable = let paintable: gtk::gdk::Paintable =
gtk::WidgetPaintable::new(Some(&c)).upcast(); 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(); child = c.next_sibling();
@ -841,9 +949,11 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: &gtk::Grid) {
let window_weak = window.downgrade(); let window_weak = window.downgrade();
gtk::glib::idle_add_local_once(move || { gtk::glib::idle_add_local_once(move || {
// Swap back to plain grid as window child (drop the overlay). // Swap back to plain grid as window child (drop the overlay).
if let (Some(grid), Some(win), Some(ov)) = if let (Some(grid), Some(win), Some(ov)) = (
(new_grid_weak.upgrade(), window_weak.upgrade(), overlay_weak.upgrade()) new_grid_weak.upgrade(),
{ window_weak.upgrade(),
overlay_weak.upgrade(),
) {
if grid.parent().as_ref() == Some(ov.upcast_ref::<gtk::Widget>()) { if grid.parent().as_ref() == Some(ov.upcast_ref::<gtk::Widget>()) {
ov.set_child(None::<&gtk::Widget>); ov.set_child(None::<&gtk::Widget>);
win.set_child(Some(&grid)); win.set_child(Some(&grid));
@ -864,7 +974,8 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: &gtk::Grid) {
let mut child = new_grid_clone.first_child(); let mut child = new_grid_clone.first_child();
while let Some(c) = child { while let Some(c) = child {
let key = c.widget_name(); 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); .unwrap_or_else(gtk::graphene::Rect::zero);
if !key.is_empty() { if !key.is_empty() {
if let Some(snap) = snaps.remove(key.as_str()) { if let Some(snap) = snaps.remove(key.as_str()) {
@ -909,9 +1020,11 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: &gtk::Grid) {
gtk::glib::timeout_add_local_once( gtk::glib::timeout_add_local_once(
Duration::from_millis((LAYOUT_ANIM_MS + 50) as u64), Duration::from_millis((LAYOUT_ANIM_MS + 50) as u64),
move || { move || {
if let (Some(grid), Some(win), Some(ov)) = if let (Some(grid), Some(win), Some(ov)) = (
(grid_weak.upgrade(), window_weak.upgrade(), overlay_weak.upgrade()) grid_weak.upgrade(),
{ window_weak.upgrade(),
overlay_weak.upgrade(),
) {
if grid.parent().as_ref() == Some(ov.upcast_ref::<gtk::Widget>()) { if grid.parent().as_ref() == Some(ov.upcast_ref::<gtk::Widget>()) {
ov.set_child(None::<&gtk::Widget>); ov.set_child(None::<&gtk::Widget>);
win.set_child(Some(&grid)); win.set_child(Some(&grid));
@ -939,7 +1052,9 @@ fn animate_picture_to_bounds(
let fixed_weak = fixed.downgrade(); let fixed_weak = fixed.downgrade();
let target_weak = target.downgrade(); let target_weak = target.downgrade();
pic.add_tick_callback(move |_, _| { 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 elapsed = start.elapsed().as_millis() as f64;
let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0); let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0);
let e = ease_out_cubic(t); let e = ease_out_cubic(t);
@ -966,11 +1081,17 @@ fn fade_in(widget: &gtk::Widget) {
let start = Instant::now(); let start = Instant::now();
let weak = widget.downgrade(); let weak = widget.downgrade();
widget.add_tick_callback(move |_, _| { 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 elapsed = start.elapsed().as_millis() as f64;
let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0); let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0);
w.set_opacity(t); 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: &gtk::Picture, fixed: &gtk::Fixed) {
let pic_weak = pic.downgrade(); let pic_weak = pic.downgrade();
let fixed_weak = fixed.downgrade(); let fixed_weak = fixed.downgrade();
pic.add_tick_callback(move |_, _| { 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 elapsed = start.elapsed().as_millis() as f64;
let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0); let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0);
p.set_opacity(1.0 - t); p.set_opacity(1.0 - t);
if t >= 1.0 { 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; return gtk::glib::ControlFlow::Break;
} }
gtk::glib::ControlFlow::Continue gtk::glib::ControlFlow::Continue
@ -1017,7 +1142,10 @@ fn recompute_global_state() {
// Snapshot per-display active layout id outside any borrow of WARM_CAMERAS. // Snapshot per-display active layout id outside any borrow of WARM_CAMERAS.
let active: Vec<(u32, Option<u32>)> = DISPLAYS.with(|ds| { let active: Vec<(u32, Option<u32>)> = 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 // 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; let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
for cell in &layout.cells { for cell in &layout.cells {
if cell.content_type != "camera" { continue; } if cell.content_type != "camera" {
let Some(cam_id) = cell.camera_id else { continue }; continue;
let Some(cam) = cam_map.get(&cam_id) else { 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; 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) { if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) {
out.insert((cam_id, badge)); out.insert((cam_id, badge));
@ -1051,7 +1185,10 @@ fn recompute_global_state() {
} }
for bd in &displays { 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(cur_id) = active_id {
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) { if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
cell_keys(layout, &cam_map, &mut warm_set); cell_keys(layout, &cam_map, &mut warm_set);
@ -1071,7 +1208,10 @@ fn recompute_global_state() {
let mut warm_webs: std::collections::HashSet<WebKey> = std::collections::HashSet::new(); let mut warm_webs: std::collections::HashSet<WebKey> = std::collections::HashSet::new();
let mut hot_webs: std::collections::HashSet<WebKey> = std::collections::HashSet::new(); let mut hot_webs: std::collections::HashSet<WebKey> = std::collections::HashSet::new();
for bd in &displays { 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(cur_id) = active_id {
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) { if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
web_keys_for_layout(layout, &mut warm_webs); 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_pool_states(&warm_set, &hot_set, max_cooling_secs);
recompute_web_states(&warm_webs, &hot_webs, 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()); to_stop.push(entry.pipeline.clone());
} else { } else {
entry.state = WarmthState::Cooling; entry.state = WarmthState::Cooling;
entry.cooling_until = Some( entry.cooling_until =
Instant::now() + Duration::from_secs(max_cooling_secs as u64), Some(Instant::now() + Duration::from_secs(max_cooling_secs as u64));
);
info!( info!(
"camera {} ({}): cooling for {}s before drop", "camera {} ({}): cooling for {}s before drop",
key.0, key.1, max_cooling_secs 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 { for pipe in to_stop {
@ -1151,8 +1294,7 @@ fn expire_cooling_pipelines() {
let keys: Vec<PoolKey> = warm let keys: Vec<PoolKey> = warm
.iter() .iter()
.filter(|(_, e)| { .filter(|(_, e)| {
e.state == WarmthState::Cooling e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t)
&& e.cooling_until.is_some_and(|t| now >= t)
}) })
.map(|(k, _)| *k) .map(|(k, _)| *k)
.collect(); .collect();
@ -1163,7 +1305,10 @@ fn expire_cooling_pipelines() {
} }
}); });
for (key, pipe) in expired { 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); 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 { fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool {
let Ok(target) = Url::parse(url) else { return false }; let Ok(target) = Url::parse(url) else {
let Ok(server) = Url::parse(server_url) else { return false }; return false;
};
let Ok(server) = Url::parse(server_url) else {
return false;
};
if target.scheme() != server.scheme() if target.scheme() != server.scheme()
|| target.host_str() != server.host_str() || target.host_str() != server.host_str()
|| target.port_or_known_default() != server.port_or_known_default() || 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 key: PoolKey = (cam_id, desired_badge);
let cached = WARM_CAMERAS.with(|w| { 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 { if let Some((_pipe, paintable)) = cached {
// Promote out of Cooling if we're rendering it again. // Promote out of Cooling if we're rendering it again.
WARM_CAMERAS.with(|w| { WARM_CAMERAS.with(|w| {
if let Some(e) = w.borrow_mut().get_mut(&key) { if let Some(e) = w.borrow_mut().get_mut(&key) {
if e.state == WarmthState::Cooling { 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.state = WarmthState::Warm;
e.cooling_until = None; e.cooling_until = None;
} }
@ -1230,12 +1384,15 @@ fn ensure_warm(
let paintable = sink.property::<gtk::gdk::Paintable>("paintable"); let paintable = sink.property::<gtk::gdk::Paintable>("paintable");
pipeline::play(&pipe); pipeline::play(&pipe);
WARM_CAMERAS.with(|w| { WARM_CAMERAS.with(|w| {
w.borrow_mut().insert(key, PipelineEntry { w.borrow_mut().insert(
pipeline: pipe, key,
paintable: paintable.clone(), PipelineEntry {
state: WarmthState::Warm, pipeline: pipe,
cooling_until: None, paintable: paintable.clone(),
}); state: WarmthState::Warm,
cooling_until: None,
},
);
}); });
info!("warmed pipeline for camera {cam_id} (stream: {desired_badge})"); info!("warmed pipeline for camera {cam_id} (stream: {desired_badge})");
Some((paintable, desired_badge)) Some((paintable, desired_badge))
@ -1264,9 +1421,7 @@ fn ensure_web(
server_url: &str, server_url: &str,
kiosk_key: &str, kiosk_key: &str,
) -> webkit6::WebView { ) -> webkit6::WebView {
let cached = WARM_WEBVIEWS.with(|m| { let cached = WARM_WEBVIEWS.with(|m| m.borrow().get(&key).map(|e| e.webview.clone()));
m.borrow().get(&key).map(|e| e.webview.clone())
});
if let Some(wv) = cached { if let Some(wv) = cached {
WARM_WEBVIEWS.with(|m| { WARM_WEBVIEWS.with(|m| {
if let Some(e) = m.borrow_mut().get_mut(&key) { if let Some(e) = m.borrow_mut().get_mut(&key) {
@ -1296,11 +1451,14 @@ fn ensure_web(
} }
} }
WARM_WEBVIEWS.with(|m| { WARM_WEBVIEWS.with(|m| {
m.borrow_mut().insert(key.clone(), WebEntry { m.borrow_mut().insert(
webview: wv.clone(), key.clone(),
state: WarmthState::Warm, WebEntry {
cooling_until: None, webview: wv.clone(),
}); state: WarmthState::Warm,
cooling_until: None,
},
);
}); });
info!("warmed webview {key}"); info!("warmed webview {key}");
wv wv
@ -1359,16 +1517,17 @@ fn recompute_web_states(
to_remove.push(key.clone()); to_remove.push(key.clone());
} else { } else {
entry.state = WarmthState::Cooling; entry.state = WarmthState::Cooling;
entry.cooling_until = Some( entry.cooling_until =
Instant::now() + Duration::from_secs(max_cooling_secs as u64), Some(Instant::now() + Duration::from_secs(max_cooling_secs as u64));
);
info!("webview {key}: cooling for {max_cooling_secs}s before drop"); info!("webview {key}: cooling for {max_cooling_secs}s before drop");
} }
} }
} }
for k in &to_remove { for k in &to_remove {
if let Some(e) = warm.remove(k) { 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<WebKey> = warm let keys: Vec<WebKey> = warm
.iter() .iter()
.filter(|(_, e)| { .filter(|(_, e)| {
e.state == WarmthState::Cooling e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t)
&& e.cooling_until.is_some_and(|t| now >= t)
}) })
.map(|(k, _)| k.clone()) .map(|(k, _)| k.clone())
.collect(); .collect();
for k in keys { for k in keys {
if let Some(e) = warm.remove(&k) { 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); 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 /// Hide the mouse pointer on a window. Avoid GDK's "none" cursor here because
/// should see — the cursor is just visual noise sitting in the middle of the /// some GTK/Wayland stacks render it as a small square in the top-left corner.
/// content. GDK's "none" cursor name maps to a hidden cursor on Wayland.
fn hide_cursor_on(window: &ApplicationWindow) { fn hide_cursor_on(window: &ApplicationWindow) {
if let Some(cursor) = gtk::gdk::Cursor::from_name("none", None) { window.add_css_class("kiosk-hidden-cursor");
window.set_cursor(Some(&cursor));
}
} }
fn show_logo(window: &ApplicationWindow) { fn show_logo(window: &ApplicationWindow) {

View file

@ -46,13 +46,17 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
match msg { match msg {
Ok(Message::Text(text)) => { Ok(Message::Text(text)) => {
if text.contains("\"type\":\"ping\"") { 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\"") { } else if text.contains("\"type\":\"onvif-soap-request\"") {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) else { let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text)
else {
warn!("ws: onvif request was not valid JSON"); warn!("ws: onvif request was not valid JSON");
continue; continue;
}; };
let Ok(req) = serde_json::from_value::<OnvifSoapRequest>(msg) else { let Ok(req) = serde_json::from_value::<OnvifSoapRequest>(msg)
else {
warn!("ws: onvif request missing fields"); warn!("ws: onvif request missing fields");
continue; continue;
}; };
@ -69,11 +73,22 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
let _ = tx.send(ServerMsg::Wake); let _ = tx.send(ServerMsg::Wake);
} else if text.contains("\"type\":\"layout-switch\"") { } else if text.contains("\"type\":\"layout-switch\"") {
info!("ws: layout-switch received: {text}"); info!("ws: layout-switch received: {text}");
let layout_id: Option<u32> = text.split("\"layout_id\":").nth(1) let msg = serde_json::from_str::<serde_json::Value>(&text).ok();
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next()) let layout_id = msg
.and_then(|s| s.parse::<u32>().ok()); .as_ref()
if let Some(id) = layout_id { .and_then(|m| m.get("layout_id"))
let _ = tx.send(ServerMsg::SwitchLayout(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 { } else {
warn!("ws: layout-switch missing layout_id"); warn!("ws: layout-switch missing layout_id");
} }
@ -82,18 +97,23 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
let _ = tx.send(ServerMsg::FirmwareCheck); let _ = tx.send(ServerMsg::FirmwareCheck);
} else if text.contains("\"type\":\"fan\"") { } else if text.contains("\"type\":\"fan\"") {
info!("ws: fan received: {text}"); info!("ws: fan received: {text}");
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) else { let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text)
else {
warn!("ws: fan command was not valid JSON"); warn!("ws: fan command was not valid JSON");
continue; continue;
}; };
let pwm: Option<u32> = if msg.get("mode").and_then(|v| v.as_str()) == Some("auto") { let pwm: Option<u32> =
None if msg.get("mode").and_then(|v| v.as_str()) == Some("auto")
} else if let Some(value) = msg.get("pwm").and_then(|v| v.as_u64()) { {
Some(value.min(255) as u32) None
} else { } else if let Some(value) =
warn!("ws: fan command missing mode=auto or pwm"); msg.get("pwm").and_then(|v| v.as_u64())
continue; {
}; Some(value.min(255) as u32)
} else {
warn!("ws: fan command missing mode=auto or pwm");
continue;
};
let _ = tx.send(ServerMsg::Fan(pwm)); let _ = tx.send(ServerMsg::Fan(pwm));
} else { } else {
info!("ws: msg: {text}"); info!("ws: msg: {text}");
@ -132,7 +152,8 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
"type": "onvif-soap-response", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"error": format!("kiosk ONVIF client init failed: {err}"), "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", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"error": format!("invalid ONVIF URL: {err}"), "error": format!("invalid ONVIF URL: {err}"),
}).to_string(); })
.to_string();
} }
}; };
if parsed.scheme() != "http" && parsed.scheme() != "https" { if parsed.scheme() != "http" && parsed.scheme() != "https" {
@ -151,12 +173,19 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
"type": "onvif-soap-response", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"error": "ONVIF URL must use http or https", "error": "ONVIF URL must use http or https",
}).to_string(); })
.to_string();
} }
let result = client let result = client
.post(parsed) .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) .header("SOAPAction", req.action)
.body(req.body) .body(req.body)
.send() .send()
@ -171,20 +200,23 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
"request_id": req.request_id, "request_id": req.request_id,
"status": status, "status": status,
"body": body, "body": body,
}).to_string(), })
.to_string(),
Err(err) => serde_json::json!({ Err(err) => serde_json::json!({
"type": "onvif-soap-response", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"status": status, "status": status,
"error": format!("kiosk ONVIF response read failed: {err}"), "error": format!("kiosk ONVIF response read failed: {err}"),
}).to_string(), })
.to_string(),
} }
} }
Err(err) => serde_json::json!({ Err(err) => serde_json::json!({
"type": "onvif-soap-response", "type": "onvif-soap-response",
"request_id": req.request_id, "request_id": req.request_id,
"error": format!("kiosk ONVIF request failed: {err}"), "error": format!("kiosk ONVIF request failed: {err}"),
}).to_string(), })
.to_string(),
} }
} }

View file

@ -1231,6 +1231,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const mainStream = streams.find((s) => s.role === "main"); const mainStream = streams.find((s) => s.role === "main");
if (mainStream) { if (mainStream) {
deps.repo.updateCameraStream(mainStream.id, { rtsp_uri: rtspUrl }); deps.repo.updateCameraStream(mainStream.id, { rtsp_uri: rtspUrl });
} else {
deps.repo.createCameraStream({
camera_id: id,
role: "main",
name: "Main",
rtsp_uri: rtspUrl,
});
} }
} }
notifyKiosks(); notifyKiosks();
@ -1290,8 +1297,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
role: kl.role, role: kl.role,
})); }));
const displays = deps.repo.listDisplaysForKiosk(id); const displays = deps.repo.listDisplaysForKiosk(id);
const firstDisplay = displays[0]; const displayLayouts = displays.map((display) => ({
const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : []; display,
layouts: deps.repo.listLayoutsForDisplay(display.id),
}));
const gpioBindings = deps.repo.listGpioBindings(id); const gpioBindings = deps.repo.listGpioBindings(id);
const firmwareReleases = deps.repo.listFirmwareReleases(); const firmwareReleases = deps.repo.listFirmwareReleases();
return htmlPage(KioskEditPage({ return htmlPage(KioskEditPage({
@ -1300,7 +1309,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
labels: kioskLabels, labels: kioskLabels,
allLabels: deps.repo.listLabels(), allLabels: deps.repo.listLabels(),
displays, displays,
switchableLayouts, displayLayouts,
gpioBindings, gpioBindings,
firmwareReleases, 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 displayLayoutSwitch = (event: any) => {
const displayId = Number(getRouterParam(event, "displayId")); const displayId = Number(getRouterParam(event, "displayId"));
const layoutId = Number(getRouterParam(event, "layoutId")); const layoutId = Number(getRouterParam(event, "layoutId"));
if (Number.isFinite(displayId) && Number.isFinite(layoutId)) { if (Number.isFinite(displayId) && Number.isFinite(layoutId)) {
const display = deps.repo.getDisplayById(displayId); 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, { getCoordinator().sendToKiosk(display.kiosk_id, {
type: "layout-switch", type: "layout-switch",
display_id: displayId, display_id: displayId,
layout_id: layoutId, 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}` } }); return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } });
}; };

View file

@ -304,8 +304,14 @@ function registerKioskRoutes(
os_version?: string; os_version?: string;
displays?: Array<{ index?: number; name: string; width_px: number; height_px: number }>; displays?: Array<{ index?: number; name: string; width_px: number; height_px: number }>;
cpu_temp_c?: number | null; cpu_temp_c?: number | null;
cpu_load_percent?: number | null;
fan_rpm?: number | null; fan_rpm?: number | null;
fan_pwm?: 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_key?: string | null;
local_port?: number | null; local_port?: number | null;
// Managed-image kiosk echoes back the version it last applied, and the // 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, kiosk_app_version: body?.kiosk_app_version ?? null,
os_version: body?.os_version ?? null, os_version: body?.os_version ?? null,
cpu_temp_c: body?.cpu_temp_c ?? null, cpu_temp_c: body?.cpu_temp_c ?? null,
cpu_load_percent: body?.cpu_load_percent ?? null,
fan_rpm: body?.fan_rpm ?? null, fan_rpm: body?.fan_rpm ?? null,
fan_pwm: body?.fan_pwm ?? 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_key: body?.local_key ?? null,
local_port: body?.local_port ?? null, local_port: body?.local_port ?? null,
local_last_ip: remoteIp, local_last_ip: remoteIp,
@ -354,8 +366,14 @@ function registerKioskRoutes(
kiosk_app_version: body?.kiosk_app_version, kiosk_app_version: body?.kiosk_app_version,
bundle_version: body?.bundle_version, bundle_version: body?.bundle_version,
cpu_temp_c: body?.cpu_temp_c, cpu_temp_c: body?.cpu_temp_c,
cpu_load_percent: body?.cpu_load_percent,
fan_rpm: body?.fan_rpm, fan_rpm: body?.fan_rpm,
fan_pwm: body?.fan_pwm, 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, ip: remoteIp,
}); });

View file

@ -239,14 +239,21 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
if (msg["type"] === "status") { if (msg["type"] === "status") {
obs.log.info("kiosk status: {data}", { data: data.toString() }); obs.log.info("kiosk status: {data}", { data: data.toString() });
const cpu = typeof msg["cpu_temp_c"] === "number" ? msg["cpu_temp_c"] : null; 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 fanRpm = typeof msg["fan_rpm"] === "number" ? msg["fan_rpm"] : null;
const fanPwm = typeof msg["fan_pwm"] === "number" ? msg["fan_pwm"] : null; const fanPwm = typeof msg["fan_pwm"] === "number" ? msg["fan_pwm"] : null;
const telemetry = { const telemetry = {
kiosk_id: kiosk.id, kiosk_id: kiosk.id,
kiosk_name: kioskData.name, kiosk_name: kioskData.name,
cpu_temp_c: cpu, cpu_temp_c: cpu,
cpu_load_percent: cpuLoad,
fan_rpm: fanRpm, fan_rpm: fanRpm,
fan_pwm: fanPwm, 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", { nodered.forward("kiosk.changed", {
...telemetry, ...telemetry,

View file

@ -258,8 +258,14 @@ export function rowToKiosk(r: Row): Kiosk {
last_bundle_version: sn(r["last_bundle_version"]), last_bundle_version: sn(r["last_bundle_version"]),
display_id: nn(r["display_id"]), display_id: nn(r["display_id"]),
cpu_temp_c: nn(r["cpu_temp_c"]), cpu_temp_c: nn(r["cpu_temp_c"]),
cpu_load_percent: nn(r["cpu_load_percent"]),
fan_rpm: nn(r["fan_rpm"]), fan_rpm: nn(r["fan_rpm"]),
fan_pwm: nn(r["fan_pwm"]), 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_channel: (s(r["firmware_channel"] ?? "stable")) as FirmwareChannel,
firmware_target_version: sn(r["firmware_target_version"]), firmware_target_version: sn(r["firmware_target_version"]),
firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]), firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]),

View file

@ -593,8 +593,14 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
// ---- hwmon columns on kiosks: cpu_temp_c, fan_rpm, fan_pwm ------ // ---- hwmon columns on kiosks: cpu_temp_c, fan_rpm, fan_pwm ------
(db: DatabaseSync) => { (db: DatabaseSync) => {
addColumnIfNotExists(db, "kiosks", "cpu_temp_c", "REAL"); addColumnIfNotExists(db, "kiosks", "cpu_temp_c", "REAL");
addColumnIfNotExists(db, "kiosks", "cpu_load_percent", "REAL");
addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER"); addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER");
addColumnIfNotExists(db, "kiosks", "fan_pwm", "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) ---- // ---- 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_applied_at", "TEXT");
addColumnIfNotExists(db, "kiosks", "managed_config_error", "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
)
`);
},
]; ];

View file

@ -1025,8 +1025,14 @@ export class Repository {
kiosk_app_version = NULL, kiosk_app_version = NULL,
os_version = NULL, os_version = NULL,
cpu_temp_c = NULL, cpu_temp_c = NULL,
cpu_load_percent = NULL,
fan_rpm = 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 = ?`, WHERE id = ?`,
).run( ).run(
input.key_hash, input.key_hash,
@ -1046,8 +1052,14 @@ export class Repository {
kiosk_app_version?: string | null; kiosk_app_version?: string | null;
os_version?: string | null; os_version?: string | null;
cpu_temp_c?: number | null; cpu_temp_c?: number | null;
cpu_load_percent?: number | null;
fan_rpm?: number | null; fan_rpm?: number | null;
fan_pwm?: 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_key?: string | null;
local_port?: number | null; local_port?: number | null;
local_last_ip?: string | null; local_last_ip?: string | null;
@ -1060,8 +1072,14 @@ export class Repository {
kiosk_app_version = COALESCE(?, kiosk_app_version), kiosk_app_version = COALESCE(?, kiosk_app_version),
os_version = COALESCE(?, os_version), os_version = COALESCE(?, os_version),
cpu_temp_c = ?, cpu_temp_c = ?,
cpu_load_percent = ?,
fan_rpm = ?, fan_rpm = ?,
fan_pwm = ?, fan_pwm = ?,
memory_total_mb = ?,
memory_used_mb = ?,
disk_total_mb = ?,
disk_free_mb = ?,
disk_used_percent = ?,
local_key = COALESCE(?, local_key), local_key = COALESCE(?, local_key),
local_port = COALESCE(?, local_port), local_port = COALESCE(?, local_port),
local_last_ip = COALESCE(?, local_last_ip) local_last_ip = COALESCE(?, local_last_ip)
@ -1072,8 +1090,14 @@ export class Repository {
patch.kiosk_app_version ?? null, patch.kiosk_app_version ?? null,
patch.os_version ?? null, patch.os_version ?? null,
patch.cpu_temp_c ?? null, patch.cpu_temp_c ?? null,
patch.cpu_load_percent ?? null,
patch.fan_rpm ?? null, patch.fan_rpm ?? null,
patch.fan_pwm ?? 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_key ?? null,
patch.local_port ?? null, patch.local_port ?? null,
patch.local_last_ip ?? null, patch.local_last_ip ?? null,

View file

@ -17,7 +17,13 @@ export const kioskHeartbeat = av.object(
os_version: av.optional(av.string().maxLength(128)), os_version: av.optional(av.string().maxLength(128)),
uptime_seconds: av.optional(av.int().min(0)), uptime_seconds: av.optional(av.int().min(0)),
cpu_load: av.optional(av.number().min(0).max(100)), 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_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)), active_layout_id: av.optional(av.int().min(1)),
streams_warm: av.optional(av.int().min(0)), streams_warm: av.optional(av.int().min(0)),
streams_hot: av.optional(av.int().min(0)), streams_hot: av.optional(av.int().min(0)),

View file

@ -208,6 +208,20 @@ export function generateBundle(
const bundleCameras: BundleCamera[] = cameras.map((cam) => { const bundleCameras: BundleCamera[] = cameras.map((cam) => {
const streams = repo.listCameraStreams(cam.id); 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; let onvifPwEncrypted: string | null = null;
if (cam.onvif_password && clusterKey) { if (cam.onvif_password && clusterKey) {
onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, clusterKey); onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, clusterKey);
@ -222,7 +236,7 @@ export function generateBundle(
onvif_username: cam.onvif_username, onvif_username: cam.onvif_username,
onvif_password_encrypted: onvifPwEncrypted, onvif_password_encrypted: onvifPwEncrypted,
stream_policy: cam.stream_policy, stream_policy: cam.stream_policy,
streams: streams.map((s) => ({ streams: effectiveStreams.map((s) => ({
id: s.id, id: s.id,
role: s.role, role: s.role,
name: s.name, name: s.name,

View file

@ -209,8 +209,14 @@ export interface Kiosk {
last_bundle_version: string | null; last_bundle_version: string | null;
display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id
cpu_temp_c: number | null; cpu_temp_c: number | null;
cpu_load_percent: number | null;
fan_rpm: number | null; fan_rpm: number | null;
fan_pwm: 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_channel: FirmwareChannel;
firmware_target_version: string | null; firmware_target_version: string | null;
firmware_last_attempt_at: string | null; firmware_last_attempt_at: string | null;

View file

@ -1316,7 +1316,7 @@ interface KioskEditProps {
labels: Array<{ label_id: number; name: string; role: string }>; labels: Array<{ label_id: number; name: string; role: string }>;
allLabels: Label[]; allLabels: Label[];
displays?: Display[]; displays?: Display[];
switchableLayouts?: LayoutType[]; displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>;
gpioBindings?: KioskGpioBinding[]; gpioBindings?: KioskGpioBinding[];
firmwareReleases?: FirmwareRelease[]; firmwareReleases?: FirmwareRelease[];
error?: string; error?: string;
@ -1556,24 +1556,37 @@ export function KioskEditPage(props: KioskEditProps) {
>Standby</button> >Standby</button>
</div> </div>
{props.switchableLayouts && props.switchableLayouts.length > 0 ? ( {props.displayLayouts && props.displayLayouts.length > 0 ? (
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee"> <div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout</div> <div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout By Display</div>
<div style="display:flex; gap:0.5rem; align-items:center"> <div style="display:grid; gap:0.75rem">
<select id={`kiosk-layout-pick-${String(k.id)}`} class="form-input" style="flex:1"> {props.displayLayouts.map(({ display, layouts }) => (
{props.switchableLayouts.map((l) => ( <div style="display:grid; grid-template-columns:minmax(130px, 0.8fr) minmax(180px, 1fr) auto; gap:0.5rem; align-items:center">
<option value={String(l.id)}>{l.name}</option> <div style="font-size:0.85rem">
))} <a href={`/admin/displays/${String(display.id)}`}><strong>{display.name}</strong></a>
</select> <div style="color:#666; font-size:0.75rem">{String(display.width_px)}x{String(display.height_px)}</div>
<button </div>
type="button" {layouts.length > 0 ? (
class="btn btn-sm" <select id={`display-layout-pick-${String(display.id)}`} class="form-input">
{...{ {layouts.map((l) => (
"hx-post": `/admin/kiosks/${String(k.id)}/layout/0`, <option value={String(l.id)}>{l.name}</option>
"hx-swap": "none", ))}
"hx-on::config-request": `event.detail.path = event.detail.path.replace(/\\/layout\\/.*/, '/layout/' + document.getElementById('kiosk-layout-pick-${String(k.id)}').value);`, </select>
}} ) : (
>Switch</button> <span style="color:#999; font-size:0.85rem">No attached layouts</span>
)}
<button
type="button"
class="btn btn-sm"
disabled={layouts.length === 0}
{...{
"hx-post": `/admin/displays/${String(display.id)}/layout/0`,
"hx-swap": "none",
"hx-on::config-request": `event.detail.path = event.detail.path.replace(/\\/layout\\/.*/, '/layout/' + document.getElementById('display-layout-pick-${String(display.id)}').value);`,
}}
>Switch</button>
</div>
)).join("")}
</div> </div>
</div> </div>
) : null} ) : null}
@ -1583,6 +1596,9 @@ export function KioskEditPage(props: KioskEditProps) {
<div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666; margin-bottom:0.75rem"> <div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666; margin-bottom:0.75rem">
<div>CPU: {k.cpu_temp_c != null ? `${k.cpu_temp_c.toFixed(1)}°C` : "—"}</div> <div>CPU: {k.cpu_temp_c != null ? `${k.cpu_temp_c.toFixed(1)}°C` : "—"}</div>
<div>Fan: {k.fan_rpm != null ? `${k.fan_rpm} RPM` : "—"}</div> <div>Fan: {k.fan_rpm != null ? `${k.fan_rpm} RPM` : "—"}</div>
<div>CPU Load: {percentText(k.cpu_load_percent)}</div>
<div>RAM: {mbPair(k.memory_used_mb, k.memory_total_mb)}</div>
<div>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)}%)` : ""}</div>
<div>PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}</div> <div>PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}</div>
</div> </div>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap"> <div style="display:flex; gap:0.5rem; flex-wrap:wrap">
@ -2683,6 +2699,15 @@ function tempBadge(temp: number | null) {
return <span class="badge badge-green">{txt}</span>; return <span class="badge badge-green">{txt}</span>;
} }
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 --------------------------------------------------- // ---- Node-RED Embed ---------------------------------------------------
export function NoderedEmbedPage(props: { user: string }) { export function NoderedEmbedPage(props: { user: string }) {
@ -2736,6 +2761,9 @@ export function SystemHealthPage(props: SystemHealthPageProps) {
<th>Status</th> <th>Status</th>
<th>Last Seen</th> <th>Last Seen</th>
<th>CPU Temp</th> <th>CPU Temp</th>
<th>CPU Load</th>
<th>RAM</th>
<th>Disk</th>
<th>Fan</th> <th>Fan</th>
<th>Bundle</th> <th>Bundle</th>
<th>Displays</th> <th>Displays</th>
@ -2743,7 +2771,7 @@ export function SystemHealthPage(props: SystemHealthPageProps) {
</thead> </thead>
<tbody> <tbody>
{props.rows.length === 0 ? ( {props.rows.length === 0 ? (
<tr><td colspan="7" style="text-align:center; color:#999; padding:2rem">No kiosks paired</td></tr> <tr><td colspan="10" style="text-align:center; color:#999; padding:2rem">No kiosks paired</td></tr>
) : ( ) : (
props.rows.map((row) => { props.rows.map((row) => {
const k = row.kiosk; const k = row.kiosk;
@ -2757,6 +2785,14 @@ export function SystemHealthPage(props: SystemHealthPageProps) {
</td> </td>
<td style="font-size:0.85rem; white-space:nowrap">{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</td> <td style="font-size:0.85rem; white-space:nowrap">{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</td>
<td>{tempBadge(k.cpu_temp_c)}</td> <td>{tempBadge(k.cpu_temp_c)}</td>
<td style="font-size:0.85rem">{percentText(k.cpu_load_percent)}</td>
<td style="font-size:0.85rem">{mbPair(k.memory_used_mb, k.memory_total_mb)}</td>
<td style="font-size:0.85rem">
{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 ? <span style="color:#999"> ({k.disk_used_percent.toFixed(1)}%)</span> : ""}
</td>
<td style="font-size:0.85rem"> <td style="font-size:0.85rem">
{k.fan_rpm != null ? `${String(k.fan_rpm)} RPM` : "—"} {k.fan_rpm != null ? `${String(k.fan_rpm)} RPM` : "—"}
{k.fan_pwm != null && ( {k.fan_pwm != null && (