mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +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"
|
||||
url = "2"
|
||||
webkit6 = "0.4"
|
||||
gpiod = "0.3"
|
||||
|
|
|
|||
|
|
@ -4,12 +4,47 @@ use serde::{Deserialize, Serialize};
|
|||
pub struct KioskBundle {
|
||||
pub kiosk_id: u32,
|
||||
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>,
|
||||
/// All physical displays driven by this kiosk.
|
||||
#[serde(default)]
|
||||
pub displays: Vec<BundleDisplayWithLayouts>,
|
||||
pub cameras: Vec<BundleCamera>,
|
||||
#[serde(default)]
|
||||
pub gpio_bindings: Vec<BundleGpioBinding>,
|
||||
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)]
|
||||
pub struct BundleDisplay {
|
||||
pub id: u32,
|
||||
|
|
@ -21,6 +56,19 @@ pub struct BundleDisplay {
|
|||
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)]
|
||||
pub struct BundleLayout {
|
||||
pub id: u32,
|
||||
|
|
@ -76,6 +124,17 @@ pub struct BundleStream {
|
|||
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 {
|
||||
/// 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.
|
||||
|
|
|
|||
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 bundle;
|
||||
mod cec;
|
||||
mod gpio;
|
||||
mod hwmon;
|
||||
mod pipeline;
|
||||
mod ui;
|
||||
|
|
|
|||
460
kiosk/src/ui.rs
460
kiosk/src/ui.rs
|
|
@ -1,4 +1,5 @@
|
|||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
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 tracing::{info, warn};
|
||||
|
||||
use crate::bundle::KioskBundle;
|
||||
use crate::bundle::{BundleDisplayWithLayouts, KioskBundle};
|
||||
use crate::cec;
|
||||
use crate::gpio;
|
||||
use crate::hwmon;
|
||||
use crate::pipeline;
|
||||
use crate::server;
|
||||
use crate::ws_client;
|
||||
use crate::ServerMsg;
|
||||
|
||||
/// Per-display runtime state. Kept inside a thread-local hashmap keyed by
|
||||
/// display id, so all the idle/sleep/layout tracking is local to that display
|
||||
/// 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! {
|
||||
/// camera_id → (pipeline, paintable, badge). Pipelines stay warm across
|
||||
/// layout swaps for cameras still referenced or in preload_camera_ids.
|
||||
/// badge is 'M' / 'S' / ' ' indicating which stream is active.
|
||||
static WARM_CAMERAS: RefCell<std::collections::HashMap<u32, (gstreamer::Pipeline, gtk::gdk::Paintable, char)>>
|
||||
= RefCell::new(std::collections::HashMap::new());
|
||||
/// Shared across ALL displays — if two displays use the same camera the
|
||||
/// pipeline is reused. The paintable can be attached to multiple Pictures.
|
||||
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.
|
||||
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.
|
||||
static CURRENT_AUTH: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
|
||||
|
||||
/// Layout id currently on screen, if any.
|
||||
static CURRENT_LAYOUT_ID: Cell<Option<u32>> = const { Cell::new(None) };
|
||||
|
||||
/// 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) };
|
||||
/// Per-display state, keyed by bundle display id.
|
||||
static DISPLAYS: RefCell<HashMap<u32, DisplayState>> = RefCell::new(HashMap::new());
|
||||
|
||||
/// Has the idle-watchdog already been installed on the main loop?
|
||||
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
||||
|
|
@ -52,7 +59,9 @@ pub fn build_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)
|
||||
.title("BetterFrame")
|
||||
.fullscreened(true)
|
||||
|
|
@ -61,13 +70,13 @@ fn activate(app: &Application) {
|
|||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_string("window { background-color: #000000; }");
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&WidgetExt::display(&window),
|
||||
&WidgetExt::display(&pairing_window),
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
|
||||
show_logo(&window);
|
||||
window.present();
|
||||
show_logo(&pairing_window);
|
||||
pairing_window.present();
|
||||
|
||||
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.
|
||||
let initial = match server::fetch_bundle(&server, &key) {
|
||||
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)
|
||||
}
|
||||
None => {
|
||||
|
|
@ -130,10 +139,6 @@ fn activate(app: &Application) {
|
|||
let retry_server = server.clone();
|
||||
let retry_key = key.clone();
|
||||
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 {
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
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
|
||||
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 || {
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
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) => {
|
||||
render_bundle(&window_clone, bundle, &server, &key);
|
||||
install_idle_watchdog(&window_clone);
|
||||
render_bundle(&app_clone, &pairing_window_clone, bundle, &server, &key);
|
||||
install_idle_watchdog();
|
||||
}
|
||||
WorkerMsg::SwitchLayout(id) => {
|
||||
render_layout(&window_clone, id);
|
||||
switch_layout_anywhere(id);
|
||||
}
|
||||
WorkerMsg::Wake => {
|
||||
cec::wake();
|
||||
IS_ASLEEP.with(|c| c.set(false));
|
||||
mark_activity();
|
||||
DISPLAYS.with(|ds| {
|
||||
for st in ds.borrow_mut().values_mut() {
|
||||
st.is_asleep = false;
|
||||
st.last_activity = Instant::now();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -217,65 +227,77 @@ enum WorkerMsg {
|
|||
Wake,
|
||||
}
|
||||
|
||||
/// Reset activity timer. If we were asleep, wake the display first.
|
||||
fn mark_activity() {
|
||||
LAST_ACTIVITY.with(|t| *t.borrow_mut() = Instant::now());
|
||||
if IS_ASLEEP.with(|c| c.get()) {
|
||||
info!("activity while asleep → waking display");
|
||||
cec::wake();
|
||||
IS_ASLEEP.with(|c| c.set(false));
|
||||
}
|
||||
/// Reset activity timer for one display. If asleep, wake it.
|
||||
fn mark_activity(display_id: u32) {
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
st.last_activity = Instant::now();
|
||||
if st.is_asleep {
|
||||
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.
|
||||
/// Safe to call multiple times — installs at most once.
|
||||
fn install_idle_watchdog(window: &ApplicationWindow) {
|
||||
/// Install the once-per-second watchdog that enforces idle/sleep timeouts
|
||||
/// per display. Safe to call multiple times — installs at most once.
|
||||
fn install_idle_watchdog() {
|
||||
if WATCHDOG_INSTALLED.with(|c| c.get()) { return; }
|
||||
WATCHDOG_INSTALLED.with(|c| c.set(true));
|
||||
let window = window.clone();
|
||||
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.
|
||||
let (idle_to, sleep_to, default_id) = CURRENT_BUNDLE.with(|b| {
|
||||
match b.borrow().as_ref() {
|
||||
Some(bundle) => (
|
||||
bundle.display.idle_timeout_seconds as u64,
|
||||
bundle.display.sleep_timeout_seconds as u64,
|
||||
bundle.display.default_layout_id,
|
||||
),
|
||||
None => (0, 0, None),
|
||||
// Snapshot per-display timing decisions so we can act outside the borrow.
|
||||
struct Action { display_id: u32, revert_to: Option<u32>, sleep: bool }
|
||||
let mut actions: Vec<Action> = Vec::new();
|
||||
|
||||
DISPLAYS.with(|ds| {
|
||||
for (display_id, st) in ds.borrow().iter() {
|
||||
let Some(d) = bundle.normalized_displays().into_iter().find(|d| d.id == *display_id) else { continue };
|
||||
let idle_to = d.idle_timeout_seconds as u64;
|
||||
let sleep_to = d.sleep_timeout_seconds as u64;
|
||||
let elapsed = st.last_activity.elapsed();
|
||||
let default_id = d.default_layout_id;
|
||||
|
||||
let mut act = Action { display_id: *display_id, revert_to: None, sleep: false };
|
||||
|
||||
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
|
||||
// default AND current layout doesn't itself reset the idle timer.
|
||||
if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
|
||||
let cur = CURRENT_LAYOUT_ID.with(|c| c.get());
|
||||
let cur_resets_idle = CURRENT_BUNDLE.with(|b| {
|
||||
let bundle = b.borrow();
|
||||
let Some(bundle) = bundle.as_ref() else { return false };
|
||||
let Some(cur_id) = cur else { return false };
|
||||
bundle.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)) = (cur, default_id) {
|
||||
if cur_id != def_id && cur_resets_idle {
|
||||
info!("idle timeout reached → reverting to default layout");
|
||||
render_layout(&window, def_id);
|
||||
}
|
||||
for a in actions {
|
||||
if let Some(layout_id) = a.revert_to {
|
||||
info!("idle timeout reached → reverting display {} to default", a.display_id);
|
||||
render_layout(a.display_id, layout_id);
|
||||
}
|
||||
if a.sleep {
|
||||
info!("sleep timeout reached on display {} → CEC standby", a.display_id);
|
||||
cec::standby();
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&a.display_id) {
|
||||
st.is_asleep = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -289,20 +311,17 @@ fn query_displays() -> Vec<(String, u32, u32)> {
|
|||
let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { return out };
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
// Skip non-HDMI connectors and the "card" parents
|
||||
if !name.contains("-HDMI-") && !name.contains("-DP-") { continue; }
|
||||
let path = entry.path();
|
||||
let status = std::fs::read_to_string(path.join("status")).unwrap_or_default();
|
||||
if status.trim() != "connected" { continue; }
|
||||
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 parts: Vec<&str> = mode.split('x').collect();
|
||||
if parts.len() != 2 { continue; }
|
||||
let w: u32 = parts[0].parse().unwrap_or(0);
|
||||
let h: u32 = parts[1].trim().parse().unwrap_or(0);
|
||||
if w == 0 || h == 0 { continue; }
|
||||
// Strip "cardN-" prefix for cleaner name
|
||||
let clean_name = name.split_once('-').map(|(_, rest)| rest.to_string()).unwrap_or(name);
|
||||
out.push((clean_name, w, h));
|
||||
}
|
||||
|
|
@ -331,33 +350,160 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) {
|
|||
window.set_child(Some(&vbox));
|
||||
}
|
||||
|
||||
fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &str, kiosk_key: &str) {
|
||||
// Cache the bundle + auth so layout-switch and idle-revert can re-render
|
||||
// without needing a full reload.
|
||||
/// Render a fresh bundle: rebuild the per-display window set, restart GPIO
|
||||
/// workers, recompute warm-camera needs across all displays.
|
||||
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_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
|
||||
.or_else(|| bundle.layouts.iter().find(|l| l.is_default).map(|l| l.id));
|
||||
// Restart GPIO workers (always — even if list is empty, this drops the old set).
|
||||
gpio::start_workers(&bundle.gpio_bindings, server_url, kiosk_key);
|
||||
|
||||
let Some(target_layout_id) = target_layout_id else {
|
||||
warn!("display has no default layout");
|
||||
clear_warm_cameras();
|
||||
CURRENT_LAYOUT_ID.with(|c| c.set(None));
|
||||
show_logo(window);
|
||||
let displays = bundle.normalized_displays();
|
||||
if displays.is_empty() {
|
||||
warn!("bundle has no displays");
|
||||
show_logo(pairing_window);
|
||||
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
|
||||
/// to the display's default layout. If neither exists, show the logo.
|
||||
fn render_layout(window: &ApplicationWindow, layout_id: u32) {
|
||||
mark_activity();
|
||||
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<u32> {
|
||||
bd.default_layout_id
|
||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id))
|
||||
.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 bundle = b.borrow();
|
||||
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 {
|
||||
warn!("render_layout: no cached bundle yet");
|
||||
show_logo(window);
|
||||
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(|| {
|
||||
warn!("render_layout: layout {layout_id} not found, falling back to default");
|
||||
bundle.display.default_layout_id
|
||||
.and_then(|did| bundle.layouts.iter().find(|l| l.id == did))
|
||||
.or_else(|| bundle.layouts.iter().find(|l| l.is_default))
|
||||
warn!("render_layout: layout {layout_id} not on display {display_id}, falling back to default");
|
||||
bd.default_layout_id
|
||||
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
|
||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
|
||||
});
|
||||
|
||||
let Some(layout) = layout else {
|
||||
warn!("render_layout: no usable layout");
|
||||
clear_warm_cameras();
|
||||
CURRENT_LAYOUT_ID.with(|c| c.set(None));
|
||||
show_logo(window);
|
||||
warn!("render_layout: no usable layout on display {display_id}");
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
show_logo(&st.window);
|
||||
st.current_layout_id = None;
|
||||
}
|
||||
});
|
||||
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() {
|
||||
warn!("layout has no cells");
|
||||
clear_warm_cameras();
|
||||
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id)));
|
||||
show_logo(window);
|
||||
recompute_warm_cameras(&bundle);
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
show_logo(&st.window);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id)));
|
||||
|
||||
info!("rendering layout '{}' (id {}) with {}x{} grid, {} cells",
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Recompute warm-camera set across ALL displays (the union), then drop
|
||||
// pipelines no longer needed anywhere.
|
||||
recompute_warm_cameras(&bundle);
|
||||
|
||||
let server_url = server_url.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_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();
|
||||
|
||||
// Total grid area for the heuristic
|
||||
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 {
|
||||
if let Some(cam) = cam_map.get(cam_id) {
|
||||
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_hexpand(true);
|
||||
// Wrap in Overlay so we can stack a stream-role badge on top
|
||||
let overlay = gtk::Overlay::new();
|
||||
overlay.set_child(Some(&picture));
|
||||
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| {
|
||||
for (_, (pipe, _, _)) in w.borrow().iter() { pipeline::stop(pipe); }
|
||||
w.borrow_mut().clear();
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -568,7 +739,6 @@ fn ensure_warm(
|
|||
) -> Option<(gtk::gdk::Paintable, char)> {
|
||||
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| {
|
||||
w.borrow().get(&cam_id).map(|(p, paint, b)| (p.clone(), paint.clone(), *b))
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,10 +24,14 @@ import {
|
|||
LayoutEditPage,
|
||||
DisplaysPage,
|
||||
DisplayEditPage,
|
||||
SystemHealthPage,
|
||||
NoderedEmbedPage,
|
||||
renderCell,
|
||||
renderGrid,
|
||||
} from "../../web-templates/admin-pages.js";
|
||||
import { discover as onvifDiscover } from "../../shared/onvif.js";
|
||||
import { generateBundle } from "../../shared/bundle.js";
|
||||
import { captureSnapshot } from "../../shared/snapshot.js";
|
||||
|
||||
interface DiscoverAddStream {
|
||||
profile_name: string;
|
||||
|
|
@ -252,6 +256,44 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
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 --------------------------------------------------------------
|
||||
|
||||
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" } });
|
||||
});
|
||||
|
||||
// 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 ---------------------------------------------------------------
|
||||
|
||||
app.get("/admin/kiosks", (event) => {
|
||||
|
|
@ -1054,6 +1123,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
const displays = deps.repo.listDisplaysForKiosk(id);
|
||||
const firstDisplay = displays[0];
|
||||
const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : [];
|
||||
const gpioBindings = deps.repo.listGpioBindings(id);
|
||||
return htmlPage(KioskEditPage({
|
||||
user: user.username,
|
||||
kiosk,
|
||||
|
|
@ -1061,9 +1131,45 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
allLabels: deps.repo.listLabels(),
|
||||
displays,
|
||||
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) => {
|
||||
const id = Number(getRouterParam(event, "id"));
|
||||
const body = await readBody<Record<string, string>>(event);
|
||||
|
|
@ -1106,13 +1212,39 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
});
|
||||
|
||||
// ---- Layout switch ----------------------------------------------------
|
||||
app.post("/admin/kiosks/:id/layout/:layoutId", (event) => {
|
||||
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 });
|
||||
}
|
||||
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 -----------------------------------------------
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ import type {
|
|||
EntityType,
|
||||
EventLog,
|
||||
EventSourceType,
|
||||
GpioDirection,
|
||||
GpioEdge,
|
||||
GpioPull,
|
||||
Kiosk,
|
||||
KioskGpioBinding,
|
||||
KioskLabel,
|
||||
Label,
|
||||
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 {
|
||||
return {
|
||||
id: n(r["id"]),
|
||||
|
|
|
|||
|
|
@ -601,4 +601,18 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
|||
(db: DatabaseSync) => {
|
||||
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,
|
||||
EventLog,
|
||||
EventSourceType,
|
||||
GpioDirection,
|
||||
GpioEdge,
|
||||
GpioPull,
|
||||
Kiosk,
|
||||
KioskGpioBinding,
|
||||
KioskLabel,
|
||||
Label,
|
||||
LabelRole,
|
||||
|
|
@ -45,6 +49,7 @@ import {
|
|||
rowToEntity,
|
||||
rowToEventLog,
|
||||
rowToKiosk,
|
||||
rowToKioskGpioBinding,
|
||||
rowToLabel,
|
||||
rowToLayout,
|
||||
rowToLayoutCell,
|
||||
|
|
@ -1377,6 +1382,55 @@ export class Repository {
|
|||
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 {
|
||||
const sets: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
|
|
|
|||
|
|
@ -70,12 +70,37 @@ export interface BundleDisplay {
|
|||
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 {
|
||||
kiosk_id: number;
|
||||
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;
|
||||
/**
|
||||
* @deprecated Use `displays[N].layouts`. Mirrors `displays[0].layouts` for
|
||||
* older kiosk builds.
|
||||
*/
|
||||
layouts: BundleLayout[];
|
||||
/** All physical displays driven by this kiosk. New (multi-display) shape. */
|
||||
displays: BundleDisplayWithLayouts[];
|
||||
cameras: BundleCamera[];
|
||||
gpio_bindings: BundleGpioBinding[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
|
|
@ -88,76 +113,90 @@ export function generateBundle(
|
|||
const kiosk = repo.getKioskById(kioskId);
|
||||
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);
|
||||
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet
|
||||
let display = kioskDisplays[0] ?? null;
|
||||
if (!display && kiosk.display_id) {
|
||||
display = repo.getDisplayById(kiosk.display_id);
|
||||
const displays = kioskDisplays.length > 0
|
||||
? kioskDisplays
|
||||
: (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);
|
||||
const layoutIds = layouts.map((l) => l.id);
|
||||
|
||||
// Collect all cameras referenced by cells in these layouts
|
||||
const cameras = repo.camerasForLayoutIds(layoutIds);
|
||||
|
||||
const defaultLayoutId = display.default_layout_id;
|
||||
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
||||
const cells = repo.layoutCells(l.id);
|
||||
let gridCols = 1;
|
||||
let gridRows = 1;
|
||||
for (const c of cells) {
|
||||
const right = c.col + c.col_span;
|
||||
const bottom = c.row + c.row_span;
|
||||
if (right > gridCols) gridCols = right;
|
||||
if (bottom > gridRows) gridRows = bottom;
|
||||
}
|
||||
return {
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
grid_cols: gridCols,
|
||||
grid_rows: gridRows,
|
||||
priority: l.priority,
|
||||
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
||||
preload_camera_ids: l.preload_camera_ids,
|
||||
resets_idle_timer: l.resets_idle_timer,
|
||||
is_default: defaultLayoutId === l.id,
|
||||
cells: cells.map((c) => {
|
||||
// If the cell has an entity, prefer its current content so admin
|
||||
// edits to the entity propagate without forcing a cell-touch. The
|
||||
// bundle still ships the legacy camera_id/web_url/html_content shape
|
||||
// so the existing Rust kiosk consumes it unchanged.
|
||||
let contentType = c.content_type;
|
||||
let cameraId = c.camera_id;
|
||||
let webUrl = c.web_url;
|
||||
let htmlContent = c.html_content;
|
||||
if (c.entity_id != null) {
|
||||
const ent = repo.getEntityById(c.entity_id);
|
||||
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;
|
||||
function buildLayouts(displayId: number, defaultLayoutId: number | null): BundleLayout[] {
|
||||
const layouts = repo.layoutsForDisplayId(displayId);
|
||||
return layouts.map((l) => {
|
||||
const cells = repo.layoutCells(l.id);
|
||||
let gridCols = 1;
|
||||
let gridRows = 1;
|
||||
for (const c of cells) {
|
||||
const right = c.col + c.col_span;
|
||||
const bottom = c.row + c.row_span;
|
||||
if (right > gridCols) gridCols = right;
|
||||
if (bottom > gridRows) gridRows = bottom;
|
||||
}
|
||||
return {
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
grid_cols: gridCols,
|
||||
grid_rows: gridRows,
|
||||
priority: l.priority,
|
||||
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
||||
preload_camera_ids: l.preload_camera_ids,
|
||||
resets_idle_timer: l.resets_idle_timer,
|
||||
is_default: defaultLayoutId === l.id,
|
||||
cells: cells.map((c) => {
|
||||
// If the cell has an entity, prefer its current content so admin
|
||||
// edits to the entity propagate without forcing a cell-touch. The
|
||||
// bundle still ships the legacy camera_id/web_url/html_content shape
|
||||
// so the existing Rust kiosk consumes it unchanged.
|
||||
let contentType = c.content_type;
|
||||
let cameraId = c.camera_id;
|
||||
let webUrl = c.web_url;
|
||||
let htmlContent = c.html_content;
|
||||
if (c.entity_id != null) {
|
||||
const ent = repo.getEntityById(c.entity_id);
|
||||
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 {
|
||||
row: c.row,
|
||||
col: c.col,
|
||||
row_span: c.row_span,
|
||||
col_span: c.col_span,
|
||||
content_type: contentType,
|
||||
camera_id: cameraId,
|
||||
stream_selector: c.stream_selector,
|
||||
web_url: webUrl,
|
||||
html_content: htmlContent,
|
||||
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
||||
fit: c.fit,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
row: c.row,
|
||||
col: c.col,
|
||||
row_span: c.row_span,
|
||||
col_span: c.col_span,
|
||||
content_type: contentType,
|
||||
camera_id: cameraId,
|
||||
stream_selector: c.stream_selector,
|
||||
web_url: webUrl,
|
||||
html_content: htmlContent,
|
||||
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
||||
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 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 = {
|
||||
kiosk_id: kioskId,
|
||||
kiosk_name: kiosk.name,
|
||||
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,
|
||||
id: primary.id,
|
||||
name: primary.name,
|
||||
width_px: primary.width_px,
|
||||
height_px: primary.height_px,
|
||||
idle_timeout_seconds: primary.idle_timeout_seconds,
|
||||
sleep_timeout_seconds: primary.sleep_timeout_seconds,
|
||||
default_layout_id: primary.default_layout_id,
|
||||
},
|
||||
layouts: bundleLayouts,
|
||||
layouts: primary.layouts,
|
||||
displays: bundleDisplays,
|
||||
cameras: bundleCameras,
|
||||
gpio_bindings: gpioBindings,
|
||||
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>;
|
||||
}
|
||||
|
||||
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 {
|
||||
id: number;
|
||||
source_kiosk_id: number | null;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
Display,
|
||||
Entity,
|
||||
Kiosk,
|
||||
KioskGpioBinding,
|
||||
Label,
|
||||
Layout as LayoutType,
|
||||
LayoutCell,
|
||||
|
|
@ -691,6 +692,31 @@ export function EntityEditPage(props: EntityEditPageProps) {
|
|||
</select>
|
||||
</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" && (
|
||||
<div class="form-group">
|
||||
<label for="web_url">URL</label>
|
||||
|
|
@ -1165,6 +1191,7 @@ interface KioskEditProps {
|
|||
allLabels: Label[];
|
||||
displays?: Display[];
|
||||
switchableLayouts?: LayoutType[];
|
||||
gpioBindings?: KioskGpioBinding[];
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
|
@ -1284,6 +1311,93 @@ export function KioskEditPage(props: KioskEditProps) {
|
|||
)}
|
||||
</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">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
||||
{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>
|
||||
{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}`}>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
|
|
@ -2090,3 +2223,135 @@ function formatTime(iso: string): string {
|
|||
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>
|
||||
<nav class="sidebar-nav">
|
||||
<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/entities" label="Entities" icon="⚇" active={a === "entities"} />
|
||||
<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"} />
|
||||
<hr />
|
||||
<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>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue