mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
feat(kiosk): improve display controls and health
This commit is contained in:
parent
251b076b99
commit
3ffaf780e3
16 changed files with 646 additions and 190 deletions
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
373
kiosk/src/ui.rs
373
kiosk/src/ui.rs
|
|
@ -6,19 +6,21 @@ use std::time::{Duration, Instant};
|
|||
use url::Url;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture};
|
||||
use gtk4::{
|
||||
self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::ServerMsg;
|
||||
use crate::bundle::{BundleDisplayWithLayouts, KioskBundle};
|
||||
use crate::cec;
|
||||
use crate::gpio;
|
||||
use crate::firmware;
|
||||
use crate::gpio;
|
||||
use crate::hwmon;
|
||||
use crate::local_server;
|
||||
use crate::pipeline;
|
||||
use crate::server;
|
||||
use crate::ws_client;
|
||||
use crate::ServerMsg;
|
||||
|
||||
/// Per-display runtime state. Kept inside a thread-local hashmap keyed by
|
||||
/// display id, so all the idle/sleep/layout tracking is local to that display
|
||||
|
|
@ -116,7 +118,7 @@ fn activate(app: &Application) {
|
|||
.build();
|
||||
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_string("window { background-color: #000000; }");
|
||||
provider.load_from_string("window { background-color: #000000; } .kiosk-hidden-cursor, .kiosk-hidden-cursor * { cursor: none; }");
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&WidgetExt::display(&pairing_window),
|
||||
&provider,
|
||||
|
|
@ -129,7 +131,8 @@ fn activate(app: &Application) {
|
|||
|
||||
let (tx, rx) = mpsc::channel::<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 {
|
||||
window,
|
||||
current_layout_id: None,
|
||||
last_activity: Instant::now(),
|
||||
is_asleep: false,
|
||||
});
|
||||
new_state.insert(
|
||||
bd.id,
|
||||
DisplayState {
|
||||
window,
|
||||
current_layout_id: None,
|
||||
last_activity: Instant::now(),
|
||||
is_asleep: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
DISPLAYS.with(|ds| *ds.borrow_mut() = new_state);
|
||||
|
||||
|
|
@ -616,13 +689,14 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
return;
|
||||
};
|
||||
|
||||
let layout = bd.layouts.iter().find(|l| l.id == layout_id)
|
||||
.or_else(|| {
|
||||
warn!("render_layout: layout {layout_id} not on display {display_id}, falling back to default");
|
||||
bd.default_layout_id
|
||||
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
|
||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
|
||||
});
|
||||
let layout = bd.layouts.iter().find(|l| l.id == layout_id).or_else(|| {
|
||||
warn!(
|
||||
"render_layout: layout {layout_id} not on display {display_id}, falling back to default"
|
||||
);
|
||||
bd.default_layout_id
|
||||
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
|
||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
|
||||
});
|
||||
|
||||
let Some(layout) = layout else {
|
||||
warn!("render_layout: no usable layout on display {display_id}");
|
||||
|
|
@ -638,15 +712,25 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
// Update per-display layout id BEFORE recomputing warm-cameras so the
|
||||
// union across displays is correct.
|
||||
let previous_layout_id = DISPLAYS.with(|ds| {
|
||||
let prev = ds.borrow().get(&display_id).and_then(|s| s.current_layout_id);
|
||||
let prev = ds
|
||||
.borrow()
|
||||
.get(&display_id)
|
||||
.and_then(|s| s.current_layout_id);
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
st.current_layout_id = Some(layout.id);
|
||||
}
|
||||
prev
|
||||
});
|
||||
|
||||
info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)",
|
||||
layout.name, layout.id, display_id, layout.grid_cols, layout.grid_rows, layout.cells.len());
|
||||
info!(
|
||||
"rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)",
|
||||
layout.name,
|
||||
layout.id,
|
||||
display_id,
|
||||
layout.grid_cols,
|
||||
layout.grid_rows,
|
||||
layout.cells.len()
|
||||
);
|
||||
|
||||
// Notify the server when the active layout actually changes so Node-RED
|
||||
// sees idle reverts + any other kiosk-initiated switch. Skip when the
|
||||
|
|
@ -657,7 +741,13 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
let server = server_url.clone();
|
||||
let key = kiosk_key.clone();
|
||||
std::thread::spawn(move || {
|
||||
server::report_layout_change(&server, &key, display_id, layout_id_for_report, &layout_name);
|
||||
server::report_layout_change(
|
||||
&server,
|
||||
&key,
|
||||
display_id,
|
||||
layout_id_for_report,
|
||||
&layout_name,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -701,10 +791,17 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
for cell in &layout.cells {
|
||||
let cell_key: Option<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: >k::Grid) {
|
|||
if let Some(b) = c.compute_bounds(&old_child) {
|
||||
let paintable: gtk::gdk::Paintable =
|
||||
gtk::WidgetPaintable::new(Some(&c)).upcast();
|
||||
snaps.insert(key.to_string(), CellSnap { paintable, bounds: b });
|
||||
snaps.insert(
|
||||
key.to_string(),
|
||||
CellSnap {
|
||||
paintable,
|
||||
bounds: b,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
child = c.next_sibling();
|
||||
|
|
@ -841,9 +949,11 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: >k::Grid) {
|
|||
let window_weak = window.downgrade();
|
||||
gtk::glib::idle_add_local_once(move || {
|
||||
// Swap back to plain grid as window child (drop the overlay).
|
||||
if let (Some(grid), Some(win), Some(ov)) =
|
||||
(new_grid_weak.upgrade(), window_weak.upgrade(), overlay_weak.upgrade())
|
||||
{
|
||||
if let (Some(grid), Some(win), Some(ov)) = (
|
||||
new_grid_weak.upgrade(),
|
||||
window_weak.upgrade(),
|
||||
overlay_weak.upgrade(),
|
||||
) {
|
||||
if grid.parent().as_ref() == Some(ov.upcast_ref::<gtk::Widget>()) {
|
||||
ov.set_child(None::<>k::Widget>);
|
||||
win.set_child(Some(&grid));
|
||||
|
|
@ -864,7 +974,8 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: >k::Grid) {
|
|||
let mut child = new_grid_clone.first_child();
|
||||
while let Some(c) = child {
|
||||
let key = c.widget_name();
|
||||
let new_bounds = c.compute_bounds(&new_grid_clone)
|
||||
let new_bounds = c
|
||||
.compute_bounds(&new_grid_clone)
|
||||
.unwrap_or_else(gtk::graphene::Rect::zero);
|
||||
if !key.is_empty() {
|
||||
if let Some(snap) = snaps.remove(key.as_str()) {
|
||||
|
|
@ -909,9 +1020,11 @@ fn animate_layout_swap(window: &ApplicationWindow, new_grid: >k::Grid) {
|
|||
gtk::glib::timeout_add_local_once(
|
||||
Duration::from_millis((LAYOUT_ANIM_MS + 50) as u64),
|
||||
move || {
|
||||
if let (Some(grid), Some(win), Some(ov)) =
|
||||
(grid_weak.upgrade(), window_weak.upgrade(), overlay_weak.upgrade())
|
||||
{
|
||||
if let (Some(grid), Some(win), Some(ov)) = (
|
||||
grid_weak.upgrade(),
|
||||
window_weak.upgrade(),
|
||||
overlay_weak.upgrade(),
|
||||
) {
|
||||
if grid.parent().as_ref() == Some(ov.upcast_ref::<gtk::Widget>()) {
|
||||
ov.set_child(None::<>k::Widget>);
|
||||
win.set_child(Some(&grid));
|
||||
|
|
@ -939,7 +1052,9 @@ fn animate_picture_to_bounds(
|
|||
let fixed_weak = fixed.downgrade();
|
||||
let target_weak = target.downgrade();
|
||||
pic.add_tick_callback(move |_, _| {
|
||||
let Some(pic) = pic_weak.upgrade() else { return gtk::glib::ControlFlow::Break; };
|
||||
let Some(pic) = pic_weak.upgrade() else {
|
||||
return gtk::glib::ControlFlow::Break;
|
||||
};
|
||||
let elapsed = start.elapsed().as_millis() as f64;
|
||||
let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0);
|
||||
let e = ease_out_cubic(t);
|
||||
|
|
@ -966,11 +1081,17 @@ fn fade_in(widget: >k::Widget) {
|
|||
let start = Instant::now();
|
||||
let weak = widget.downgrade();
|
||||
widget.add_tick_callback(move |_, _| {
|
||||
let Some(w) = weak.upgrade() else { return gtk::glib::ControlFlow::Break; };
|
||||
let Some(w) = weak.upgrade() else {
|
||||
return gtk::glib::ControlFlow::Break;
|
||||
};
|
||||
let elapsed = start.elapsed().as_millis() as f64;
|
||||
let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0);
|
||||
w.set_opacity(t);
|
||||
if t >= 1.0 { gtk::glib::ControlFlow::Break } else { gtk::glib::ControlFlow::Continue }
|
||||
if t >= 1.0 {
|
||||
gtk::glib::ControlFlow::Break
|
||||
} else {
|
||||
gtk::glib::ControlFlow::Continue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -979,12 +1100,16 @@ fn fade_out_and_drop(pic: >k::Picture, fixed: >k::Fixed) {
|
|||
let pic_weak = pic.downgrade();
|
||||
let fixed_weak = fixed.downgrade();
|
||||
pic.add_tick_callback(move |_, _| {
|
||||
let Some(p) = pic_weak.upgrade() else { return gtk::glib::ControlFlow::Break; };
|
||||
let Some(p) = pic_weak.upgrade() else {
|
||||
return gtk::glib::ControlFlow::Break;
|
||||
};
|
||||
let elapsed = start.elapsed().as_millis() as f64;
|
||||
let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0);
|
||||
p.set_opacity(1.0 - t);
|
||||
if t >= 1.0 {
|
||||
if let Some(_f) = fixed_weak.upgrade() { p.unparent(); }
|
||||
if let Some(_f) = fixed_weak.upgrade() {
|
||||
p.unparent();
|
||||
}
|
||||
return gtk::glib::ControlFlow::Break;
|
||||
}
|
||||
gtk::glib::ControlFlow::Continue
|
||||
|
|
@ -1017,7 +1142,10 @@ fn recompute_global_state() {
|
|||
|
||||
// Snapshot per-display active layout id outside any borrow of WARM_CAMERAS.
|
||||
let active: Vec<(u32, Option<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 {
|
||||
pipeline: pipe,
|
||||
paintable: paintable.clone(),
|
||||
state: WarmthState::Warm,
|
||||
cooling_until: None,
|
||||
});
|
||||
w.borrow_mut().insert(
|
||||
key,
|
||||
PipelineEntry {
|
||||
pipeline: pipe,
|
||||
paintable: paintable.clone(),
|
||||
state: WarmthState::Warm,
|
||||
cooling_until: None,
|
||||
},
|
||||
);
|
||||
});
|
||||
info!("warmed pipeline for camera {cam_id} (stream: {desired_badge})");
|
||||
Some((paintable, desired_badge))
|
||||
|
|
@ -1264,9 +1421,7 @@ fn ensure_web(
|
|||
server_url: &str,
|
||||
kiosk_key: &str,
|
||||
) -> webkit6::WebView {
|
||||
let cached = WARM_WEBVIEWS.with(|m| {
|
||||
m.borrow().get(&key).map(|e| e.webview.clone())
|
||||
});
|
||||
let cached = WARM_WEBVIEWS.with(|m| m.borrow().get(&key).map(|e| e.webview.clone()));
|
||||
if let Some(wv) = cached {
|
||||
WARM_WEBVIEWS.with(|m| {
|
||||
if let Some(e) = m.borrow_mut().get_mut(&key) {
|
||||
|
|
@ -1296,11 +1451,14 @@ fn ensure_web(
|
|||
}
|
||||
}
|
||||
WARM_WEBVIEWS.with(|m| {
|
||||
m.borrow_mut().insert(key.clone(), WebEntry {
|
||||
webview: wv.clone(),
|
||||
state: WarmthState::Warm,
|
||||
cooling_until: None,
|
||||
});
|
||||
m.borrow_mut().insert(
|
||||
key.clone(),
|
||||
WebEntry {
|
||||
webview: wv.clone(),
|
||||
state: WarmthState::Warm,
|
||||
cooling_until: None,
|
||||
},
|
||||
);
|
||||
});
|
||||
info!("warmed webview {key}");
|
||||
wv
|
||||
|
|
@ -1359,16 +1517,17 @@ fn recompute_web_states(
|
|||
to_remove.push(key.clone());
|
||||
} else {
|
||||
entry.state = WarmthState::Cooling;
|
||||
entry.cooling_until = Some(
|
||||
Instant::now() + Duration::from_secs(max_cooling_secs as u64),
|
||||
);
|
||||
entry.cooling_until =
|
||||
Some(Instant::now() + Duration::from_secs(max_cooling_secs as u64));
|
||||
info!("webview {key}: cooling for {max_cooling_secs}s before drop");
|
||||
}
|
||||
}
|
||||
}
|
||||
for k in &to_remove {
|
||||
if let Some(e) = warm.remove(k) {
|
||||
if e.webview.parent().is_some() { e.webview.unparent(); }
|
||||
if e.webview.parent().is_some() {
|
||||
e.webview.unparent();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1383,14 +1542,15 @@ fn expire_cooling_webviews() {
|
|||
let keys: Vec<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) {
|
||||
|
|
|
|||
|
|
@ -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,18 +97,23 @@ 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") {
|
||||
None
|
||||
} else if let Some(value) = msg.get("pwm").and_then(|v| v.as_u64()) {
|
||||
Some(value.min(255) as u32)
|
||||
} else {
|
||||
warn!("ws: fan command missing mode=auto or pwm");
|
||||
continue;
|
||||
};
|
||||
let pwm: Option<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())
|
||||
{
|
||||
Some(value.min(255) as u32)
|
||||
} else {
|
||||
warn!("ws: fan command missing mode=auto or pwm");
|
||||
continue;
|
||||
};
|
||||
let _ = tx.send(ServerMsg::Fan(pwm));
|
||||
} else {
|
||||
info!("ws: msg: {text}");
|
||||
|
|
@ -132,7 +152,8 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
|
|||
"type": "onvif-soap-response",
|
||||
"request_id": req.request_id,
|
||||
"error": format!("kiosk ONVIF client init failed: {err}"),
|
||||
}).to_string();
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -143,7 +164,8 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
|
|||
"type": "onvif-soap-response",
|
||||
"request_id": req.request_id,
|
||||
"error": format!("invalid ONVIF URL: {err}"),
|
||||
}).to_string();
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
};
|
||||
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||
|
|
@ -151,12 +173,19 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
|
|||
"type": "onvif-soap-response",
|
||||
"request_id": req.request_id,
|
||||
"error": "ONVIF URL must use http or https",
|
||||
}).to_string();
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
|
||||
let result = client
|
||||
.post(parsed)
|
||||
.header("Content-Type", format!("application/soap+xml; charset=utf-8; action=\"{}\"", req.action))
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!(
|
||||
"application/soap+xml; charset=utf-8; action=\"{}\"",
|
||||
req.action
|
||||
),
|
||||
)
|
||||
.header("SOAPAction", req.action)
|
||||
.body(req.body)
|
||||
.send()
|
||||
|
|
@ -171,20 +200,23 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
|
|||
"request_id": req.request_id,
|
||||
"status": status,
|
||||
"body": body,
|
||||
}).to_string(),
|
||||
})
|
||||
.to_string(),
|
||||
Err(err) => serde_json::json!({
|
||||
"type": "onvif-soap-response",
|
||||
"request_id": req.request_id,
|
||||
"status": status,
|
||||
"error": format!("kiosk ONVIF response read failed: {err}"),
|
||||
}).to_string(),
|
||||
})
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
Err(err) => serde_json::json!({
|
||||
"type": "onvif-soap-response",
|
||||
"request_id": req.request_id,
|
||||
"error": format!("kiosk ONVIF request failed: {err}"),
|
||||
}).to_string(),
|
||||
})
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}` } });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1316,7 +1316,7 @@ interface KioskEditProps {
|
|||
labels: Array<{ label_id: number; name: string; role: string }>;
|
||||
allLabels: Label[];
|
||||
displays?: Display[];
|
||||
switchableLayouts?: LayoutType[];
|
||||
displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>;
|
||||
gpioBindings?: KioskGpioBinding[];
|
||||
firmwareReleases?: FirmwareRelease[];
|
||||
error?: string;
|
||||
|
|
@ -1556,24 +1556,37 @@ export function KioskEditPage(props: KioskEditProps) {
|
|||
>Standby</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) => (
|
||||
<option value={String(l.id)}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
{...{
|
||||
"hx-post": `/admin/kiosks/${String(k.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);`,
|
||||
}}
|
||||
>Switch</button>
|
||||
<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/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>
|
||||
) : 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 && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue