mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
feat: layout switch push + idle/sleep timer + offline bundle cache
Layout switch push:
- POST /admin/kiosks/:id/layout/:layoutId — coordinator sends
{type:"layout-switch", layout_id} via WS
- Kiosk renders specified layout from cached bundle
- KioskEditPage adds Switch Layout dropdown + button
Idle/sleep timer:
- thread_local LAST_ACTIVITY + IS_ASLEEP + CURRENT_LAYOUT_ID
- mark_activity() on render/switch/wake; wakes if asleep
- glib timeout_add_local every 1s checks elapsed:
- elapsed >= idle_timeout AND not on default + resets_idle_timer
→ switch to default layout
- elapsed >= sleep_timeout AND !asleep → cec::standby()
- Display idle/sleep timeouts from bundle.display
Offline cache:
- server::save_bundle → ~/.betterframe-kiosk/bundle.json
- server::load_cached_bundle on offline boot
- fetch_bundle no longer panics; returns Option
- 30s retry loop until server reachable
- Reload-bundle gracefully handles fetch failures
This commit is contained in:
parent
1e09582379
commit
1c0fe02fcf
6 changed files with 319 additions and 35 deletions
|
|
@ -12,6 +12,8 @@ pub enum ServerMsg {
|
||||||
Wake,
|
Wake,
|
||||||
/// Some(0..=255) = manual PWM. None = restore auto.
|
/// Some(0..=255) = manual PWM. None = restore auto.
|
||||||
Fan(Option<u32>),
|
Fan(Option<u32>),
|
||||||
|
/// Switch to a specific layout by ID (must be present in current bundle).
|
||||||
|
SwitchLayout(u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
|
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,32 @@ fn state_dir() -> PathBuf {
|
||||||
|
|
||||||
fn key_file() -> PathBuf { state_dir().join("kiosk.key") }
|
fn key_file() -> PathBuf { state_dir().join("kiosk.key") }
|
||||||
fn server_file() -> PathBuf { state_dir().join("server.url") }
|
fn server_file() -> PathBuf { state_dir().join("server.url") }
|
||||||
|
fn bundle_cache_path() -> PathBuf { state_dir().join("bundle.json") }
|
||||||
|
|
||||||
|
/// Persist the latest bundle to disk for offline boot.
|
||||||
|
pub fn save_bundle(bundle: &KioskBundle) {
|
||||||
|
match serde_json::to_string(bundle) {
|
||||||
|
Ok(text) => {
|
||||||
|
if let Err(e) = fs::write(bundle_cache_path(), text) {
|
||||||
|
tracing::warn!("failed to save bundle cache: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("failed to serialize bundle: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a cached bundle from disk. Returns None if file missing or invalid.
|
||||||
|
pub fn load_cached_bundle() -> Option<KioskBundle> {
|
||||||
|
let path = bundle_cache_path();
|
||||||
|
let text = fs::read_to_string(&path).ok()?;
|
||||||
|
match serde_json::from_str::<KioskBundle>(&text) {
|
||||||
|
Ok(b) => Some(b),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("cached bundle invalid: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Discover the BetterFrame server.
|
/// Discover the BetterFrame server.
|
||||||
pub fn discover_server(override_url: Option<&str>) -> String {
|
pub fn discover_server(override_url: Option<&str>) -> String {
|
||||||
|
|
@ -132,20 +158,38 @@ pub fn poll_claim(server: &str, code: &str) -> (String, String) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch bundle from server.
|
/// Fetch bundle from server. Returns None on network/HTTP/parse failure.
|
||||||
pub fn fetch_bundle(server: &str, key: &str) -> KioskBundle {
|
/// On success, also writes the bundle to the on-disk cache.
|
||||||
|
pub fn fetch_bundle(server: &str, key: &str) -> Option<KioskBundle> {
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let resp = client
|
let resp = match client
|
||||||
.get(format!("{server}/api/kiosk/bundle"))
|
.get(format!("{server}/api/kiosk/bundle"))
|
||||||
.header("Authorization", format!("Bearer {key}"))
|
.header("Authorization", format!("Bearer {key}"))
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
.send()
|
.send()
|
||||||
.expect("bundle fetch failed");
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("bundle fetch failed: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
panic!("Bundle fetch returned {}", resp.status());
|
tracing::warn!("bundle fetch returned {}", resp.status());
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.json().expect("bad bundle JSON")
|
match resp.json::<KioskBundle>() {
|
||||||
|
Ok(b) => {
|
||||||
|
save_bundle(&b);
|
||||||
|
Some(b)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("bundle parse failed: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send heartbeat with display geometry + hwmon.
|
/// Send heartbeat with display geometry + hwmon.
|
||||||
|
|
|
||||||
241
kiosk/src/ui.rs
241
kiosk/src/ui.rs
|
|
@ -1,15 +1,8 @@
|
||||||
use std::cell::RefCell;
|
use std::cell::{Cell, RefCell};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture};
|
use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
@ -22,6 +15,32 @@ use crate::server;
|
||||||
use crate::ws_client;
|
use crate::ws_client;
|
||||||
use crate::ServerMsg;
|
use crate::ServerMsg;
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
/// Most recently rendered bundle. Used for layout-switch + idle revert.
|
||||||
|
static CURRENT_BUNDLE: RefCell<Option<KioskBundle>> = const { RefCell::new(None) };
|
||||||
|
|
||||||
|
/// 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) };
|
||||||
|
|
||||||
|
/// Has the idle-watchdog already been installed on the main loop?
|
||||||
|
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
||||||
|
}
|
||||||
|
|
||||||
const APP_ID: &str = "dev.betterframe.kiosk";
|
const APP_ID: &str = "dev.betterframe.kiosk";
|
||||||
const BETTERFRAME_LOGO_SVG: &str = include_str!("../../server/src/web-static/betterframe-logo.svg");
|
const BETTERFRAME_LOGO_SVG: &str = include_str!("../../server/src/web-static/betterframe-logo.svg");
|
||||||
const BETTERFRAME_MARK_SVG: &str = include_str!("../../server/src/web-static/betterframe-mark.svg");
|
const BETTERFRAME_MARK_SVG: &str = include_str!("../../server/src/web-static/betterframe-mark.svg");
|
||||||
|
|
@ -71,9 +90,27 @@ fn activate(app: &Application) {
|
||||||
key
|
key
|
||||||
};
|
};
|
||||||
|
|
||||||
let bundle = server::fetch_bundle(&server, &key);
|
// Try fetching live bundle. If server unreachable, fall back to
|
||||||
info!("bundle: {} cameras, {} layouts", bundle.cameras.len(), bundle.layouts.len());
|
// 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());
|
||||||
|
Some(b)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if let Some(cached) = server::load_cached_bundle() {
|
||||||
|
warn!("offline mode: rendering cached bundle");
|
||||||
|
Some(cached)
|
||||||
|
} else {
|
||||||
|
warn!("no bundle available (server unreachable, no cache)");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(bundle) = initial {
|
||||||
let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone()));
|
let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn WS client in a separate thread for live updates
|
// Spawn WS client in a separate thread for live updates
|
||||||
let server_ws = server.clone();
|
let server_ws = server.clone();
|
||||||
|
|
@ -87,22 +124,55 @@ fn activate(app: &Application) {
|
||||||
ws_client::run(&server_ws, &key_ws, ws_tx);
|
ws_client::run(&server_ws, &key_ws, ws_tx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Background retry thread: if we couldn't fetch a live bundle on boot,
|
||||||
|
// try again every 30s until we get one. Once fetched, send a render.
|
||||||
|
let retry_tx = tx.clone();
|
||||||
|
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) {
|
||||||
|
info!("offline-retry: fresh bundle fetched, rendering");
|
||||||
|
let _ = retry_tx.send(WorkerMsg::RenderBundle(
|
||||||
|
b,
|
||||||
|
retry_server.clone(),
|
||||||
|
retry_key.clone(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for WS messages and dispatch
|
// Listen for WS messages and dispatch
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for msg in ws_rx {
|
for msg in ws_rx {
|
||||||
match msg {
|
match msg {
|
||||||
ServerMsg::ReloadBundle => {
|
ServerMsg::ReloadBundle => {
|
||||||
info!("reloading bundle");
|
info!("reloading bundle");
|
||||||
let bundle = server::fetch_bundle(&server_for_reload, &key_for_reload);
|
match server::fetch_bundle(&server_for_reload, &key_for_reload) {
|
||||||
|
Some(bundle) => {
|
||||||
let _ = tx_for_reload.send(WorkerMsg::RenderBundle(
|
let _ = tx_for_reload.send(WorkerMsg::RenderBundle(
|
||||||
bundle,
|
bundle,
|
||||||
server_for_reload.clone(),
|
server_for_reload.clone(),
|
||||||
key_for_reload.clone(),
|
key_for_reload.clone(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
None => warn!("reload-bundle: fetch failed, keeping current render"),
|
||||||
|
}
|
||||||
|
}
|
||||||
ServerMsg::Standby => cec::standby(),
|
ServerMsg::Standby => cec::standby(),
|
||||||
ServerMsg::Wake => cec::wake(),
|
ServerMsg::Wake => {
|
||||||
|
let _ = tx_for_reload.send(WorkerMsg::Wake);
|
||||||
|
}
|
||||||
ServerMsg::Fan(pwm) => { hwmon::set_fan(pwm); }
|
ServerMsg::Fan(pwm) => { hwmon::set_fan(pwm); }
|
||||||
|
ServerMsg::SwitchLayout(id) => {
|
||||||
|
let _ = tx_for_reload.send(WorkerMsg::SwitchLayout(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -122,7 +192,18 @@ fn activate(app: &Application) {
|
||||||
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(&window_clone, &code),
|
||||||
WorkerMsg::RenderBundle(bundle, server, key) => render_bundle(&window_clone, bundle, &server, &key),
|
WorkerMsg::RenderBundle(bundle, server, key) => {
|
||||||
|
render_bundle(&window_clone, bundle, &server, &key);
|
||||||
|
install_idle_watchdog(&window_clone);
|
||||||
|
}
|
||||||
|
WorkerMsg::SwitchLayout(id) => {
|
||||||
|
render_layout(&window_clone, id);
|
||||||
|
}
|
||||||
|
WorkerMsg::Wake => {
|
||||||
|
cec::wake();
|
||||||
|
IS_ASLEEP.with(|c| c.set(false));
|
||||||
|
mark_activity();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gtk::glib::ControlFlow::Continue
|
gtk::glib::ControlFlow::Continue
|
||||||
|
|
@ -132,6 +213,73 @@ fn activate(app: &Application) {
|
||||||
enum WorkerMsg {
|
enum WorkerMsg {
|
||||||
ShowPairingCode(String),
|
ShowPairingCode(String),
|
||||||
RenderBundle(KioskBundle, String, String),
|
RenderBundle(KioskBundle, String, String),
|
||||||
|
SwitchLayout(u32),
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
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());
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query connected HDMI displays from sysfs. Returns (name, width, height).
|
/// Query connected HDMI displays from sysfs. Returns (name, width, height).
|
||||||
|
|
@ -184,16 +332,57 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &str, kiosk_key: &str) {
|
fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &str, kiosk_key: &str) {
|
||||||
let layout = match bundle.display.default_layout_id {
|
// Cache the bundle + auth so layout-switch and idle-revert can re-render
|
||||||
Some(default_layout_id) => bundle.layouts.iter()
|
// without needing a full reload.
|
||||||
.find(|l| l.id == default_layout_id)
|
CURRENT_BUNDLE.with(|b| *b.borrow_mut() = Some(bundle.clone()));
|
||||||
.or_else(|| bundle.layouts.iter().find(|l| l.is_default)),
|
CURRENT_AUTH.with(|a| *a.borrow_mut() = Some((server_url.to_string(), kiosk_key.to_string())));
|
||||||
None => None,
|
mark_activity();
|
||||||
};
|
|
||||||
|
|
||||||
let Some(layout) = layout else {
|
let target_layout_id = bundle.display.default_layout_id
|
||||||
|
.or_else(|| bundle.layouts.iter().find(|l| l.is_default).map(|l| l.id));
|
||||||
|
|
||||||
|
let Some(target_layout_id) = target_layout_id else {
|
||||||
warn!("display has no default layout");
|
warn!("display has no default layout");
|
||||||
clear_warm_cameras();
|
clear_warm_cameras();
|
||||||
|
CURRENT_LAYOUT_ID.with(|c| c.set(None));
|
||||||
|
show_logo(window);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
render_layout(window, target_layout_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
let auth = CURRENT_AUTH.with(|a| a.borrow().clone());
|
||||||
|
let (server_url, kiosk_key) = auth?;
|
||||||
|
Some((bundle, server_url, kiosk_key))
|
||||||
|
});
|
||||||
|
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)
|
||||||
|
.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))
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
show_logo(window);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -201,12 +390,15 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s
|
||||||
if layout.cells.is_empty() {
|
if layout.cells.is_empty() {
|
||||||
warn!("layout has no cells");
|
warn!("layout has no cells");
|
||||||
clear_warm_cameras();
|
clear_warm_cameras();
|
||||||
|
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id)));
|
||||||
show_logo(window);
|
show_logo(window);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("rendering layout '{}' with {}x{} grid, {} cells",
|
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id)));
|
||||||
layout.name, layout.grid_cols, layout.grid_rows, layout.cells.len());
|
|
||||||
|
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
|
// Compute which cameras are needed: cells with content_type=camera + preload_camera_ids
|
||||||
let mut needed: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
let mut needed: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||||
|
|
@ -229,6 +421,9 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let server_url = server_url.as_str();
|
||||||
|
let kiosk_key = kiosk_key.as_str();
|
||||||
|
|
||||||
let grid = Grid::new();
|
let grid = Grid::new();
|
||||||
grid.set_row_homogeneous(true);
|
grid.set_row_homogeneous(true);
|
||||||
grid.set_column_homogeneous(true);
|
grid.set_column_homogeneous(true);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,16 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
|
||||||
} else if text.contains("\"type\":\"wake\"") {
|
} else if text.contains("\"type\":\"wake\"") {
|
||||||
info!("ws: wake received");
|
info!("ws: wake received");
|
||||||
let _ = tx.send(ServerMsg::Wake);
|
let _ = tx.send(ServerMsg::Wake);
|
||||||
|
} else if text.contains("\"type\":\"layout-switch\"") {
|
||||||
|
info!("ws: layout-switch received: {text}");
|
||||||
|
let layout_id: Option<u32> = text.split("\"layout_id\":").nth(1)
|
||||||
|
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
|
||||||
|
.and_then(|s| s.parse::<u32>().ok());
|
||||||
|
if let Some(id) = layout_id {
|
||||||
|
let _ = tx.send(ServerMsg::SwitchLayout(id));
|
||||||
|
} else {
|
||||||
|
warn!("ws: layout-switch missing layout_id");
|
||||||
|
}
|
||||||
} else if text.contains("\"type\":\"fan\"") {
|
} else if text.contains("\"type\":\"fan\"") {
|
||||||
info!("ws: fan received: {text}");
|
info!("ws: fan received: {text}");
|
||||||
let pwm: Option<u32> = if text.contains("\"mode\":\"auto\"") {
|
let pwm: Option<u32> = if text.contains("\"mode\":\"auto\"") {
|
||||||
|
|
|
||||||
|
|
@ -1052,12 +1052,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
role: kl.role,
|
role: kl.role,
|
||||||
}));
|
}));
|
||||||
const displays = deps.repo.listDisplaysForKiosk(id);
|
const displays = deps.repo.listDisplaysForKiosk(id);
|
||||||
|
const firstDisplay = displays[0];
|
||||||
|
const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : [];
|
||||||
return htmlPage(KioskEditPage({
|
return htmlPage(KioskEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
kiosk,
|
kiosk,
|
||||||
labels: kioskLabels,
|
labels: kioskLabels,
|
||||||
allLabels: deps.repo.listLabels(),
|
allLabels: deps.repo.listLabels(),
|
||||||
displays,
|
displays,
|
||||||
|
switchableLayouts,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1102,6 +1105,16 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Layout switch ----------------------------------------------------
|
||||||
|
app.post("/admin/kiosks/:id/layout/:layoutId", (event) => {
|
||||||
|
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}` } });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- CEC power commands -----------------------------------------------
|
// ---- CEC power commands -----------------------------------------------
|
||||||
app.post("/admin/kiosks/:id/power/standby", (event) => {
|
app.post("/admin/kiosks/:id/power/standby", (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
|
|
||||||
|
|
@ -1164,6 +1164,7 @@ interface KioskEditProps {
|
||||||
labels: Array<{ label_id: number; name: string; role: string }>;
|
labels: Array<{ label_id: number; name: string; role: string }>;
|
||||||
allLabels: Label[];
|
allLabels: Label[];
|
||||||
displays?: Display[];
|
displays?: Display[];
|
||||||
|
switchableLayouts?: LayoutType[];
|
||||||
error?: string;
|
error?: string;
|
||||||
success?: string;
|
success?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1213,6 +1214,25 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{props.switchableLayouts && props.switchableLayouts.length > 0 ? (
|
||||||
|
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
||||||
|
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout</div>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action={`/admin/kiosks/${k.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.switchableLayouts.map((l) => (
|
||||||
|
<option value={String(l.id)}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-sm">Switch</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
||||||
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Hardware</div>
|
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Hardware</div>
|
||||||
<div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666; margin-bottom:0.75rem">
|
<div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666; margin-bottom:0.75rem">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue