mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
feat: multi-display + snapshot + health + GPIO + nodered embed
Multi-display: - Bundle ships displays[] each with own layouts + idle/sleep - Rust kiosk creates one ApplicationWindow per gdk monitor - Per-display state (layout, idle, sleep) via HashMap - WARM_CAMERAS pool shared across displays - Backward-compat top-level display/layouts still emitted System Health (/admin/health): - Online status, CPU temp (color-coded), fan RPM/PWM - Bundle version mismatch detection - 30s auto-refresh Camera snapshot/test: - shared/snapshot.ts: ffmpeg/gst-launch fallback, 5s timeout - /admin/entities/:id/snapshot returns JPEG - EntityEditPage shows live preview with Refresh GPIO (Pi buttons/sensors): - kiosk_gpio_bindings table + CRUD admin UI - Bundle ships gpio_bindings[] - kiosk/src/gpio.rs with gpiod crate, worker thread per pin - Edge events POST to /api/kiosk/event with source_type=gpio Layout switch fixes: - GET aliases added so direct URL hits work - New /admin/displays/:displayId/layout/:layoutId for multi-display - DisplayEditPage gets "Switch Layout Now" section Node-RED embed: - /admin/nodered renders iframe at /nrdp/ - Sandbox attrs allow scripts/forms/popups - Sidebar link now opens embedded view
This commit is contained in:
parent
1c0fe02fcf
commit
975cc184b3
14 changed files with 1284 additions and 221 deletions
|
|
@ -33,3 +33,4 @@ tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
url = "2"
|
url = "2"
|
||||||
webkit6 = "0.4"
|
webkit6 = "0.4"
|
||||||
|
gpiod = "0.3"
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,47 @@ use serde::{Deserialize, Serialize};
|
||||||
pub struct KioskBundle {
|
pub struct KioskBundle {
|
||||||
pub kiosk_id: u32,
|
pub kiosk_id: u32,
|
||||||
pub kiosk_name: String,
|
pub kiosk_name: String,
|
||||||
pub display: BundleDisplay,
|
/// Legacy single-display field (mirrors `displays[0]`). New code should
|
||||||
|
/// iterate `displays` instead.
|
||||||
|
#[serde(default)]
|
||||||
|
pub display: Option<BundleDisplay>,
|
||||||
|
/// Legacy single-display layouts (mirrors `displays[0].layouts`). Kept for
|
||||||
|
/// backward compatibility with older bundles that pre-date multi-display.
|
||||||
|
#[serde(default)]
|
||||||
pub layouts: Vec<BundleLayout>,
|
pub layouts: Vec<BundleLayout>,
|
||||||
|
/// All physical displays driven by this kiosk.
|
||||||
|
#[serde(default)]
|
||||||
|
pub displays: Vec<BundleDisplayWithLayouts>,
|
||||||
pub cameras: Vec<BundleCamera>,
|
pub cameras: Vec<BundleCamera>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gpio_bindings: Vec<BundleGpioBinding>,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl KioskBundle {
|
||||||
|
/// Normalize the bundle: if `displays` is empty (old server), synthesize it
|
||||||
|
/// from the legacy single `display` + `layouts` fields so the rest of the
|
||||||
|
/// kiosk only deals with one shape.
|
||||||
|
pub fn normalized_displays(&self) -> Vec<BundleDisplayWithLayouts> {
|
||||||
|
if !self.displays.is_empty() {
|
||||||
|
return self.displays.clone();
|
||||||
|
}
|
||||||
|
if let Some(d) = &self.display {
|
||||||
|
return vec![BundleDisplayWithLayouts {
|
||||||
|
id: d.id,
|
||||||
|
name: d.name.clone(),
|
||||||
|
width_px: d.width_px,
|
||||||
|
height_px: d.height_px,
|
||||||
|
idle_timeout_seconds: d.idle_timeout_seconds,
|
||||||
|
sleep_timeout_seconds: d.sleep_timeout_seconds,
|
||||||
|
default_layout_id: d.default_layout_id,
|
||||||
|
layouts: self.layouts.clone(),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleDisplay {
|
pub struct BundleDisplay {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
|
|
@ -21,6 +56,19 @@ pub struct BundleDisplay {
|
||||||
pub default_layout_id: Option<u32>,
|
pub default_layout_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BundleDisplayWithLayouts {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub width_px: u32,
|
||||||
|
pub height_px: u32,
|
||||||
|
pub idle_timeout_seconds: u32,
|
||||||
|
pub sleep_timeout_seconds: u32,
|
||||||
|
pub default_layout_id: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layouts: Vec<BundleLayout>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleLayout {
|
pub struct BundleLayout {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
|
|
@ -76,6 +124,17 @@ pub struct BundleStream {
|
||||||
pub framerate: Option<u32>,
|
pub framerate: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BundleGpioBinding {
|
||||||
|
pub id: u32,
|
||||||
|
pub chip: String,
|
||||||
|
pub pin: u32,
|
||||||
|
pub direction: String,
|
||||||
|
pub pull: Option<String>,
|
||||||
|
pub edge: Option<String>,
|
||||||
|
pub topic: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl BundleCamera {
|
impl BundleCamera {
|
||||||
/// Pick stream URI + role tag for this camera given selector and cell area fraction.
|
/// Pick stream URI + role tag for this camera given selector and cell area fraction.
|
||||||
/// Heuristic: when selector=auto, cell ≥20% of grid → main, else sub.
|
/// Heuristic: when selector=auto, cell ≥20% of grid → main, else sub.
|
||||||
|
|
|
||||||
152
kiosk/src/gpio.rs
Normal file
152
kiosk/src/gpio.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
//! GPIO worker threads — one per binding.
|
||||||
|
//!
|
||||||
|
//! Each binding spawns a worker that opens the configured gpio chip, requests
|
||||||
|
//! the configured line, waits for edge events, and posts to
|
||||||
|
//! `/api/kiosk/event` on each trigger. Output bindings are opened but idle —
|
||||||
|
//! reserved for future server-driven set operations.
|
||||||
|
//!
|
||||||
|
//! The whole worker pool is rebuilt on every bundle reload: `start_workers`
|
||||||
|
//! replaces any previously running set. Workers shut down when their
|
||||||
|
//! `running` flag flips to `false`.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::bundle::BundleGpioBinding;
|
||||||
|
|
||||||
|
struct WorkerHandle {
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static WORKERS: Mutex<Vec<WorkerHandle>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// Tear down the previous worker set (if any) and start a fresh set for the
|
||||||
|
/// given bindings. Safe to call repeatedly on bundle reload.
|
||||||
|
pub fn start_workers(bindings: &[BundleGpioBinding], server_url: &str, kiosk_key: &str) {
|
||||||
|
stop_workers();
|
||||||
|
|
||||||
|
if bindings.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for b in bindings {
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let r2 = running.clone();
|
||||||
|
let binding = b.clone();
|
||||||
|
let server = server_url.to_string();
|
||||||
|
let key = kiosk_key.to_string();
|
||||||
|
|
||||||
|
std::thread::spawn(move || run_binding(binding, server, key, r2));
|
||||||
|
handles.push(WorkerHandle { running });
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut w) = WORKERS.lock() {
|
||||||
|
*w = handles;
|
||||||
|
}
|
||||||
|
info!("gpio: {} worker(s) started", bindings.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_workers() {
|
||||||
|
if let Ok(mut w) = WORKERS.lock() {
|
||||||
|
for h in w.drain(..) {
|
||||||
|
h.running.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_binding(b: BundleGpioBinding, server: String, key: String, running: Arc<AtomicBool>) {
|
||||||
|
if b.direction != "in" {
|
||||||
|
// Output bindings: open the line so the chip knows it's claimed but
|
||||||
|
// there's nothing to wait on. Bail early — output set ops will be
|
||||||
|
// added later via WS commands.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chip = match gpiod::Chip::new(&b.chip) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("gpio: open chip {} failed: {e}", b.chip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut opts = gpiod::Options::input([b.pin as u32])
|
||||||
|
.consumer("betterframe-kiosk");
|
||||||
|
|
||||||
|
if let Some(edge) = b.edge.as_deref() {
|
||||||
|
let edge_detect = match edge {
|
||||||
|
"rising" => gpiod::EdgeDetect::Rising,
|
||||||
|
"falling" => gpiod::EdgeDetect::Falling,
|
||||||
|
_ => gpiod::EdgeDetect::Both,
|
||||||
|
};
|
||||||
|
opts = opts.edge(edge_detect);
|
||||||
|
} else {
|
||||||
|
opts = opts.edge(gpiod::EdgeDetect::Both);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pull) = b.pull.as_deref() {
|
||||||
|
let bias = match pull {
|
||||||
|
"up" => gpiod::Bias::PullUp,
|
||||||
|
"down" => gpiod::Bias::PullDown,
|
||||||
|
_ => gpiod::Bias::Disable,
|
||||||
|
};
|
||||||
|
opts = opts.bias(bias);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = match chip.request_lines(opts) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("gpio: request {}:{} failed: {e}", b.chip, b.pin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"gpio: watching {} pin {} ({}/{}/topic={})",
|
||||||
|
b.chip,
|
||||||
|
b.pin,
|
||||||
|
b.direction,
|
||||||
|
b.edge.as_deref().unwrap_or("both"),
|
||||||
|
b.topic
|
||||||
|
);
|
||||||
|
|
||||||
|
let http = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.expect("reqwest client build");
|
||||||
|
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
// Poll with a timeout so the running flag is checked periodically.
|
||||||
|
match lines.read_event() {
|
||||||
|
Ok(event) => {
|
||||||
|
let edge_str = match event.edge {
|
||||||
|
gpiod::Edge::Rising => "rising",
|
||||||
|
gpiod::Edge::Falling => "falling",
|
||||||
|
};
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"chip": b.chip,
|
||||||
|
"pin": b.pin,
|
||||||
|
"edge": edge_str,
|
||||||
|
});
|
||||||
|
let _ = http
|
||||||
|
.post(format!("{server}/api/kiosk/event"))
|
||||||
|
.header("Authorization", format!("Bearer {key}"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"topic": b.topic,
|
||||||
|
"source_type": "gpio",
|
||||||
|
"payload": payload,
|
||||||
|
}))
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("gpio: read_event {}:{} failed: {e}", b.chip, b.pin);
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("gpio: worker {}:{} stopped", b.chip, b.pin);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
mod server;
|
mod server;
|
||||||
mod bundle;
|
mod bundle;
|
||||||
mod cec;
|
mod cec;
|
||||||
|
mod gpio;
|
||||||
mod hwmon;
|
mod hwmon;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
|
||||||
460
kiosk/src/ui.rs
460
kiosk/src/ui.rs
|
|
@ -1,4 +1,5 @@
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
@ -7,20 +8,32 @@ 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::bundle::KioskBundle;
|
use crate::bundle::{BundleDisplayWithLayouts, KioskBundle};
|
||||||
use crate::cec;
|
use crate::cec;
|
||||||
|
use crate::gpio;
|
||||||
use crate::hwmon;
|
use crate::hwmon;
|
||||||
use crate::pipeline;
|
use crate::pipeline;
|
||||||
use crate::server;
|
use crate::server;
|
||||||
use crate::ws_client;
|
use crate::ws_client;
|
||||||
use crate::ServerMsg;
|
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
|
||||||
|
/// even though the GTK main loop is shared.
|
||||||
|
struct DisplayState {
|
||||||
|
window: ApplicationWindow,
|
||||||
|
current_layout_id: Option<u32>,
|
||||||
|
last_activity: Instant,
|
||||||
|
is_asleep: bool,
|
||||||
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// camera_id → (pipeline, paintable, badge). Pipelines stay warm across
|
/// camera_id → (pipeline, paintable, badge). Pipelines stay warm across
|
||||||
/// layout swaps for cameras still referenced or in preload_camera_ids.
|
/// layout swaps for cameras still referenced or in preload_camera_ids.
|
||||||
/// badge is 'M' / 'S' / ' ' indicating which stream is active.
|
/// Shared across ALL displays — if two displays use the same camera the
|
||||||
static WARM_CAMERAS: RefCell<std::collections::HashMap<u32, (gstreamer::Pipeline, gtk::gdk::Paintable, char)>>
|
/// pipeline is reused. The paintable can be attached to multiple Pictures.
|
||||||
= RefCell::new(std::collections::HashMap::new());
|
static WARM_CAMERAS: RefCell<HashMap<u32, (gstreamer::Pipeline, gtk::gdk::Paintable, char)>>
|
||||||
|
= RefCell::new(HashMap::new());
|
||||||
|
|
||||||
/// Most recently rendered bundle. Used for layout-switch + idle revert.
|
/// Most recently rendered bundle. Used for layout-switch + idle revert.
|
||||||
static CURRENT_BUNDLE: RefCell<Option<KioskBundle>> = const { RefCell::new(None) };
|
static CURRENT_BUNDLE: RefCell<Option<KioskBundle>> = const { RefCell::new(None) };
|
||||||
|
|
@ -28,14 +41,8 @@ thread_local! {
|
||||||
/// Server URL + kiosk key for re-rendering on layout-switch.
|
/// Server URL + kiosk key for re-rendering on layout-switch.
|
||||||
static CURRENT_AUTH: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
|
static CURRENT_AUTH: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
|
||||||
|
|
||||||
/// Layout id currently on screen, if any.
|
/// Per-display state, keyed by bundle display id.
|
||||||
static CURRENT_LAYOUT_ID: Cell<Option<u32>> = const { Cell::new(None) };
|
static DISPLAYS: RefCell<HashMap<u32, DisplayState>> = RefCell::new(HashMap::new());
|
||||||
|
|
||||||
/// Timestamp of the last "activity" event (render, switch, wake).
|
|
||||||
static LAST_ACTIVITY: RefCell<Instant> = RefCell::new(Instant::now());
|
|
||||||
|
|
||||||
/// True after we've fired CEC standby due to sleep timeout.
|
|
||||||
static IS_ASLEEP: Cell<bool> = const { Cell::new(false) };
|
|
||||||
|
|
||||||
/// Has the idle-watchdog already been installed on the main loop?
|
/// Has the idle-watchdog already been installed on the main loop?
|
||||||
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
||||||
|
|
@ -52,7 +59,9 @@ pub fn build_app() -> Application {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn activate(app: &Application) {
|
fn activate(app: &Application) {
|
||||||
let window = ApplicationWindow::builder()
|
// Create the initial pairing window. Multi-display windows are spawned
|
||||||
|
// later once we receive a bundle.
|
||||||
|
let pairing_window = ApplicationWindow::builder()
|
||||||
.application(app)
|
.application(app)
|
||||||
.title("BetterFrame")
|
.title("BetterFrame")
|
||||||
.fullscreened(true)
|
.fullscreened(true)
|
||||||
|
|
@ -61,13 +70,13 @@ fn activate(app: &Application) {
|
||||||
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; }");
|
||||||
gtk::style_context_add_provider_for_display(
|
gtk::style_context_add_provider_for_display(
|
||||||
&WidgetExt::display(&window),
|
&WidgetExt::display(&pairing_window),
|
||||||
&provider,
|
&provider,
|
||||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
);
|
);
|
||||||
|
|
||||||
show_logo(&window);
|
show_logo(&pairing_window);
|
||||||
window.present();
|
pairing_window.present();
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<WorkerMsg>();
|
let (tx, rx) = mpsc::channel::<WorkerMsg>();
|
||||||
|
|
||||||
|
|
@ -94,7 +103,7 @@ 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, {} layouts", b.cameras.len(), b.layouts.len());
|
info!("bundle: {} cameras, {} display(s)", b.cameras.len(), b.normalized_displays().len());
|
||||||
Some(b)
|
Some(b)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
|
@ -130,10 +139,6 @@ fn activate(app: &Application) {
|
||||||
let retry_server = server.clone();
|
let retry_server = server.clone();
|
||||||
let retry_key = key.clone();
|
let retry_key = key.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
// Only loop while we have no live bundle yet — best-effort heuristic:
|
|
||||||
// we attempt once, then sleep. If server unreachable each time we
|
|
||||||
// keep waiting; once a fetch succeeds we push a fresh render.
|
|
||||||
// After first success we exit; subsequent updates flow via WS.
|
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_secs(30));
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
if let Some(b) = server::fetch_bundle(&retry_server, &retry_key) {
|
if let Some(b) = server::fetch_bundle(&retry_server, &retry_key) {
|
||||||
|
|
@ -187,22 +192,27 @@ fn activate(app: &Application) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Poll channel from UI thread via timeout
|
// Poll channel from UI thread via timeout
|
||||||
let window_clone = window.clone();
|
let app_clone = app.clone();
|
||||||
|
let pairing_window_clone = pairing_window.clone();
|
||||||
gtk::glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
gtk::glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||||
while let Ok(msg) = rx.try_recv() {
|
while let Ok(msg) = rx.try_recv() {
|
||||||
match msg {
|
match msg {
|
||||||
WorkerMsg::ShowPairingCode(code) => show_pairing_code(&window_clone, &code),
|
WorkerMsg::ShowPairingCode(code) => show_pairing_code(&pairing_window_clone, &code),
|
||||||
WorkerMsg::RenderBundle(bundle, server, key) => {
|
WorkerMsg::RenderBundle(bundle, server, key) => {
|
||||||
render_bundle(&window_clone, bundle, &server, &key);
|
render_bundle(&app_clone, &pairing_window_clone, bundle, &server, &key);
|
||||||
install_idle_watchdog(&window_clone);
|
install_idle_watchdog();
|
||||||
}
|
}
|
||||||
WorkerMsg::SwitchLayout(id) => {
|
WorkerMsg::SwitchLayout(id) => {
|
||||||
render_layout(&window_clone, id);
|
switch_layout_anywhere(id);
|
||||||
}
|
}
|
||||||
WorkerMsg::Wake => {
|
WorkerMsg::Wake => {
|
||||||
cec::wake();
|
cec::wake();
|
||||||
IS_ASLEEP.with(|c| c.set(false));
|
DISPLAYS.with(|ds| {
|
||||||
mark_activity();
|
for st in ds.borrow_mut().values_mut() {
|
||||||
|
st.is_asleep = false;
|
||||||
|
st.last_activity = Instant::now();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,65 +227,77 @@ enum WorkerMsg {
|
||||||
Wake,
|
Wake,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset activity timer. If we were asleep, wake the display first.
|
/// Reset activity timer for one display. If asleep, wake it.
|
||||||
fn mark_activity() {
|
fn mark_activity(display_id: u32) {
|
||||||
LAST_ACTIVITY.with(|t| *t.borrow_mut() = Instant::now());
|
DISPLAYS.with(|ds| {
|
||||||
if IS_ASLEEP.with(|c| c.get()) {
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||||
info!("activity while asleep → waking display");
|
st.last_activity = Instant::now();
|
||||||
cec::wake();
|
if st.is_asleep {
|
||||||
IS_ASLEEP.with(|c| c.set(false));
|
info!("activity while asleep → waking display {display_id}");
|
||||||
}
|
cec::wake();
|
||||||
|
st.is_asleep = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the once-per-second watchdog that enforces idle/sleep timeouts.
|
/// Install the once-per-second watchdog that enforces idle/sleep timeouts
|
||||||
/// Safe to call multiple times — installs at most once.
|
/// per display. Safe to call multiple times — installs at most once.
|
||||||
fn install_idle_watchdog(window: &ApplicationWindow) {
|
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));
|
||||||
let window = window.clone();
|
|
||||||
gtk::glib::timeout_add_local(Duration::from_secs(1), move || {
|
gtk::glib::timeout_add_local(Duration::from_secs(1), move || {
|
||||||
let elapsed = LAST_ACTIVITY.with(|t| t.borrow().elapsed());
|
let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
|
||||||
|
let Some(bundle) = bundle else { return gtk::glib::ControlFlow::Continue };
|
||||||
|
|
||||||
// Need the bundle to read display timeouts + default layout.
|
// Snapshot per-display timing decisions so we can act outside the borrow.
|
||||||
let (idle_to, sleep_to, default_id) = CURRENT_BUNDLE.with(|b| {
|
struct Action { display_id: u32, revert_to: Option<u32>, sleep: bool }
|
||||||
match b.borrow().as_ref() {
|
let mut actions: Vec<Action> = Vec::new();
|
||||||
Some(bundle) => (
|
|
||||||
bundle.display.idle_timeout_seconds as u64,
|
DISPLAYS.with(|ds| {
|
||||||
bundle.display.sleep_timeout_seconds as u64,
|
for (display_id, st) in ds.borrow().iter() {
|
||||||
bundle.display.default_layout_id,
|
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;
|
||||||
None => (0, 0, None),
|
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 };
|
||||||
|
|
||||||
|
if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
|
||||||
|
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);
|
||||||
|
if let (Some(cur_id), Some(def_id)) = (st.current_layout_id, default_id) {
|
||||||
|
if cur_id != def_id && cur_resets_idle {
|
||||||
|
act.revert_to = Some(def_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sleep_to > 0 && elapsed >= Duration::from_secs(sleep_to) && !st.is_asleep {
|
||||||
|
act.sleep = true;
|
||||||
|
}
|
||||||
|
if act.revert_to.is_some() || act.sleep {
|
||||||
|
actions.push(act);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Idle revert: if elapsed >= idle timeout AND current layout is not
|
for a in actions {
|
||||||
// default AND current layout doesn't itself reset the idle timer.
|
if let Some(layout_id) = a.revert_to {
|
||||||
if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
|
info!("idle timeout reached → reverting display {} to default", a.display_id);
|
||||||
let cur = CURRENT_LAYOUT_ID.with(|c| c.get());
|
render_layout(a.display_id, layout_id);
|
||||||
let cur_resets_idle = CURRENT_BUNDLE.with(|b| {
|
}
|
||||||
let bundle = b.borrow();
|
if a.sleep {
|
||||||
let Some(bundle) = bundle.as_ref() else { return false };
|
info!("sleep timeout reached on display {} → CEC standby", a.display_id);
|
||||||
let Some(cur_id) = cur else { return false };
|
cec::standby();
|
||||||
bundle.layouts.iter().find(|l| l.id == cur_id)
|
DISPLAYS.with(|ds| {
|
||||||
.map(|l| l.resets_idle_timer)
|
if let Some(st) = ds.borrow_mut().get_mut(&a.display_id) {
|
||||||
.unwrap_or(false)
|
st.is_asleep = true;
|
||||||
});
|
}
|
||||||
if let (Some(cur_id), Some(def_id)) = (cur, default_id) {
|
});
|
||||||
if cur_id != def_id && cur_resets_idle {
|
|
||||||
info!("idle timeout reached → reverting to default layout");
|
|
||||||
render_layout(&window, def_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Sleep: fire CEC standby once, mark asleep.
|
|
||||||
if sleep_to > 0
|
|
||||||
&& elapsed >= Duration::from_secs(sleep_to)
|
|
||||||
&& !IS_ASLEEP.with(|c| c.get())
|
|
||||||
{
|
|
||||||
info!("sleep timeout reached → CEC standby");
|
|
||||||
cec::standby();
|
|
||||||
IS_ASLEEP.with(|c| c.set(true));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gtk::glib::ControlFlow::Continue
|
gtk::glib::ControlFlow::Continue
|
||||||
|
|
@ -289,20 +311,17 @@ fn query_displays() -> Vec<(String, u32, u32)> {
|
||||||
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();
|
||||||
// Skip non-HDMI connectors and the "card" parents
|
|
||||||
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();
|
||||||
// First line = preferred mode
|
|
||||||
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 { continue; }
|
||||||
// Strip "cardN-" prefix for cleaner name
|
|
||||||
let clean_name = name.split_once('-').map(|(_, rest)| rest.to_string()).unwrap_or(name);
|
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));
|
||||||
}
|
}
|
||||||
|
|
@ -331,33 +350,160 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) {
|
||||||
window.set_child(Some(&vbox));
|
window.set_child(Some(&vbox));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &str, kiosk_key: &str) {
|
/// Render a fresh bundle: rebuild the per-display window set, restart GPIO
|
||||||
// Cache the bundle + auth so layout-switch and idle-revert can re-render
|
/// workers, recompute warm-camera needs across all displays.
|
||||||
// without needing a full reload.
|
fn render_bundle(
|
||||||
|
app: &Application,
|
||||||
|
pairing_window: &ApplicationWindow,
|
||||||
|
bundle: KioskBundle,
|
||||||
|
server_url: &str,
|
||||||
|
kiosk_key: &str,
|
||||||
|
) {
|
||||||
CURRENT_BUNDLE.with(|b| *b.borrow_mut() = Some(bundle.clone()));
|
CURRENT_BUNDLE.with(|b| *b.borrow_mut() = Some(bundle.clone()));
|
||||||
CURRENT_AUTH.with(|a| *a.borrow_mut() = Some((server_url.to_string(), kiosk_key.to_string())));
|
CURRENT_AUTH.with(|a| *a.borrow_mut() = Some((server_url.to_string(), kiosk_key.to_string())));
|
||||||
mark_activity();
|
|
||||||
|
|
||||||
let target_layout_id = bundle.display.default_layout_id
|
// Restart GPIO workers (always — even if list is empty, this drops the old set).
|
||||||
.or_else(|| bundle.layouts.iter().find(|l| l.is_default).map(|l| l.id));
|
gpio::start_workers(&bundle.gpio_bindings, server_url, kiosk_key);
|
||||||
|
|
||||||
let Some(target_layout_id) = target_layout_id else {
|
let displays = bundle.normalized_displays();
|
||||||
warn!("display has no default layout");
|
if displays.is_empty() {
|
||||||
clear_warm_cameras();
|
warn!("bundle has no displays");
|
||||||
CURRENT_LAYOUT_ID.with(|c| c.set(None));
|
show_logo(pairing_window);
|
||||||
show_logo(window);
|
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
render_layout(window, target_layout_id);
|
// Match GDK monitors to bundle displays by index. Bundle display 0 → GDK
|
||||||
|
// monitor 0, etc. v1 simple ordering — re-binding will land if/when the
|
||||||
|
// admin UI exposes a mapping. Falls back to overlapping windows on a
|
||||||
|
// single physical screen if the kiosk has fewer monitors than bundle
|
||||||
|
// displays (rare on Pi5).
|
||||||
|
let gdk_monitors: Vec<gtk::gdk::Monitor> = WidgetExt::display(pairing_window)
|
||||||
|
.monitors()
|
||||||
|
.iter::<gtk::gdk::Monitor>()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
});
|
||||||
|
for id in to_remove {
|
||||||
|
if let Some(st) = DISPLAYS.with(|ds| ds.borrow_mut().remove(&id)) {
|
||||||
|
st.window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute global warm-camera set: union across all displays' current/needed cameras.
|
||||||
|
let mut globally_needed: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||||
|
for (i, bd) in displays.iter().enumerate() {
|
||||||
|
let target_id = pick_initial_layout(bd);
|
||||||
|
if let Some(target_id) = target_id {
|
||||||
|
if let Some(layout) = bd.layouts.iter().find(|l| l.id == target_id) {
|
||||||
|
for cell in &layout.cells {
|
||||||
|
if cell.content_type == "camera" {
|
||||||
|
if let Some(id) = cell.camera_id { globally_needed.insert(id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in &layout.preload_camera_ids { globally_needed.insert(*id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pre-touch the monitor binding to silence warnings about unused var.
|
||||||
|
let _ = gdk_monitors.get(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop pipelines for cameras no longer needed by any display.
|
||||||
|
WARM_CAMERAS.with(|w| {
|
||||||
|
let mut warm = w.borrow_mut();
|
||||||
|
let stale: Vec<u32> = warm.keys().filter(|id| !globally_needed.contains(id)).copied().collect();
|
||||||
|
for id in stale {
|
||||||
|
if let Some((pipe, _, _)) = warm.remove(&id) {
|
||||||
|
info!("stopping pipeline for camera {id} (no longer needed by any display)");
|
||||||
|
pipeline::stop(&pipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build/reuse window per bundle display, then render its initial layout.
|
||||||
|
let mut new_state: HashMap<u32, DisplayState> = HashMap::new();
|
||||||
|
for (i, bd) in displays.iter().enumerate() {
|
||||||
|
let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id));
|
||||||
|
let window = match existing {
|
||||||
|
Some(st) => st.window,
|
||||||
|
None => {
|
||||||
|
let w = ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
|
.title(format!("BetterFrame — {}", bd.name))
|
||||||
|
.fullscreened(true)
|
||||||
|
.build();
|
||||||
|
let provider = gtk::CssProvider::new();
|
||||||
|
provider.load_from_string("window { background-color: #000000; }");
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&WidgetExt::display(&w),
|
||||||
|
&provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
w.present();
|
||||||
|
if let Some(monitor) = gdk_monitors.get(i) {
|
||||||
|
w.fullscreen_on_monitor(monitor);
|
||||||
|
}
|
||||||
|
w
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Hide the pairing window now that real displays are up (if we created any).
|
||||||
|
if !displays.is_empty() {
|
||||||
|
pairing_window.set_visible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now render each display's initial layout.
|
||||||
|
for bd in &displays {
|
||||||
|
let target = pick_initial_layout(bd);
|
||||||
|
if let Some(layout_id) = target {
|
||||||
|
render_layout(bd.id, layout_id);
|
||||||
|
} else {
|
||||||
|
warn!("display {} has no default layout", bd.id);
|
||||||
|
DISPLAYS.with(|ds| {
|
||||||
|
if let Some(st) = ds.borrow_mut().get_mut(&bd.id) {
|
||||||
|
show_logo(&st.window);
|
||||||
|
st.current_layout_id = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a specific layout id from the cached bundle. If not found, fall back
|
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<u32> {
|
||||||
/// to the display's default layout. If neither exists, show the logo.
|
bd.default_layout_id
|
||||||
fn render_layout(window: &ApplicationWindow, layout_id: u32) {
|
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id))
|
||||||
mark_activity();
|
.or_else(|| bd.layouts.first().map(|l| l.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find which display owns a given layout_id and render it there.
|
||||||
|
fn switch_layout_anywhere(layout_id: u32) {
|
||||||
|
let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
|
||||||
|
let Some(bundle) = bundle else { return };
|
||||||
|
for bd in bundle.normalized_displays() {
|
||||||
|
if bd.layouts.iter().any(|l| l.id == layout_id) {
|
||||||
|
render_layout(bd.id, layout_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
warn!("switch_layout: layout {layout_id} not found on any display");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a specific layout id on a specific display.
|
||||||
|
fn render_layout(display_id: u32, layout_id: u32) {
|
||||||
|
mark_activity(display_id);
|
||||||
|
|
||||||
// Snapshot what we need out of the cached bundle.
|
|
||||||
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
|
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
|
||||||
let bundle = b.borrow();
|
let bundle = b.borrow();
|
||||||
let bundle = bundle.as_ref()?.clone();
|
let bundle = bundle.as_ref()?.clone();
|
||||||
|
|
@ -367,59 +513,59 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) {
|
||||||
});
|
});
|
||||||
let Some((bundle, server_url, kiosk_key)) = snapshot else {
|
let Some((bundle, server_url, kiosk_key)) = snapshot else {
|
||||||
warn!("render_layout: no cached bundle yet");
|
warn!("render_layout: no cached bundle yet");
|
||||||
show_logo(window);
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let layout = bundle.layouts.iter().find(|l| l.id == layout_id)
|
let displays = bundle.normalized_displays();
|
||||||
|
let Some(bd) = displays.iter().find(|d| d.id == display_id) else {
|
||||||
|
warn!("render_layout: display {display_id} not in bundle");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let layout = bd.layouts.iter().find(|l| l.id == layout_id)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
warn!("render_layout: layout {layout_id} not found, falling back to default");
|
warn!("render_layout: layout {layout_id} not on display {display_id}, falling back to default");
|
||||||
bundle.display.default_layout_id
|
bd.default_layout_id
|
||||||
.and_then(|did| bundle.layouts.iter().find(|l| l.id == did))
|
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
|
||||||
.or_else(|| bundle.layouts.iter().find(|l| l.is_default))
|
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
|
||||||
});
|
});
|
||||||
|
|
||||||
let Some(layout) = layout else {
|
let Some(layout) = layout else {
|
||||||
warn!("render_layout: no usable layout");
|
warn!("render_layout: no usable layout on display {display_id}");
|
||||||
clear_warm_cameras();
|
DISPLAYS.with(|ds| {
|
||||||
CURRENT_LAYOUT_ID.with(|c| c.set(None));
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||||
show_logo(window);
|
show_logo(&st.window);
|
||||||
|
st.current_layout_id = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update per-display layout id BEFORE recomputing warm-cameras so the
|
||||||
|
// union across displays is correct.
|
||||||
|
DISPLAYS.with(|ds| {
|
||||||
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||||
|
st.current_layout_id = Some(layout.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)",
|
||||||
|
layout.name, layout.id, display_id, layout.grid_cols, layout.grid_rows, layout.cells.len());
|
||||||
|
|
||||||
if layout.cells.is_empty() {
|
if layout.cells.is_empty() {
|
||||||
warn!("layout has no cells");
|
warn!("layout has no cells");
|
||||||
clear_warm_cameras();
|
recompute_warm_cameras(&bundle);
|
||||||
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id)));
|
DISPLAYS.with(|ds| {
|
||||||
show_logo(window);
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||||
|
show_logo(&st.window);
|
||||||
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id)));
|
// Recompute warm-camera set across ALL displays (the union), then drop
|
||||||
|
// pipelines no longer needed anywhere.
|
||||||
info!("rendering layout '{}' (id {}) with {}x{} grid, {} cells",
|
recompute_warm_cameras(&bundle);
|
||||||
layout.name, layout.id, layout.grid_cols, layout.grid_rows, layout.cells.len());
|
|
||||||
|
|
||||||
// Compute which cameras are needed: cells with content_type=camera + preload_camera_ids
|
|
||||||
let mut needed: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
|
||||||
for cell in &layout.cells {
|
|
||||||
if cell.content_type == "camera" {
|
|
||||||
if let Some(id) = cell.camera_id { needed.insert(id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id in &layout.preload_camera_ids { needed.insert(*id); }
|
|
||||||
|
|
||||||
// Stop pipelines for cameras no longer needed
|
|
||||||
WARM_CAMERAS.with(|w| {
|
|
||||||
let mut warm = w.borrow_mut();
|
|
||||||
let stale: Vec<u32> = warm.keys().filter(|id| !needed.contains(id)).copied().collect();
|
|
||||||
for id in stale {
|
|
||||||
if let Some((pipe, _, _)) = warm.remove(&id) {
|
|
||||||
info!("stopping pipeline for camera {id} (no longer needed)");
|
|
||||||
pipeline::stop(&pipe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let server_url = server_url.as_str();
|
let server_url = server_url.as_str();
|
||||||
let kiosk_key = kiosk_key.as_str();
|
let kiosk_key = kiosk_key.as_str();
|
||||||
|
|
@ -430,13 +576,12 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) {
|
||||||
grid.set_vexpand(true);
|
grid.set_vexpand(true);
|
||||||
grid.set_hexpand(true);
|
grid.set_hexpand(true);
|
||||||
|
|
||||||
let cam_map: std::collections::HashMap<u32, &crate::bundle::BundleCamera> =
|
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
|
||||||
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
||||||
|
|
||||||
// Total grid area for the heuristic
|
|
||||||
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;
|
||||||
|
|
||||||
// Ensure preloaded cameras have pipelines even if not visible (use sub for warmth)
|
// Ensure preloaded cameras have pipelines even if not visible.
|
||||||
for cam_id in &layout.preload_camera_ids {
|
for cam_id in &layout.preload_camera_ids {
|
||||||
if let Some(cam) = cam_map.get(cam_id) {
|
if let Some(cam) = cam_map.get(cam_id) {
|
||||||
ensure_warm(*cam_id, cam, None, 0.0);
|
ensure_warm(*cam_id, cam, None, 0.0);
|
||||||
|
|
@ -458,7 +603,6 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) {
|
||||||
});
|
});
|
||||||
picture.set_vexpand(true);
|
picture.set_vexpand(true);
|
||||||
picture.set_hexpand(true);
|
picture.set_hexpand(true);
|
||||||
// Wrap in Overlay so we can stack a stream-role badge on top
|
|
||||||
let overlay = gtk::Overlay::new();
|
let overlay = gtk::Overlay::new();
|
||||||
overlay.set_child(Some(&picture));
|
overlay.set_child(Some(&picture));
|
||||||
overlay.set_vexpand(true);
|
overlay.set_vexpand(true);
|
||||||
|
|
@ -520,13 +664,40 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.set_child(Some(&grid));
|
DISPLAYS.with(|ds| {
|
||||||
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||||
|
st.window.set_child(Some(&grid));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_warm_cameras() {
|
/// Compute the union of cameras needed across all displays' current layouts +
|
||||||
|
/// preload sets, then drop any warm pipelines outside that set.
|
||||||
|
fn recompute_warm_cameras(bundle: &KioskBundle) {
|
||||||
|
let mut needed: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||||
|
let displays = bundle.normalized_displays();
|
||||||
|
DISPLAYS.with(|ds| {
|
||||||
|
for (display_id, st) in ds.borrow().iter() {
|
||||||
|
let Some(bd) = displays.iter().find(|d| d.id == *display_id) else { continue };
|
||||||
|
let Some(cur_id) = st.current_layout_id else { continue };
|
||||||
|
let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) else { continue };
|
||||||
|
for cell in &layout.cells {
|
||||||
|
if cell.content_type == "camera" {
|
||||||
|
if let Some(id) = cell.camera_id { needed.insert(id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in &layout.preload_camera_ids { needed.insert(*id); }
|
||||||
|
}
|
||||||
|
});
|
||||||
WARM_CAMERAS.with(|w| {
|
WARM_CAMERAS.with(|w| {
|
||||||
for (_, (pipe, _, _)) in w.borrow().iter() { pipeline::stop(pipe); }
|
let mut warm = w.borrow_mut();
|
||||||
w.borrow_mut().clear();
|
let stale: Vec<u32> = warm.keys().filter(|id| !needed.contains(id)).copied().collect();
|
||||||
|
for id in stale {
|
||||||
|
if let Some((pipe, _, _)) = warm.remove(&id) {
|
||||||
|
info!("stopping pipeline for camera {id} (no longer needed)");
|
||||||
|
pipeline::stop(&pipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -568,7 +739,6 @@ fn ensure_warm(
|
||||||
) -> Option<(gtk::gdk::Paintable, char)> {
|
) -> Option<(gtk::gdk::Paintable, char)> {
|
||||||
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
|
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
|
||||||
|
|
||||||
// Check cached: if badge matches desired, reuse. Else swap.
|
|
||||||
let cached = WARM_CAMERAS.with(|w| {
|
let cached = WARM_CAMERAS.with(|w| {
|
||||||
w.borrow().get(&cam_id).map(|(p, paint, b)| (p.clone(), paint.clone(), *b))
|
w.borrow().get(&cam_id).map(|(p, paint, b)| (p.clone(), paint.clone(), *b))
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,14 @@ import {
|
||||||
LayoutEditPage,
|
LayoutEditPage,
|
||||||
DisplaysPage,
|
DisplaysPage,
|
||||||
DisplayEditPage,
|
DisplayEditPage,
|
||||||
|
SystemHealthPage,
|
||||||
|
NoderedEmbedPage,
|
||||||
renderCell,
|
renderCell,
|
||||||
renderGrid,
|
renderGrid,
|
||||||
} from "../../web-templates/admin-pages.js";
|
} from "../../web-templates/admin-pages.js";
|
||||||
import { discover as onvifDiscover } from "../../shared/onvif.js";
|
import { discover as onvifDiscover } from "../../shared/onvif.js";
|
||||||
|
import { generateBundle } from "../../shared/bundle.js";
|
||||||
|
import { captureSnapshot } from "../../shared/snapshot.js";
|
||||||
|
|
||||||
interface DiscoverAddStream {
|
interface DiscoverAddStream {
|
||||||
profile_name: string;
|
profile_name: string;
|
||||||
|
|
@ -252,6 +256,44 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 301, headers: { location: "/admin/" } });
|
return new Response(null, { status: 301, headers: { location: "/admin/" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- System Health --------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/admin/health", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const kiosks = deps.repo.listKiosks();
|
||||||
|
const now = Date.now();
|
||||||
|
let clusterKey: string | undefined;
|
||||||
|
try {
|
||||||
|
const enc = deps.repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
|
||||||
|
if (enc) clusterKey = deps.secrets.decryptString(enc, "cluster");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
const rows = kiosks.map((k) => {
|
||||||
|
const online = k.last_seen_at
|
||||||
|
? now - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000
|
||||||
|
: false;
|
||||||
|
const displays = deps.repo.listDisplaysForKiosk(k.id);
|
||||||
|
let expectedBundleVersion: string | null = null;
|
||||||
|
try {
|
||||||
|
const b = generateBundle(deps.repo, deps.secrets, k.id, clusterKey);
|
||||||
|
expectedBundleVersion = b?.version ?? null;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
const bundleMismatch =
|
||||||
|
expectedBundleVersion != null
|
||||||
|
&& k.last_bundle_version != null
|
||||||
|
&& k.last_bundle_version !== expectedBundleVersion;
|
||||||
|
return {
|
||||||
|
kiosk: k,
|
||||||
|
online,
|
||||||
|
bundleMismatch,
|
||||||
|
expectedBundleVersion,
|
||||||
|
displays,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return htmlPage(SystemHealthPage({ user: user.username, rows }));
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Cameras --------------------------------------------------------------
|
// ---- Cameras --------------------------------------------------------------
|
||||||
|
|
||||||
app.get("/admin/cameras", (event) => {
|
app.get("/admin/cameras", (event) => {
|
||||||
|
|
@ -513,6 +555,33 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Camera snapshot — pulls one frame from the entity's main stream and
|
||||||
|
// returns it as JPEG. Used by the EntityEditPage "Test" preview.
|
||||||
|
app.get("/admin/entities/:id/snapshot", async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const ent = deps.repo.getEntityById(id);
|
||||||
|
if (!ent || ent.type !== "camera" || ent.camera_id == null) {
|
||||||
|
return new Response("Not a camera entity", { status: 404 });
|
||||||
|
}
|
||||||
|
const streams = deps.repo.listCameraStreams(ent.camera_id);
|
||||||
|
const main = streams.find((s) => s.role === "main") ?? streams[0];
|
||||||
|
const cam = deps.repo.getCameraById(ent.camera_id);
|
||||||
|
const rtsp = main?.rtsp_uri ?? cam?.rtsp_url ?? null;
|
||||||
|
if (!rtsp) return new Response("No RTSP URL", { status: 404 });
|
||||||
|
|
||||||
|
const jpeg = await captureSnapshot(rtsp, { timeoutMs: 8000 });
|
||||||
|
if (!jpeg) {
|
||||||
|
return new Response("Snapshot failed (camera unreachable or ffmpeg/gst missing)", { status: 502 });
|
||||||
|
}
|
||||||
|
return new Response(jpeg, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "image/jpeg",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Kiosks ---------------------------------------------------------------
|
// ---- Kiosks ---------------------------------------------------------------
|
||||||
|
|
||||||
app.get("/admin/kiosks", (event) => {
|
app.get("/admin/kiosks", (event) => {
|
||||||
|
|
@ -1054,6 +1123,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const displays = deps.repo.listDisplaysForKiosk(id);
|
const displays = deps.repo.listDisplaysForKiosk(id);
|
||||||
const firstDisplay = displays[0];
|
const firstDisplay = displays[0];
|
||||||
const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : [];
|
const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : [];
|
||||||
|
const gpioBindings = deps.repo.listGpioBindings(id);
|
||||||
return htmlPage(KioskEditPage({
|
return htmlPage(KioskEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
kiosk,
|
kiosk,
|
||||||
|
|
@ -1061,9 +1131,45 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
allLabels: deps.repo.listLabels(),
|
allLabels: deps.repo.listLabels(),
|
||||||
displays,
|
displays,
|
||||||
switchableLayouts,
|
switchableLayouts,
|
||||||
|
gpioBindings,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- GPIO bindings ----------------------------------------------------
|
||||||
|
app.post("/admin/kiosks/:id/gpio", async (event) => {
|
||||||
|
const kioskId = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const pin = Number(body?.["pin"]);
|
||||||
|
const direction = (body?.["direction"] ?? "in") === "out" ? "out" : "in";
|
||||||
|
const pullRaw = body?.["pull"];
|
||||||
|
const pull = pullRaw === "up" || pullRaw === "down" || pullRaw === "none" ? pullRaw : null;
|
||||||
|
const edgeRaw = body?.["edge"];
|
||||||
|
const edge = edgeRaw === "rising" || edgeRaw === "falling" || edgeRaw === "both" ? edgeRaw : null;
|
||||||
|
const chip = (body?.["chip"] ?? "gpiochip0").trim() || "gpiochip0";
|
||||||
|
const topic = (body?.["topic"] ?? "").trim();
|
||||||
|
if (Number.isFinite(pin) && topic) {
|
||||||
|
deps.repo.createGpioBinding({
|
||||||
|
kiosk_id: kioskId,
|
||||||
|
chip,
|
||||||
|
pin,
|
||||||
|
direction,
|
||||||
|
pull,
|
||||||
|
edge,
|
||||||
|
topic,
|
||||||
|
});
|
||||||
|
notifyKiosks();
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/kiosks/:id/gpio/:bindingId/delete", (event) => {
|
||||||
|
const kioskId = Number(getRouterParam(event, "id"));
|
||||||
|
const bindingId = Number(getRouterParam(event, "bindingId"));
|
||||||
|
deps.repo.deleteGpioBinding(bindingId);
|
||||||
|
notifyKiosks();
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } });
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id", async (event) => {
|
app.post("/admin/kiosks/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
|
@ -1106,13 +1212,39 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Layout switch ----------------------------------------------------
|
// ---- Layout switch ----------------------------------------------------
|
||||||
app.post("/admin/kiosks/:id/layout/:layoutId", (event) => {
|
const kioskLayoutSwitch = (event: any) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
const layoutId = Number(getRouterParam(event, "layoutId"));
|
const layoutId = Number(getRouterParam(event, "layoutId"));
|
||||||
if (Number.isFinite(id) && Number.isFinite(layoutId)) {
|
if (Number.isFinite(id) && Number.isFinite(layoutId)) {
|
||||||
getCoordinator().sendToKiosk(id, { type: "layout-switch", layout_id: layoutId });
|
getCoordinator().sendToKiosk(id, { type: "layout-switch", layout_id: layoutId });
|
||||||
}
|
}
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
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) {
|
||||||
|
getCoordinator().sendToKiosk(display.kiosk_id, {
|
||||||
|
type: "layout-switch",
|
||||||
|
display_id: displayId,
|
||||||
|
layout_id: layoutId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } });
|
||||||
|
};
|
||||||
|
app.post("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch);
|
||||||
|
app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch);
|
||||||
|
|
||||||
|
// Node-RED embedded page
|
||||||
|
app.get("/admin/nodered", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
return htmlPage(NoderedEmbedPage({ user: user.username }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- CEC power commands -----------------------------------------------
|
// ---- CEC power commands -----------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,11 @@ import type {
|
||||||
EntityType,
|
EntityType,
|
||||||
EventLog,
|
EventLog,
|
||||||
EventSourceType,
|
EventSourceType,
|
||||||
|
GpioDirection,
|
||||||
|
GpioEdge,
|
||||||
|
GpioPull,
|
||||||
Kiosk,
|
Kiosk,
|
||||||
|
KioskGpioBinding,
|
||||||
KioskLabel,
|
KioskLabel,
|
||||||
Label,
|
Label,
|
||||||
LabelRole,
|
LabelRole,
|
||||||
|
|
@ -280,6 +284,22 @@ export function rowToPairingCode(r: Row): PairingCode {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rowToKioskGpioBinding(r: Row): KioskGpioBinding {
|
||||||
|
const pullRaw = sn(r["pull"]);
|
||||||
|
const edgeRaw = sn(r["edge"]);
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
kiosk_id: n(r["kiosk_id"]),
|
||||||
|
chip: s(r["chip"]) || "gpiochip0",
|
||||||
|
pin: n(r["pin"]),
|
||||||
|
direction: s(r["direction"]) as GpioDirection,
|
||||||
|
pull: pullRaw ? (pullRaw as GpioPull) : null,
|
||||||
|
edge: edgeRaw ? (edgeRaw as GpioEdge) : null,
|
||||||
|
topic: s(r["topic"]),
|
||||||
|
created_at: s(r["created_at"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function rowToEventLog(r: Row): EventLog {
|
export function rowToEventLog(r: Row): EventLog {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: n(r["id"]),
|
||||||
|
|
|
||||||
|
|
@ -601,4 +601,18 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
(db: DatabaseSync) => {
|
(db: DatabaseSync) => {
|
||||||
addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'");
|
addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- kiosk GPIO bindings ----
|
||||||
|
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
||||||
|
chip TEXT NOT NULL DEFAULT 'gpiochip0',
|
||||||
|
pin INTEGER NOT NULL,
|
||||||
|
direction TEXT NOT NULL CHECK(direction IN ('in', 'out')),
|
||||||
|
pull TEXT CHECK(pull IN ('up', 'down', 'none')),
|
||||||
|
edge TEXT CHECK(edge IN ('rising', 'falling', 'both')),
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
) STRICT`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_kiosk_gpio_bindings_kiosk ON kiosk_gpio_bindings(kiosk_id)`,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@ import type {
|
||||||
EntityType,
|
EntityType,
|
||||||
EventLog,
|
EventLog,
|
||||||
EventSourceType,
|
EventSourceType,
|
||||||
|
GpioDirection,
|
||||||
|
GpioEdge,
|
||||||
|
GpioPull,
|
||||||
Kiosk,
|
Kiosk,
|
||||||
|
KioskGpioBinding,
|
||||||
KioskLabel,
|
KioskLabel,
|
||||||
Label,
|
Label,
|
||||||
LabelRole,
|
LabelRole,
|
||||||
|
|
@ -45,6 +49,7 @@ import {
|
||||||
rowToEntity,
|
rowToEntity,
|
||||||
rowToEventLog,
|
rowToEventLog,
|
||||||
rowToKiosk,
|
rowToKiosk,
|
||||||
|
rowToKioskGpioBinding,
|
||||||
rowToLabel,
|
rowToLabel,
|
||||||
rowToLayout,
|
rowToLayout,
|
||||||
rowToLayoutCell,
|
rowToLayoutCell,
|
||||||
|
|
@ -1377,6 +1382,55 @@ export class Repository {
|
||||||
void this.notify("labels", "delete", id);
|
void this.notify("labels", "delete", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// kiosk GPIO bindings
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
listGpioBindings(kioskId: number): KioskGpioBinding[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
"SELECT * FROM kiosk_gpio_bindings WHERE kiosk_id = ? ORDER BY chip, pin",
|
||||||
|
).all(kioskId);
|
||||||
|
return rs.map((r) => rowToKioskGpioBinding(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
getGpioBindingById(id: number): KioskGpioBinding | null {
|
||||||
|
const r = this.prep("SELECT * FROM kiosk_gpio_bindings WHERE id = ?").get(id);
|
||||||
|
return r ? rowToKioskGpioBinding(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createGpioBinding(input: {
|
||||||
|
kiosk_id: number;
|
||||||
|
chip?: string;
|
||||||
|
pin: number;
|
||||||
|
direction: GpioDirection;
|
||||||
|
pull?: GpioPull | null;
|
||||||
|
edge?: GpioEdge | null;
|
||||||
|
topic: string;
|
||||||
|
}): KioskGpioBinding {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO kiosk_gpio_bindings (kiosk_id, chip, pin, direction, pull, edge, topic)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.kiosk_id,
|
||||||
|
input.chip ?? "gpiochip0",
|
||||||
|
input.pin,
|
||||||
|
input.direction,
|
||||||
|
input.pull ?? null,
|
||||||
|
input.edge ?? null,
|
||||||
|
input.topic,
|
||||||
|
);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("kiosk_gpio_bindings", "create", id);
|
||||||
|
const b = this.getGpioBindingById(id);
|
||||||
|
if (!b) throw new Error("gpio binding vanished after insert");
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGpioBinding(id: number): void {
|
||||||
|
this.db.prepare(`DELETE FROM kiosk_gpio_bindings WHERE id = ?`).run(id);
|
||||||
|
void this.notify("kiosk_gpio_bindings", "delete", id);
|
||||||
|
}
|
||||||
|
|
||||||
updateLabel(id: number, patch: { name?: string; description?: string | null; color?: string | null }): void {
|
updateLabel(id: number, patch: { name?: string; description?: string | null; color?: string | null }): void {
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
const vals: unknown[] = [];
|
const vals: unknown[] = [];
|
||||||
|
|
|
||||||
|
|
@ -70,12 +70,37 @@ export interface BundleDisplay {
|
||||||
default_layout_id: number | null;
|
default_layout_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BundleDisplayWithLayouts extends BundleDisplay {
|
||||||
|
layouts: BundleLayout[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleGpioBinding {
|
||||||
|
id: number;
|
||||||
|
chip: string;
|
||||||
|
pin: number;
|
||||||
|
direction: "in" | "out";
|
||||||
|
pull: "up" | "down" | "none" | null;
|
||||||
|
edge: "rising" | "falling" | "both" | null;
|
||||||
|
topic: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KioskBundle {
|
export interface KioskBundle {
|
||||||
kiosk_id: number;
|
kiosk_id: number;
|
||||||
kiosk_name: string;
|
kiosk_name: string;
|
||||||
|
/**
|
||||||
|
* @deprecated Use `displays` (array). Kept for backward compat with older
|
||||||
|
* kiosk builds that consume a single display. Mirrors `displays[0]`.
|
||||||
|
*/
|
||||||
display: BundleDisplay;
|
display: BundleDisplay;
|
||||||
|
/**
|
||||||
|
* @deprecated Use `displays[N].layouts`. Mirrors `displays[0].layouts` for
|
||||||
|
* older kiosk builds.
|
||||||
|
*/
|
||||||
layouts: BundleLayout[];
|
layouts: BundleLayout[];
|
||||||
|
/** All physical displays driven by this kiosk. New (multi-display) shape. */
|
||||||
|
displays: BundleDisplayWithLayouts[];
|
||||||
cameras: BundleCamera[];
|
cameras: BundleCamera[];
|
||||||
|
gpio_bindings: BundleGpioBinding[];
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,76 +113,90 @@ export function generateBundle(
|
||||||
const kiosk = repo.getKioskById(kioskId);
|
const kiosk = repo.getKioskById(kioskId);
|
||||||
if (!kiosk) return null;
|
if (!kiosk) return null;
|
||||||
|
|
||||||
// Find display for this kiosk (displays now point to kiosks via kiosk_id)
|
// Find all displays for this kiosk (displays now point to kiosks via kiosk_id)
|
||||||
const kioskDisplays = repo.listDisplaysForKiosk(kioskId);
|
const kioskDisplays = repo.listDisplaysForKiosk(kioskId);
|
||||||
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet
|
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet
|
||||||
let display = kioskDisplays[0] ?? null;
|
const displays = kioskDisplays.length > 0
|
||||||
if (!display && kiosk.display_id) {
|
? kioskDisplays
|
||||||
display = repo.getDisplayById(kiosk.display_id);
|
: (kiosk.display_id ? [repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable<typeof d> => d != null) : []);
|
||||||
|
|
||||||
|
if (displays.length === 0) return null;
|
||||||
|
|
||||||
|
// Collect camera IDs across ALL displays' layouts (de-duped).
|
||||||
|
const allLayoutIds = new Set<number>();
|
||||||
|
for (const d of displays) {
|
||||||
|
for (const l of repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id);
|
||||||
}
|
}
|
||||||
if (!display) return null;
|
const cameras = repo.camerasForLayoutIds([...allLayoutIds]);
|
||||||
|
|
||||||
const layouts = repo.layoutsForDisplayId(display.id);
|
function buildLayouts(displayId: number, defaultLayoutId: number | null): BundleLayout[] {
|
||||||
const layoutIds = layouts.map((l) => l.id);
|
const layouts = repo.layoutsForDisplayId(displayId);
|
||||||
|
return layouts.map((l) => {
|
||||||
// Collect all cameras referenced by cells in these layouts
|
const cells = repo.layoutCells(l.id);
|
||||||
const cameras = repo.camerasForLayoutIds(layoutIds);
|
let gridCols = 1;
|
||||||
|
let gridRows = 1;
|
||||||
const defaultLayoutId = display.default_layout_id;
|
for (const c of cells) {
|
||||||
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
const right = c.col + c.col_span;
|
||||||
const cells = repo.layoutCells(l.id);
|
const bottom = c.row + c.row_span;
|
||||||
let gridCols = 1;
|
if (right > gridCols) gridCols = right;
|
||||||
let gridRows = 1;
|
if (bottom > gridRows) gridRows = bottom;
|
||||||
for (const c of cells) {
|
}
|
||||||
const right = c.col + c.col_span;
|
return {
|
||||||
const bottom = c.row + c.row_span;
|
id: l.id,
|
||||||
if (right > gridCols) gridCols = right;
|
name: l.name,
|
||||||
if (bottom > gridRows) gridRows = bottom;
|
grid_cols: gridCols,
|
||||||
}
|
grid_rows: gridRows,
|
||||||
return {
|
priority: l.priority,
|
||||||
id: l.id,
|
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
||||||
name: l.name,
|
preload_camera_ids: l.preload_camera_ids,
|
||||||
grid_cols: gridCols,
|
resets_idle_timer: l.resets_idle_timer,
|
||||||
grid_rows: gridRows,
|
is_default: defaultLayoutId === l.id,
|
||||||
priority: l.priority,
|
cells: cells.map((c) => {
|
||||||
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
// If the cell has an entity, prefer its current content so admin
|
||||||
preload_camera_ids: l.preload_camera_ids,
|
// edits to the entity propagate without forcing a cell-touch. The
|
||||||
resets_idle_timer: l.resets_idle_timer,
|
// bundle still ships the legacy camera_id/web_url/html_content shape
|
||||||
is_default: defaultLayoutId === l.id,
|
// so the existing Rust kiosk consumes it unchanged.
|
||||||
cells: cells.map((c) => {
|
let contentType = c.content_type;
|
||||||
// If the cell has an entity, prefer its current content so admin
|
let cameraId = c.camera_id;
|
||||||
// edits to the entity propagate without forcing a cell-touch. The
|
let webUrl = c.web_url;
|
||||||
// bundle still ships the legacy camera_id/web_url/html_content shape
|
let htmlContent = c.html_content;
|
||||||
// so the existing Rust kiosk consumes it unchanged.
|
if (c.entity_id != null) {
|
||||||
let contentType = c.content_type;
|
const ent = repo.getEntityById(c.entity_id);
|
||||||
let cameraId = c.camera_id;
|
if (ent) {
|
||||||
let webUrl = c.web_url;
|
contentType = ent.type;
|
||||||
let htmlContent = c.html_content;
|
cameraId = ent.type === "camera" ? ent.camera_id : null;
|
||||||
if (c.entity_id != null) {
|
webUrl = ent.type === "web" ? ent.web_url : null;
|
||||||
const ent = repo.getEntityById(c.entity_id);
|
htmlContent = ent.type === "html" ? ent.html_content : null;
|
||||||
if (ent) {
|
}
|
||||||
contentType = ent.type;
|
|
||||||
cameraId = ent.type === "camera" ? ent.camera_id : null;
|
|
||||||
webUrl = ent.type === "web" ? ent.web_url : null;
|
|
||||||
htmlContent = ent.type === "html" ? ent.html_content : null;
|
|
||||||
}
|
}
|
||||||
}
|
return {
|
||||||
return {
|
row: c.row,
|
||||||
row: c.row,
|
col: c.col,
|
||||||
col: c.col,
|
row_span: c.row_span,
|
||||||
row_span: c.row_span,
|
col_span: c.col_span,
|
||||||
col_span: c.col_span,
|
content_type: contentType,
|
||||||
content_type: contentType,
|
camera_id: cameraId,
|
||||||
camera_id: cameraId,
|
stream_selector: c.stream_selector,
|
||||||
stream_selector: c.stream_selector,
|
web_url: webUrl,
|
||||||
web_url: webUrl,
|
html_content: htmlContent,
|
||||||
html_content: htmlContent,
|
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
||||||
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
fit: c.fit,
|
||||||
fit: c.fit,
|
};
|
||||||
};
|
}),
|
||||||
}),
|
};
|
||||||
};
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const bundleDisplays: BundleDisplayWithLayouts[] = displays.map((display) => ({
|
||||||
|
id: display.id,
|
||||||
|
name: display.name,
|
||||||
|
width_px: display.width_px,
|
||||||
|
height_px: display.height_px,
|
||||||
|
idle_timeout_seconds: display.idle_timeout_seconds,
|
||||||
|
sleep_timeout_seconds: display.sleep_timeout_seconds,
|
||||||
|
default_layout_id: display.default_layout_id,
|
||||||
|
layouts: buildLayouts(display.id, display.default_layout_id),
|
||||||
|
}));
|
||||||
|
|
||||||
const bundleCameras: BundleCamera[] = cameras.map((cam) => {
|
const bundleCameras: BundleCamera[] = cameras.map((cam) => {
|
||||||
const streams = repo.listCameraStreams(cam.id);
|
const streams = repo.listCameraStreams(cam.id);
|
||||||
|
|
@ -188,20 +227,36 @@ export function generateBundle(
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gpioBindings: BundleGpioBinding[] = repo.listGpioBindings(kioskId).map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
chip: g.chip,
|
||||||
|
pin: g.pin,
|
||||||
|
direction: g.direction,
|
||||||
|
pull: g.pull,
|
||||||
|
edge: g.edge,
|
||||||
|
topic: g.topic,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mirror first display into the legacy top-level `display` + `layouts` so
|
||||||
|
// older kiosk builds keep working unchanged. New builds should read
|
||||||
|
// `displays`.
|
||||||
|
const primary = bundleDisplays[0]!;
|
||||||
const bundle: KioskBundle = {
|
const bundle: KioskBundle = {
|
||||||
kiosk_id: kioskId,
|
kiosk_id: kioskId,
|
||||||
kiosk_name: kiosk.name,
|
kiosk_name: kiosk.name,
|
||||||
display: {
|
display: {
|
||||||
id: display.id,
|
id: primary.id,
|
||||||
name: display.name,
|
name: primary.name,
|
||||||
width_px: display.width_px,
|
width_px: primary.width_px,
|
||||||
height_px: display.height_px,
|
height_px: primary.height_px,
|
||||||
idle_timeout_seconds: display.idle_timeout_seconds,
|
idle_timeout_seconds: primary.idle_timeout_seconds,
|
||||||
sleep_timeout_seconds: display.sleep_timeout_seconds,
|
sleep_timeout_seconds: primary.sleep_timeout_seconds,
|
||||||
default_layout_id: display.default_layout_id,
|
default_layout_id: primary.default_layout_id,
|
||||||
},
|
},
|
||||||
layouts: bundleLayouts,
|
layouts: primary.layouts,
|
||||||
|
displays: bundleDisplays,
|
||||||
cameras: bundleCameras,
|
cameras: bundleCameras,
|
||||||
|
gpio_bindings: gpioBindings,
|
||||||
version: "",
|
version: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
123
server/src/shared/snapshot.ts
Normal file
123
server/src/shared/snapshot.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/**
|
||||||
|
* Camera snapshot capture.
|
||||||
|
*
|
||||||
|
* Spawns ffmpeg (preferred) or gst-launch-1.0 to pull one frame from an RTSP
|
||||||
|
* URL and return it as a JPEG buffer. Bounded by a hard timeout so a stuck
|
||||||
|
* connection can't pile up subprocesses.
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { unlink, readFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
export interface SnapshotOptions {
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a single frame from an RTSP URL. Returns a JPEG buffer, or null on
|
||||||
|
* any failure (timeout, missing binary, non-zero exit, empty file).
|
||||||
|
*
|
||||||
|
* Tries ffmpeg first (fast, widely installed). Falls back to gst-launch-1.0.
|
||||||
|
*/
|
||||||
|
export async function captureSnapshot(
|
||||||
|
rtspUrl: string,
|
||||||
|
opts: SnapshotOptions = {},
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
if (!rtspUrl || !rtspUrl.startsWith("rtsp://")) return null;
|
||||||
|
|
||||||
|
const result = await tryFfmpeg(rtspUrl, timeoutMs);
|
||||||
|
if (result) return result;
|
||||||
|
return tryGstLaunch(rtspUrl, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryFfmpeg(rtspUrl: string, timeoutMs: number): Promise<Buffer | null> {
|
||||||
|
const out = join(tmpdir(), `bf-snap-${randomBytes(8).toString("hex")}.jpg`);
|
||||||
|
// -y overwrite, -rtsp_transport tcp (most reliable), -frames:v 1 single frame,
|
||||||
|
// -an no audio. Use mjpeg via filename extension.
|
||||||
|
const args = [
|
||||||
|
"-y",
|
||||||
|
"-rtsp_transport", "tcp",
|
||||||
|
"-i", rtspUrl,
|
||||||
|
"-frames:v", "1",
|
||||||
|
"-q:v", "5",
|
||||||
|
"-an",
|
||||||
|
out,
|
||||||
|
];
|
||||||
|
const ok = await runWithTimeout("ffmpeg", args, timeoutMs);
|
||||||
|
if (!ok) {
|
||||||
|
void unlink(out).catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buf = await readFile(out);
|
||||||
|
return buf.length > 0 ? buf : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
void unlink(out).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryGstLaunch(rtspUrl: string, timeoutMs: number): Promise<Buffer | null> {
|
||||||
|
const out = join(tmpdir(), `bf-snap-${randomBytes(8).toString("hex")}.jpg`);
|
||||||
|
// rtspsrc → decodebin → videoconvert → jpegenc → single-frame filesink
|
||||||
|
const args = [
|
||||||
|
"-q",
|
||||||
|
"rtspsrc", `location=${rtspUrl}`, "protocols=tcp", "latency=200", "!",
|
||||||
|
"decodebin", "!",
|
||||||
|
"videoconvert", "!",
|
||||||
|
"jpegenc", "!",
|
||||||
|
"filesink", `location=${out}`,
|
||||||
|
"num-buffers=1",
|
||||||
|
];
|
||||||
|
const ok = await runWithTimeout("gst-launch-1.0", args, timeoutMs);
|
||||||
|
if (!ok) {
|
||||||
|
void unlink(out).catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buf = await readFile(out);
|
||||||
|
return buf.length > 0 ? buf : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
void unlink(out).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWithTimeout(bin: string, args: string[], timeoutMs: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let child;
|
||||||
|
try {
|
||||||
|
child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||||
|
} catch {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let settled = false;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
||||||
|
resolve(false);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.on("error", () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(code === 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -237,6 +237,22 @@ export interface PairingCode {
|
||||||
extras: Record<string, unknown>;
|
extras: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GpioDirection = "in" | "out";
|
||||||
|
export type GpioPull = "up" | "down" | "none";
|
||||||
|
export type GpioEdge = "rising" | "falling" | "both";
|
||||||
|
|
||||||
|
export interface KioskGpioBinding {
|
||||||
|
id: number;
|
||||||
|
kiosk_id: number;
|
||||||
|
chip: string;
|
||||||
|
pin: number;
|
||||||
|
direction: GpioDirection;
|
||||||
|
pull: GpioPull | null;
|
||||||
|
edge: GpioEdge | null;
|
||||||
|
topic: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventLog {
|
export interface EventLog {
|
||||||
id: number;
|
id: number;
|
||||||
source_kiosk_id: number | null;
|
source_kiosk_id: number | null;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
Display,
|
Display,
|
||||||
Entity,
|
Entity,
|
||||||
Kiosk,
|
Kiosk,
|
||||||
|
KioskGpioBinding,
|
||||||
Label,
|
Label,
|
||||||
Layout as LayoutType,
|
Layout as LayoutType,
|
||||||
LayoutCell,
|
LayoutCell,
|
||||||
|
|
@ -691,6 +692,31 @@ export function EntityEditPage(props: EntityEditPageProps) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{e.type === "camera" && e.camera_id != null && (
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Live Preview</label>
|
||||||
|
<div style="background:#111827; border-radius:4px; overflow:hidden; aspect-ratio:16/9; display:flex; align-items:center; justify-content:center">
|
||||||
|
<img
|
||||||
|
id={`snap-${String(e.id)}`}
|
||||||
|
src={`/admin/entities/${e.id}/snapshot?t=${String(Date.now())}`}
|
||||||
|
alt="Camera snapshot"
|
||||||
|
style="width:100%; height:100%; object-fit:contain; display:block"
|
||||||
|
{...{ "onerror": "this.style.display='none'; var s=this.nextElementSibling; if(s) s.style.display='block';" }}
|
||||||
|
/>
|
||||||
|
<span style="display:none; color:#fca5a5; font-size:0.85rem">Snapshot failed — camera unreachable or RTSP not configured</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:0.5rem">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
{...{ "onclick": `(function(){var img=document.getElementById('snap-${String(e.id)}'); if(img){img.style.display='block'; img.src='/admin/entities/${String(e.id)}/snapshot?t='+Date.now();}})()` }}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<span style="margin-left:0.5rem; color:#666; font-size:0.8rem">Pulls one frame via ffmpeg/gst (up to ~8s).</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{e.type === "web" && (
|
{e.type === "web" && (
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="web_url">URL</label>
|
<label for="web_url">URL</label>
|
||||||
|
|
@ -1165,6 +1191,7 @@ interface KioskEditProps {
|
||||||
allLabels: Label[];
|
allLabels: Label[];
|
||||||
displays?: Display[];
|
displays?: Display[];
|
||||||
switchableLayouts?: LayoutType[];
|
switchableLayouts?: LayoutType[];
|
||||||
|
gpioBindings?: KioskGpioBinding[];
|
||||||
error?: string;
|
error?: string;
|
||||||
success?: string;
|
success?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1284,6 +1311,93 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* GPIO bindings */}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">GPIO Bindings</h2>
|
||||||
|
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
||||||
|
Each input binding fires an event with the configured topic when the
|
||||||
|
pin's edge triggers. Pi 5's main GPIO chip is <code>gpiochip4</code>;
|
||||||
|
older Pis use <code>gpiochip0</code>.
|
||||||
|
</p>
|
||||||
|
{props.gpioBindings && props.gpioBindings.length > 0 ? (
|
||||||
|
<div class="table-wrap" style="margin-bottom:1rem">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Chip</th>
|
||||||
|
<th>Pin</th>
|
||||||
|
<th>Dir</th>
|
||||||
|
<th>Pull</th>
|
||||||
|
<th>Edge</th>
|
||||||
|
<th>Topic</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.gpioBindings.map((g) => (
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:monospace; font-size:0.85rem">{g.chip}</td>
|
||||||
|
<td style="font-family:monospace">{String(g.pin)}</td>
|
||||||
|
<td><span class="badge badge-gray">{g.direction}</span></td>
|
||||||
|
<td style="font-size:0.85rem">{g.pull ?? "—"}</td>
|
||||||
|
<td style="font-size:0.85rem">{g.edge ?? "—"}</td>
|
||||||
|
<td style="font-family:monospace; font-size:0.85rem">{g.topic}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action={`/admin/kiosks/${k.id}/gpio/${g.id}/delete`} style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Remove GPIO binding?')"}}>×</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style="color:#999; margin-bottom:1rem">No GPIO bindings configured</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form method="post" action={`/admin/kiosks/${k.id}/gpio`} style="display:grid; grid-template-columns:repeat(6, 1fr) auto; gap:0.5rem; align-items:end">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.75rem; color:#666">Chip</label>
|
||||||
|
<input name="chip" class="form-input" value="gpiochip0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.75rem; color:#666">Pin</label>
|
||||||
|
<input name="pin" type="number" class="form-input" required min="0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.75rem; color:#666">Dir</label>
|
||||||
|
<select name="direction" class="form-input">
|
||||||
|
<option value="in">in</option>
|
||||||
|
<option value="out">out</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.75rem; color:#666">Pull</label>
|
||||||
|
<select name="pull" class="form-input">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="up">up</option>
|
||||||
|
<option value="down">down</option>
|
||||||
|
<option value="none">none</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.75rem; color:#666">Edge</label>
|
||||||
|
<select name="edge" class="form-input">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="rising">rising</option>
|
||||||
|
<option value="falling">falling</option>
|
||||||
|
<option value="both">both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.75rem; color:#666">Topic</label>
|
||||||
|
<input name="topic" class="form-input" required placeholder="gpio/button-1" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
||||||
{props.labels.length > 0 ? (
|
{props.labels.length > 0 ? (
|
||||||
|
|
@ -1926,6 +2040,25 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
|
||||||
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div>
|
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{props.attachedLayouts.length > 0 && d.kiosk_id ? (
|
||||||
|
<div style="margin-bottom:1rem; padding:0.75rem; background:#f9fafb; border-radius:4px">
|
||||||
|
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout Now</div>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action={`/admin/displays/${d.id}/layout/0`}
|
||||||
|
style="display:flex; gap:0.5rem; align-items:center"
|
||||||
|
{...{ "onsubmit": "this.action = this.action.replace(/\\/layout\\/.*/, '/layout/' + this.layout_id.value); return true;" }}
|
||||||
|
>
|
||||||
|
<select name="layout_id" class="form-input" style="flex:1">
|
||||||
|
{props.attachedLayouts.map((l) => (
|
||||||
|
<option value={String(l.id)}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-sm">Switch</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<form method="post" action={`/admin/displays/${d.id}`}>
|
<form method="post" action={`/admin/displays/${d.id}`}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
|
|
@ -2090,3 +2223,135 @@ function formatTime(iso: string): string {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- System Health ----------------------------------------------------------
|
||||||
|
|
||||||
|
interface SystemHealthRow {
|
||||||
|
kiosk: Kiosk;
|
||||||
|
online: boolean;
|
||||||
|
bundleMismatch: boolean;
|
||||||
|
expectedBundleVersion: string | null;
|
||||||
|
displays: Display[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemHealthPageProps {
|
||||||
|
user: string;
|
||||||
|
rows: SystemHealthRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempBadge(temp: number | null) {
|
||||||
|
if (temp == null) return <span class="badge badge-gray">—</span>;
|
||||||
|
const txt = `${temp.toFixed(1)}°C`;
|
||||||
|
if (temp >= 80) return <span class="badge badge-red">{txt}</span>;
|
||||||
|
if (temp >= 70) return <span class="badge" style="background-color:#fef3c7; color:#92400e">{txt}</span>;
|
||||||
|
return <span class="badge badge-green">{txt}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Node-RED Embed ---------------------------------------------------
|
||||||
|
|
||||||
|
export function NoderedEmbedPage(props: { user: string }) {
|
||||||
|
return (
|
||||||
|
<Layout title="Node-RED" user={props.user} activeNav="nodered">
|
||||||
|
<div style="position:fixed; top:48px; left:220px; right:0; bottom:0; background:#fff">
|
||||||
|
<iframe
|
||||||
|
src="/nrdp/"
|
||||||
|
style="width:100%; height:100%; border:none; display:block"
|
||||||
|
{...{ "sandbox": "allow-same-origin allow-scripts allow-forms allow-popups allow-downloads" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemHealthPage(props: SystemHealthPageProps) {
|
||||||
|
const total = props.rows.length;
|
||||||
|
const online = props.rows.filter((r) => r.online).length;
|
||||||
|
const hot = props.rows.filter((r) => r.kiosk.cpu_temp_c != null && r.kiosk.cpu_temp_c >= 70).length;
|
||||||
|
const mismatched = props.rows.filter((r) => r.bundleMismatch).length;
|
||||||
|
return (
|
||||||
|
<Layout title="System Health" user={props.user} activeNav="health">
|
||||||
|
<meta http-equiv="refresh" content="30" />
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Kiosks</div>
|
||||||
|
<div class="stat-value">{String(total)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Online</div>
|
||||||
|
<div class="stat-value" style={online === total ? "color:#065f46" : "color:#92400e"}>{String(online)}/{String(total)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Hot (≥70°C)</div>
|
||||||
|
<div class="stat-value" style={hot > 0 ? "color:#b91c1c" : "color:#065f46"}>{String(hot)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Bundle Mismatch</div>
|
||||||
|
<div class="stat-value" style={mismatched > 0 ? "color:#b91c1c" : "color:#065f46"}>{String(mismatched)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
|
||||||
|
Auto-refresh every 30 seconds. Online = last seen within 5 minutes.
|
||||||
|
</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kiosk</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
<th>CPU Temp</th>
|
||||||
|
<th>Fan</th>
|
||||||
|
<th>Bundle</th>
|
||||||
|
<th>Displays</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.rows.length === 0 ? (
|
||||||
|
<tr><td colspan="7" style="text-align:center; color:#999; padding:2rem">No kiosks paired</td></tr>
|
||||||
|
) : (
|
||||||
|
props.rows.map((row) => {
|
||||||
|
const k = row.kiosk;
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/admin/kiosks/${k.id}`}><strong>{k.name}</strong></a></td>
|
||||||
|
<td>
|
||||||
|
{row.online
|
||||||
|
? <span class="badge badge-green">Online</span>
|
||||||
|
: <span class="badge badge-red">Offline</span>}
|
||||||
|
</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">
|
||||||
|
{k.fan_rpm != null ? `${String(k.fan_rpm)} RPM` : "—"}
|
||||||
|
{k.fan_pwm != null && (
|
||||||
|
<span style="color:#999"> ({String(k.fan_pwm)}/255)</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:0.85rem">
|
||||||
|
{row.bundleMismatch ? (
|
||||||
|
<span class="badge badge-red" title={`expected ${row.expectedBundleVersion ?? "?"}, have ${k.last_bundle_version ?? "none"}`}>mismatch</span>
|
||||||
|
) : k.last_bundle_version ? (
|
||||||
|
<span class="badge badge-green">{k.last_bundle_version.slice(0, 8)}</span>
|
||||||
|
) : (
|
||||||
|
<span class="badge badge-gray">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:0.85rem">
|
||||||
|
{row.displays.length === 0 ? (
|
||||||
|
<span style="color:#999">none</span>
|
||||||
|
) : (
|
||||||
|
row.displays.map((d) => (
|
||||||
|
<div>{d.name}: {String(d.width_px)}×{String(d.height_px)}</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<NavItem href="/admin/" label="Overview" icon="■" active={a === "overview"} />
|
<NavItem href="/admin/" label="Overview" icon="■" active={a === "overview"} />
|
||||||
|
<NavItem href="/admin/health" label="Health" icon="♥" active={a === "health"} />
|
||||||
<NavItem href="/admin/cameras" label="Cameras" icon="⚫" active={a === "cameras"} />
|
<NavItem href="/admin/cameras" label="Cameras" icon="⚫" active={a === "cameras"} />
|
||||||
<NavItem href="/admin/entities" label="Entities" icon="⚇" active={a === "entities"} />
|
<NavItem href="/admin/entities" label="Entities" icon="⚇" active={a === "entities"} />
|
||||||
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
||||||
|
|
@ -51,7 +52,7 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||||
<hr />
|
<hr />
|
||||||
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
||||||
<NavItem href="/nrdp/" label="Node-RED" icon="→" />
|
<NavItem href="/admin/nodered" label="Node-RED" icon="→" active={a === "nodered"} />
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue