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