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::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<f32>,
pub cpu_load_percent: Option<f32>,
pub fan_rpm: 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 {
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<f32> {
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> {
let dir = find_fan_hwmon()?;
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 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
}

View file

@ -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<u32>),
/// 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<u32>,
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");

View file

@ -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,
}))

View file

@ -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::<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));
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<u32>,
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<u32>, sleep: bool }
struct Action {
display_id: u32,
revert_to: Option<u32>,
sleep: bool,
}
let mut actions: Vec<Action> = 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<u32> = displays.iter().map(|d| d.id).collect();
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 {
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 {
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,9 +689,10 @@ 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");
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))
@ -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<String> = 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: &gtk::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: &gtk::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::<gtk::Widget>()) {
ov.set_child(None::<&gtk::Widget>);
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();
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: &gtk::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::<gtk::Widget>()) {
ov.set_child(None::<&gtk::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: &gtk::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: &gtk::Picture, fixed: &gtk::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<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
@ -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<WebKey> = std::collections::HashSet::new();
let mut hot_webs: std::collections::HashSet<WebKey> = 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<PoolKey> = 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::<gtk::gdk::Paintable>("paintable");
pipeline::play(&pipe);
WARM_CAMERAS.with(|w| {
w.borrow_mut().insert(key, PipelineEntry {
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 {
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<WebKey> = 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) {

View file

@ -46,13 +46,17 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
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::<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");
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");
continue;
};
@ -69,11 +73,22 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
let _ = tx.send(ServerMsg::Wake);
} else if text.contains("\"type\":\"layout-switch\"") {
info!("ws: layout-switch received: {text}");
let layout_id: Option<u32> = text.split("\"layout_id\":").nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|s| s.parse::<u32>().ok());
if let Some(id) = layout_id {
let _ = tx.send(ServerMsg::SwitchLayout(id));
let msg = serde_json::from_str::<serde_json::Value>(&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,13 +97,18 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
let _ = tx.send(ServerMsg::FirmwareCheck);
} else if text.contains("\"type\":\"fan\"") {
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");
continue;
};
let pwm: Option<u32> = if msg.get("mode").and_then(|v| v.as_str()) == Some("auto") {
let pwm: Option<u32> =
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()) {
} 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");
@ -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(),
}
}

View file

@ -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}` } });
};

View file

@ -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,
});

View file

@ -239,14 +239,21 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, 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,

View file

@ -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"]),

View file

@ -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
)
`);
},
];

View file

@ -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,

View file

@ -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)),

View file

@ -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,

View file

@ -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;

View file

@ -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,25 +1556,38 @@ export function KioskEditPage(props: KioskEditProps) {
>Standby</button>
</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="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout</div>
<div style="display:flex; gap:0.5rem; align-items:center">
<select id={`kiosk-layout-pick-${String(k.id)}`} class="form-input" style="flex:1">
{props.switchableLayouts.map((l) => (
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout By Display</div>
<div style="display:grid; gap:0.75rem">
{props.displayLayouts.map(({ display, layouts }) => (
<div style="display:grid; grid-template-columns:minmax(130px, 0.8fr) minmax(180px, 1fr) auto; gap:0.5rem; align-items:center">
<div style="font-size:0.85rem">
<a href={`/admin/displays/${String(display.id)}`}><strong>{display.name}</strong></a>
<div style="color:#666; font-size:0.75rem">{String(display.width_px)}x{String(display.height_px)}</div>
</div>
{layouts.length > 0 ? (
<select id={`display-layout-pick-${String(display.id)}`} class="form-input">
{layouts.map((l) => (
<option value={String(l.id)}>{l.name}</option>
))}
</select>
) : (
<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/kiosks/${String(k.id)}/layout/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('kiosk-layout-pick-${String(k.id)}').value);`,
"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>
) : 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>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>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>
<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>;
}
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) {
<th>Status</th>
<th>Last Seen</th>
<th>CPU Temp</th>
<th>CPU Load</th>
<th>RAM</th>
<th>Disk</th>
<th>Fan</th>
<th>Bundle</th>
<th>Displays</th>
@ -2743,7 +2771,7 @@ export function SystemHealthPage(props: SystemHealthPageProps) {
</thead>
<tbody>
{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) => {
const k = row.kiosk;
@ -2757,6 +2785,14 @@ export function SystemHealthPage(props: SystemHealthPageProps) {
</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 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">
{k.fan_rpm != null ? `${String(k.fan_rpm)} RPM` : "—"}
{k.fan_pwm != null && (