mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 15:46:35 +00:00
refactor(kiosk): migrate all bundle IDs to String for UUIDv7 + ONVIF image proxy
- All bundle struct ID fields (kiosk_id, display_id, layout_id, camera_id, stream_id, gpio_id) now String with de_flexible_id deserializer accepting both JSON numbers and strings. - PoolKey, DisplayState hashmap, WorkerMsg, ServerMsg all use String IDs throughout. Zero u32 ID references remain. - ONVIF event image proxy: kiosk detects PictureUri in event data, downloads image from camera (basic/digest auth), base64 encodes, attaches to event payload before forwarding to server. - Add md5 crate for HTTP Digest auth on camera image fetch. - ws_client: flexible_id_from_value helper for WS message ID parsing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0c74e26e42
commit
908fd417c0
9 changed files with 467 additions and 129 deletions
138
kiosk/Cargo.lock
generated
138
kiosk/Cargo.lock
generated
|
|
@ -2,6 +2,41 @@
|
|||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
|
|
@ -138,6 +173,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
|||
name = "betterframe-kiosk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"axum",
|
||||
"base64",
|
||||
"dirs",
|
||||
|
|
@ -149,11 +185,14 @@ dependencies = [
|
|||
"gstreamer-video",
|
||||
"gtk4",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hostname",
|
||||
"md5",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
|
|
@ -161,6 +200,7 @@ dependencies = [
|
|||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"webkit6",
|
||||
]
|
||||
|
||||
|
|
@ -263,6 +303,16 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
|
@ -326,9 +376,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
|
|
@ -381,6 +441,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -743,6 +804,16 @@ dependencies = [
|
|||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.20.12"
|
||||
|
|
@ -1151,6 +1222,24 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.4.2"
|
||||
|
|
@ -1430,6 +1519,15 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
|
|
@ -1546,6 +1644,12 @@ version = "0.7.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
|
|
@ -1655,6 +1759,12 @@ version = "1.21.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.79"
|
||||
|
|
@ -1786,6 +1896,18 @@ version = "0.3.33"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
|
|
@ -2696,6 +2818,16 @@ version = "0.2.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
@ -2714,6 +2846,12 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ urlencoding = "2"
|
|||
|
||||
# ONVIF WSSE PasswordDigest auth
|
||||
sha1 = "0.10"
|
||||
# HTTP Digest auth for camera image fetch
|
||||
md5 = "0.7"
|
||||
|
||||
# Hardware-bound at-rest encryption of state files (kiosk_key + bundle cache
|
||||
# contain camera RTSP credentials in URL form). Keys derived via HKDF from
|
||||
|
|
|
|||
|
|
@ -1,5 +1,35 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
fn de_flexible_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let v = serde_json::Value::deserialize(deserializer)?;
|
||||
match v {
|
||||
serde_json::Value::String(s) => Ok(s),
|
||||
serde_json::Value::Number(n) => Ok(n.to_string()),
|
||||
_ => Err(serde::de::Error::custom("expected string or number for id")),
|
||||
}
|
||||
}
|
||||
|
||||
fn de_flexible_id_opt<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
|
||||
let v = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||
match v {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(serde_json::Value::String(s)) if s.is_empty() => Ok(None),
|
||||
Some(serde_json::Value::String(s)) => Ok(Some(s)),
|
||||
Some(serde_json::Value::Number(n)) => Ok(Some(n.to_string())),
|
||||
_ => Err(serde::de::Error::custom("expected string or number for id")),
|
||||
}
|
||||
}
|
||||
|
||||
fn de_flexible_id_vec<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
|
||||
let v = Vec::<serde_json::Value>::deserialize(deserializer)?;
|
||||
v.into_iter()
|
||||
.map(|item| match item {
|
||||
serde_json::Value::String(s) => Ok(s),
|
||||
serde_json::Value::Number(n) => Ok(n.to_string()),
|
||||
_ => Err(serde::de::Error::custom("expected string or number in id array")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct KioskBundle {
|
||||
|
|
@ -49,37 +79,43 @@ impl KioskBundle {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleDisplay {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
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, deserialize_with = "de_flexible_id_opt")]
|
||||
pub default_layout_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleDisplayWithLayouts {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
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, deserialize_with = "de_flexible_id_opt")]
|
||||
pub default_layout_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub layouts: Vec<BundleLayout>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleLayout {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub grid_cols: u32,
|
||||
pub grid_rows: u32,
|
||||
pub priority: String,
|
||||
pub cooling_timeout_seconds: Option<u32>,
|
||||
pub preload_camera_ids: Vec<u32>,
|
||||
#[serde(default, deserialize_with = "de_flexible_id_vec")]
|
||||
pub preload_camera_ids: Vec<String>,
|
||||
pub is_default: bool,
|
||||
pub resets_idle_timer: bool,
|
||||
pub cells: Vec<BundleCell>,
|
||||
|
|
@ -137,7 +173,8 @@ pub struct SmartUrlStep {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleCamera {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub cam_type: String,
|
||||
|
|
@ -162,7 +199,8 @@ pub struct BundleCamera {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleStream {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub name: String,
|
||||
pub rtsp_uri: String,
|
||||
|
|
@ -174,7 +212,8 @@ pub struct BundleStream {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BundleGpioBinding {
|
||||
pub id: u32,
|
||||
#[serde(deserialize_with = "de_flexible_id")]
|
||||
pub id: String,
|
||||
pub chip: String,
|
||||
pub pin: u32,
|
||||
pub direction: String,
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ async fn local_info_handler(
|
|||
|
||||
async fn local_layout_handler(
|
||||
State(state): State<LocalServerState>,
|
||||
Path(id): Path<u32>,
|
||||
Path(id): Path<String>,
|
||||
Query(auth): Query<LocalAuth>,
|
||||
) -> Response {
|
||||
if !constant_time_eq(&auth.key, &state.local_key) {
|
||||
|
|
@ -127,7 +127,7 @@ async fn local_layout_handler(
|
|||
};
|
||||
if let Err(e) = tx.send(WorkerMsg::SwitchLayout {
|
||||
display_id: None,
|
||||
layout_id: id,
|
||||
layout_id: id.clone(),
|
||||
}) {
|
||||
warn!("local-server: send SwitchLayout failed: {e}");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "send failed").into_response();
|
||||
|
|
@ -152,7 +152,7 @@ async fn local_layout_handler(
|
|||
/// "good enough" and isolated.
|
||||
async fn local_snapshot_handler(
|
||||
State(state): State<LocalServerState>,
|
||||
Path(camera_id): Path<u32>,
|
||||
Path(camera_id): Path<String>,
|
||||
Query(auth): Query<LocalAuth>,
|
||||
) -> Response {
|
||||
if !constant_time_eq(&auth.key, &state.local_key) {
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@ pub use ui::WorkerMsg;
|
|||
|
||||
pub enum ServerMsg {
|
||||
ReloadBundle,
|
||||
Standby(Option<u32>),
|
||||
Wake(Option<u32>),
|
||||
Standby(Option<String>),
|
||||
Wake(Option<String>),
|
||||
/// Some(0..=255) = manual PWM. None = restore auto.
|
||||
Fan(Option<u32>),
|
||||
/// Switch to a specific layout by ID, optionally scoped to one display.
|
||||
SwitchLayout {
|
||||
display_id: Option<u32>,
|
||||
layout_id: u32,
|
||||
display_id: Option<String>,
|
||||
layout_id: String,
|
||||
},
|
||||
/// Server-pushed "go check for a firmware update now".
|
||||
FirmwareCheck,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ use crate::bundle::BundleCamera;
|
|||
|
||||
/// Active subscriptions keyed by camera id. Worker threads check this
|
||||
/// to know when to stop (camera removed from bundle / bundle changed).
|
||||
static ACTIVE: Mutex<Option<HashMap<u32, ()>>> = Mutex::new(None);
|
||||
static ACTIVE: Mutex<Option<HashMap<String, ()>>> = Mutex::new(None);
|
||||
|
||||
/// Holds the current generation Arc. When start() replaces it, the old
|
||||
/// Arc drops → old threads' Weak::upgrade() returns None → they exit.
|
||||
|
|
@ -34,7 +34,7 @@ static ACTIVE: Mutex<Option<HashMap<u32, ()>>> = Mutex::new(None);
|
|||
static GENERATION: Mutex<Option<Arc<()>>> = Mutex::new(None);
|
||||
|
||||
/// Subscription status per camera — reported in heartbeat for admin visibility.
|
||||
static STATUS: Mutex<Option<HashMap<u32, SubStatus>>> = Mutex::new(None);
|
||||
static STATUS: Mutex<Option<HashMap<String, SubStatus>>> = Mutex::new(None);
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct SubStatus {
|
||||
|
|
@ -43,10 +43,10 @@ pub struct SubStatus {
|
|||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn set_status(cam_id: u32, state: &'static str, error: Option<String>) {
|
||||
fn set_status(cam_id: &str, state: &'static str, error: Option<String>) {
|
||||
let mut map = STATUS.lock().unwrap();
|
||||
let map = map.get_or_insert_with(HashMap::new);
|
||||
let entry = map.entry(cam_id).or_insert_with(|| SubStatus {
|
||||
let entry = map.entry(cam_id.to_string()).or_insert_with(|| SubStatus {
|
||||
state: "subscribing",
|
||||
last_event_at: None,
|
||||
error: None,
|
||||
|
|
@ -55,17 +55,17 @@ fn set_status(cam_id: u32, state: &'static str, error: Option<String>) {
|
|||
entry.error = error;
|
||||
}
|
||||
|
||||
fn mark_event_received(cam_id: u32) {
|
||||
fn mark_event_received(cam_id: &str) {
|
||||
let mut map = STATUS.lock().unwrap();
|
||||
if let Some(map) = map.as_mut() {
|
||||
if let Some(entry) = map.get_mut(&cam_id) {
|
||||
if let Some(entry) = map.get_mut(cam_id) {
|
||||
entry.last_event_at = Some(crate::os_update::current_os_version_public()); // reuse timestamp helper... actually just use epoch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current subscription statuses for all cameras. Used by heartbeat.
|
||||
pub fn get_statuses() -> HashMap<u32, SubStatus> {
|
||||
pub fn get_statuses() -> HashMap<String, SubStatus> {
|
||||
STATUS.lock().unwrap().clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ pub fn start(
|
|||
|
||||
// Signal old workers to stop.
|
||||
let mut active = ACTIVE.lock().unwrap();
|
||||
let new_map: HashMap<u32, ()> = onvif_cams.iter().map(|c| (c.id, ())).collect();
|
||||
let new_map: HashMap<String, ()> = onvif_cams.iter().map(|c| (c.id.clone(), ())).collect();
|
||||
*active = Some(new_map);
|
||||
drop(active);
|
||||
|
||||
|
|
@ -146,18 +146,18 @@ fn run_subscription(
|
|||
}
|
||||
|
||||
// 1. CreatePullPointSubscription
|
||||
set_status(cam.id, "subscribing", None);
|
||||
set_status(&cam.id, "subscribing", None);
|
||||
let sub = match create_pullpoint(&event_url, user, pass) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("onvif-events: cam {} CreatePullPoint failed: {e}", cam.id);
|
||||
set_status(cam.id, "failed", Some(e));
|
||||
set_status(&cam.id, "failed", Some(e));
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address);
|
||||
set_status(cam.id, "active", None);
|
||||
set_status(&cam.id, "active", None);
|
||||
|
||||
// 2. Poll loop
|
||||
let poll_interval = Duration::from_secs(3);
|
||||
|
|
@ -184,12 +184,12 @@ fn run_subscription(
|
|||
match pull_messages(&sub.address, user, pass) {
|
||||
Ok(events) => {
|
||||
for evt in events {
|
||||
forward_event(server, kiosk_key, cam.id, &evt);
|
||||
forward_event(server, kiosk_key, &cam.id, &evt, user, pass);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("onvif-events: cam {} pull failed: {e}", cam.id);
|
||||
set_status(cam.id, "failed", Some(e));
|
||||
set_status(&cam.id, "failed", Some(e));
|
||||
std::thread::sleep(Duration::from_secs(15));
|
||||
break; // resubscribe after backoff
|
||||
}
|
||||
|
|
@ -542,12 +542,23 @@ fn extract_attr_inline(xml: &str, attr: &str) -> Option<String> {
|
|||
|
||||
// ---- Forward to BF server --------------------------------------------------
|
||||
|
||||
fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent) {
|
||||
let payload = serde_json::json!({
|
||||
fn forward_event(
|
||||
server: &str,
|
||||
kiosk_key: &str,
|
||||
camera_id: &str,
|
||||
evt: &OnvifEvent,
|
||||
cam_user: &str,
|
||||
cam_pass: &str,
|
||||
) {
|
||||
let attachments = fetch_image_attachments(&evt.data, cam_user, cam_pass);
|
||||
let mut payload = serde_json::json!({
|
||||
"source": evt.source,
|
||||
"data": evt.data,
|
||||
"timestamp": evt.timestamp,
|
||||
});
|
||||
if !attachments.is_empty() {
|
||||
payload["attachments"] = serde_json::json!(attachments);
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"topic": evt.topic,
|
||||
"source_type": "onvif",
|
||||
|
|
@ -559,10 +570,147 @@ fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent
|
|||
.post(format!("{server}/api/kiosk/event"))
|
||||
.header("Authorization", format!("Bearer {kiosk_key}"))
|
||||
.json(&body)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send();
|
||||
}
|
||||
|
||||
fn fetch_image_attachments(
|
||||
data: &HashMap<String, String>,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
) -> HashMap<String, String> {
|
||||
let mut attachments = HashMap::new();
|
||||
let image_exts = [".jpg", ".jpeg", ".png", ".bmp"];
|
||||
for (key, value) in data {
|
||||
if !value.starts_with("http://") && !value.starts_with("https://") {
|
||||
continue;
|
||||
}
|
||||
let lower = value.to_lowercase();
|
||||
if !image_exts.iter().any(|ext| lower.contains(ext)) {
|
||||
continue;
|
||||
}
|
||||
match fetch_image_b64(value, user, pass) {
|
||||
Some(b64) => {
|
||||
let mime = if lower.contains(".png") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
};
|
||||
attachments.insert(key.clone(), format!("data:{mime};base64,{b64}"));
|
||||
}
|
||||
None => {
|
||||
warn!("onvif-events: failed to fetch image for {key}: {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments
|
||||
}
|
||||
|
||||
fn fetch_image_b64(url: &str, user: &str, pass: &str) -> Option<String> {
|
||||
use base64::Engine;
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get(url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
// Retry with digest auth if basic auth returned 401.
|
||||
if status.as_u16() == 401 {
|
||||
return fetch_image_b64_digest(url, user, pass);
|
||||
}
|
||||
warn!("onvif-events: image fetch HTTP {status} for {url}");
|
||||
return None;
|
||||
}
|
||||
let bytes = resp.bytes().ok()?;
|
||||
if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
fn fetch_image_b64_digest(url: &str, user: &str, pass: &str) -> Option<String> {
|
||||
use base64::Engine;
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.ok()?;
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("Authorization", digest_auth_header(url, user, pass)?)
|
||||
.send()
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let bytes = resp.bytes().ok()?;
|
||||
if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
fn digest_auth_header(url: &str, user: &str, pass: &str) -> Option<String> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client.get(url).timeout(Duration::from_secs(3)).send().ok()?;
|
||||
if resp.status().as_u16() != 401 {
|
||||
return None;
|
||||
}
|
||||
let www_auth = resp.headers().get("www-authenticate")?.to_str().ok()?;
|
||||
if !www_auth.to_lowercase().starts_with("digest ") {
|
||||
return None;
|
||||
}
|
||||
let realm = extract_digest_field(www_auth, "realm")?;
|
||||
let nonce = extract_digest_field(www_auth, "nonce")?;
|
||||
let qop = extract_digest_field(www_auth, "qop").unwrap_or_default();
|
||||
let uri = url::Url::parse(url).ok().map(|u| u.path().to_string()).unwrap_or_else(|| "/".to_string());
|
||||
let ha1 = md5_hex(&format!("{user}:{realm}:{pass}"));
|
||||
let ha2 = md5_hex(&format!("GET:{uri}"));
|
||||
let cnonce = format!("{:08x}", rand::random::<u32>());
|
||||
let nc = "00000001";
|
||||
let response = if qop.contains("auth") {
|
||||
md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"))
|
||||
} else {
|
||||
md5_hex(&format!("{ha1}:{nonce}:{ha2}"))
|
||||
};
|
||||
if qop.contains("auth") {
|
||||
Some(format!(
|
||||
r#"Digest username="{user}", realm="{realm}", nonce="{nonce}", uri="{uri}", response="{response}", qop=auth, nc={nc}, cnonce="{cnonce}""#
|
||||
))
|
||||
} else {
|
||||
Some(format!(
|
||||
r#"Digest username="{user}", realm="{realm}", nonce="{nonce}", uri="{uri}", response="{response}""#
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_digest_field(header: &str, field: &str) -> Option<String> {
|
||||
let pat = format!("{field}=\"");
|
||||
let start = header.find(&pat)? + pat.len();
|
||||
let end = header[start..].find('"')?;
|
||||
Some(header[start..start + end].to_string())
|
||||
}
|
||||
|
||||
fn md5_hex(input: &str) -> String {
|
||||
use md5::{Digest, Md5};
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(input.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
hex_lower_bytes(&result)
|
||||
}
|
||||
|
||||
fn hex_lower_bytes(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push(HEX[(b >> 4) as usize] as char);
|
||||
s.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ---- Cluster key decryption ------------------------------------------------
|
||||
|
||||
/// Decrypt a value encrypted with secrets.encryptForCluster on the server.
|
||||
|
|
|
|||
|
|
@ -428,8 +428,8 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option<KioskBundle> {
|
|||
pub fn report_layout_change(
|
||||
server: &str,
|
||||
key: &str,
|
||||
display_id: u32,
|
||||
layout_id: u32,
|
||||
display_id: &str,
|
||||
layout_id: &str,
|
||||
layout_name: &str,
|
||||
) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
|
|
|||
179
kiosk/src/ui.rs
179
kiosk/src/ui.rs
|
|
@ -30,7 +30,7 @@ use crate::ws_client;
|
|||
/// even though the GTK main loop is shared.
|
||||
struct DisplayState {
|
||||
window: ApplicationWindow,
|
||||
current_layout_id: Option<u32>,
|
||||
current_layout_id: Option<String>,
|
||||
last_activity: Instant,
|
||||
is_asleep: bool,
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ struct PipelineEntry {
|
|||
/// per (main, sub, other) stream — each with independent warmth state. When a
|
||||
/// cell switches M↔S we promote the new variant to Warm/Hot but leave the old
|
||||
/// one to cool down naturally so a quick swap back is instant.
|
||||
type PoolKey = (u32, char);
|
||||
type PoolKey = (String, char);
|
||||
|
||||
/// WebView pool entry. Same Hot/Warm/Cooling/Cold lifecycle as cameras —
|
||||
/// switching to a layout that doesn't reference a previously-loaded URL/HTML
|
||||
|
|
@ -95,7 +95,7 @@ thread_local! {
|
|||
static CURRENT_SYNC_LABEL: RefCell<String> = RefCell::new(String::from("unknown"));
|
||||
|
||||
/// Per-display state, keyed by bundle display id.
|
||||
static DISPLAYS: RefCell<HashMap<u32, DisplayState>> = RefCell::new(HashMap::new());
|
||||
static DISPLAYS: RefCell<HashMap<String, 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) };
|
||||
|
|
@ -171,7 +171,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) => {
|
||||
crate::axiom::set_kiosk_id(b.kiosk_id.to_string());
|
||||
crate::axiom::set_kiosk_id(b.kiosk_id.clone());
|
||||
info!(
|
||||
"bundle: {} cameras, {} display(s)",
|
||||
b.cameras.len(),
|
||||
|
|
@ -329,14 +329,14 @@ fn activate(app: &Application) {
|
|||
display_id,
|
||||
layout_id,
|
||||
} => {
|
||||
if let Some(display_id) = display_id {
|
||||
render_layout(display_id, layout_id);
|
||||
if let Some(display_id) = &display_id {
|
||||
render_layout(display_id, &layout_id);
|
||||
} else {
|
||||
switch_layout_anywhere(layout_id);
|
||||
switch_layout_anywhere(&layout_id);
|
||||
}
|
||||
}
|
||||
WorkerMsg::Standby(display_id) => standby_display(display_id),
|
||||
WorkerMsg::Wake(display_id) => wake_display(display_id),
|
||||
WorkerMsg::Standby(display_id) => standby_display(display_id.as_deref()),
|
||||
WorkerMsg::Wake(display_id) => wake_display(display_id.as_deref()),
|
||||
WorkerMsg::ShowTerminalCode(code) => show_terminal_code_overlay(&code),
|
||||
WorkerMsg::DismissTerminalCode => dismiss_terminal_code_overlay(),
|
||||
WorkerMsg::UpdateProgress(progress) => show_update_banner(progress),
|
||||
|
|
@ -350,11 +350,11 @@ pub enum WorkerMsg {
|
|||
ShowPairingCode(String),
|
||||
RenderBundle(KioskBundle, String, String),
|
||||
SwitchLayout {
|
||||
display_id: Option<u32>,
|
||||
layout_id: u32,
|
||||
display_id: Option<String>,
|
||||
layout_id: String,
|
||||
},
|
||||
Standby(Option<u32>),
|
||||
Wake(Option<u32>),
|
||||
Standby(Option<String>),
|
||||
Wake(Option<String>),
|
||||
ShowTerminalCode(String),
|
||||
DismissTerminalCode,
|
||||
/// Update progress banner — shown as overlay on all displays.
|
||||
|
|
@ -362,7 +362,7 @@ pub enum WorkerMsg {
|
|||
UpdateProgress(Option<(String, u8)>),
|
||||
}
|
||||
|
||||
fn output_name_for_display(display_id: u32) -> Option<String> {
|
||||
fn output_name_for_display(display_id: &str) -> Option<String> {
|
||||
CURRENT_BUNDLE.with(|b| {
|
||||
b.borrow()
|
||||
.as_ref()
|
||||
|
|
@ -376,7 +376,7 @@ fn output_name_for_display(display_id: u32) -> Option<String> {
|
|||
})
|
||||
}
|
||||
|
||||
fn standby_display(display_id: Option<u32>) {
|
||||
fn standby_display(display_id: Option<&str>) {
|
||||
if let Some(display_id) = display_id {
|
||||
if let Some(output_name) = output_name_for_display(display_id) {
|
||||
cec::standby_output(&output_name);
|
||||
|
|
@ -384,7 +384,7 @@ fn standby_display(display_id: Option<u32>) {
|
|||
cec::standby();
|
||||
}
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
st.is_asleep = true;
|
||||
}
|
||||
});
|
||||
|
|
@ -398,7 +398,7 @@ fn standby_display(display_id: Option<u32>) {
|
|||
}
|
||||
}
|
||||
|
||||
fn wake_display(display_id: Option<u32>) {
|
||||
fn wake_display(display_id: Option<&str>) {
|
||||
if let Some(display_id) = display_id {
|
||||
if let Some(output_name) = output_name_for_display(display_id) {
|
||||
cec::wake_output(&output_name);
|
||||
|
|
@ -406,7 +406,7 @@ fn wake_display(display_id: Option<u32>) {
|
|||
cec::wake();
|
||||
}
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
st.is_asleep = false;
|
||||
st.last_activity = Instant::now();
|
||||
}
|
||||
|
|
@ -423,9 +423,9 @@ fn wake_display(display_id: Option<u32>) {
|
|||
}
|
||||
|
||||
/// Reset activity timer for one display. If asleep, wake it.
|
||||
fn mark_activity(display_id: u32) {
|
||||
fn mark_activity(display_id: &str) {
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
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}");
|
||||
|
|
@ -451,11 +451,12 @@ fn send_heartbeat_now(server_url: &str, kiosk_key: &str) -> bool {
|
|||
.map(|(index, (name, width_px, height_px))| {
|
||||
let bundle_id = bundle_displays
|
||||
.get(index)
|
||||
.map(|d| d.id)
|
||||
.or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id));
|
||||
.map(|d| d.id.clone())
|
||||
.or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id.clone()));
|
||||
let power_state = bundle_id
|
||||
.as_deref()
|
||||
.and_then(|id| {
|
||||
DISPLAYS.with(|ds| ds.borrow().get(&id).map(|st| st.is_asleep))
|
||||
DISPLAYS.with(|ds| ds.borrow().get(id).map(|st| st.is_asleep))
|
||||
})
|
||||
.map(|is_asleep| if is_asleep { "standby" } else { "awake" })
|
||||
.unwrap_or("unknown")
|
||||
|
|
@ -614,8 +615,8 @@ fn install_idle_watchdog() {
|
|||
|
||||
// Snapshot per-display timing decisions so we can act outside the borrow.
|
||||
struct Action {
|
||||
display_id: u32,
|
||||
revert_to: Option<u32>,
|
||||
display_id: String,
|
||||
revert_to: Option<String>,
|
||||
sleep: bool,
|
||||
}
|
||||
let mut actions: Vec<Action> = Vec::new();
|
||||
|
|
@ -632,10 +633,10 @@ fn install_idle_watchdog() {
|
|||
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 default_id = d.default_layout_id.clone();
|
||||
|
||||
let mut act = Action {
|
||||
display_id: *display_id,
|
||||
display_id: display_id.clone(),
|
||||
revert_to: None,
|
||||
sleep: false,
|
||||
};
|
||||
|
|
@ -643,12 +644,13 @@ fn install_idle_watchdog() {
|
|||
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))
|
||||
.as_ref()
|
||||
.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 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);
|
||||
act.revert_to = Some(def_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -667,7 +669,7 @@ fn install_idle_watchdog() {
|
|||
"idle timeout reached → reverting display {} to default",
|
||||
a.display_id
|
||||
);
|
||||
render_layout(a.display_id, layout_id);
|
||||
render_layout(&a.display_id, &layout_id);
|
||||
}
|
||||
if a.sleep {
|
||||
info!(
|
||||
|
|
@ -790,11 +792,11 @@ fn render_bundle(
|
|||
|
||||
// Collect camera IDs actually referenced in layout cells.
|
||||
let displays = bundle.normalized_displays();
|
||||
let layout_cam_ids: std::collections::HashSet<u32> = displays
|
||||
let layout_cam_ids: std::collections::HashSet<String> = displays
|
||||
.iter()
|
||||
.flat_map(|d| d.layouts.iter())
|
||||
.flat_map(|l| l.cells.iter())
|
||||
.filter_map(|c| c.camera_id)
|
||||
.filter_map(|c| c.camera_id.clone())
|
||||
.collect();
|
||||
|
||||
// Only subscribe to ONVIF events for cameras in layouts (not all bundle cameras).
|
||||
|
|
@ -825,12 +827,12 @@ fn render_bundle(
|
|||
.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| {
|
||||
let keep_ids: std::collections::HashSet<&str> = displays.iter().map(|d| d.id.as_str()).collect();
|
||||
let to_remove: Vec<String> = DISPLAYS.with(|ds| {
|
||||
ds.borrow()
|
||||
.keys()
|
||||
.filter(|id| !keep_ids.contains(id))
|
||||
.copied()
|
||||
.filter(|id| !keep_ids.contains(id.as_str()))
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
for id in to_remove {
|
||||
|
|
@ -845,7 +847,7 @@ fn render_bundle(
|
|||
// displays is correct once the loop finishes.
|
||||
|
||||
// Build/reuse window per bundle display, then render its initial layout.
|
||||
let mut new_state: HashMap<u32, DisplayState> = HashMap::new();
|
||||
let mut new_state: HashMap<String, DisplayState> = HashMap::new();
|
||||
for (i, bd) in displays.iter().enumerate() {
|
||||
let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id));
|
||||
let (window, was_asleep) = match existing {
|
||||
|
|
@ -872,7 +874,7 @@ fn render_bundle(
|
|||
}
|
||||
};
|
||||
new_state.insert(
|
||||
bd.id,
|
||||
bd.id.clone(),
|
||||
DisplayState {
|
||||
window,
|
||||
current_layout_id: None,
|
||||
|
|
@ -892,7 +894,7 @@ fn render_bundle(
|
|||
for bd in &displays {
|
||||
let target = pick_initial_layout(bd);
|
||||
if let Some(layout_id) = target {
|
||||
render_layout(bd.id, layout_id);
|
||||
render_layout(&bd.id, &layout_id);
|
||||
} else {
|
||||
warn!("display {} has no default layout", bd.id);
|
||||
DISPLAYS.with(|ds| {
|
||||
|
|
@ -905,19 +907,19 @@ fn render_bundle(
|
|||
}
|
||||
}
|
||||
|
||||
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))
|
||||
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<String> {
|
||||
bd.default_layout_id.clone()
|
||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id.clone()))
|
||||
.or_else(|| bd.layouts.first().map(|l| l.id.clone()))
|
||||
}
|
||||
|
||||
/// Find which display owns a given layout_id and render it there.
|
||||
fn switch_layout_anywhere(layout_id: u32) {
|
||||
fn switch_layout_anywhere(layout_id: &str) {
|
||||
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);
|
||||
render_layout(&bd.id, layout_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -925,7 +927,7 @@ fn switch_layout_anywhere(layout_id: u32) {
|
|||
}
|
||||
|
||||
/// Render a specific layout id on a specific display.
|
||||
fn render_layout(display_id: u32, layout_id: u32) {
|
||||
fn render_layout(display_id: &str, layout_id: &str) {
|
||||
mark_activity(display_id);
|
||||
|
||||
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
|
||||
|
|
@ -950,7 +952,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
warn!(
|
||||
"render_layout: layout {layout_id} not on display {display_id}, falling back to default"
|
||||
);
|
||||
bd.default_layout_id
|
||||
bd.default_layout_id.as_deref()
|
||||
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
|
||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
|
||||
});
|
||||
|
|
@ -958,7 +960,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
let Some(layout) = layout else {
|
||||
warn!("render_layout: no usable layout on display {display_id}");
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
show_empty_display_reference(&st.window, &bundle, bd);
|
||||
st.current_layout_id = None;
|
||||
}
|
||||
|
|
@ -971,10 +973,10 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
let previous_layout_id = DISPLAYS.with(|ds| {
|
||||
let prev = ds
|
||||
.borrow()
|
||||
.get(&display_id)
|
||||
.and_then(|s| s.current_layout_id);
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
st.current_layout_id = Some(layout.id);
|
||||
.get(display_id)
|
||||
.and_then(|s| s.current_layout_id.clone());
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
st.current_layout_id = Some(layout.id.clone());
|
||||
}
|
||||
prev
|
||||
});
|
||||
|
|
@ -992,17 +994,18 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
// Notify the server when the active layout actually changes so Node-RED
|
||||
// sees idle reverts + any other kiosk-initiated switch. Skip when the
|
||||
// layout id is unchanged (re-render of the same layout).
|
||||
if previous_layout_id != Some(layout.id) {
|
||||
if previous_layout_id.as_deref() != Some(layout.id.as_str()) {
|
||||
let layout_name = layout.name.clone();
|
||||
let layout_id_for_report = layout.id;
|
||||
let layout_id_for_report = layout.id.clone();
|
||||
let display_id_for_report = display_id.to_string();
|
||||
let server = server_url.clone();
|
||||
let key = kiosk_key.clone();
|
||||
std::thread::spawn(move || {
|
||||
server::report_layout_change(
|
||||
&server,
|
||||
&key,
|
||||
display_id,
|
||||
layout_id_for_report,
|
||||
&display_id_for_report,
|
||||
&layout_id_for_report,
|
||||
&layout_name,
|
||||
);
|
||||
});
|
||||
|
|
@ -1012,7 +1015,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
warn!("layout has no cells");
|
||||
recompute_global_state();
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
show_logo(&st.window);
|
||||
}
|
||||
});
|
||||
|
|
@ -1033,21 +1036,21 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
grid.set_vexpand(true);
|
||||
grid.set_hexpand(true);
|
||||
|
||||
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
|
||||
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
||||
let cam_map: HashMap<&str, &crate::bundle::BundleCamera> =
|
||||
bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect();
|
||||
|
||||
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
|
||||
|
||||
// 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);
|
||||
if let Some(cam) = cam_map.get(cam_id.as_str()) {
|
||||
ensure_warm(cam_id, cam, None, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
for cell in &layout.cells {
|
||||
let cell_key: Option<String> = match cell.content_type.as_str() {
|
||||
"camera" => cell.camera_id.map(|id| {
|
||||
"camera" => cell.camera_id.as_ref().map(|id| {
|
||||
format!(
|
||||
"cam:{id}:{}",
|
||||
cell.stream_selector.as_deref().unwrap_or("auto")
|
||||
|
|
@ -1063,8 +1066,8 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
};
|
||||
let widget: gtk::Widget = match cell.content_type.as_str() {
|
||||
"camera" => {
|
||||
if let Some(cam_id) = cell.camera_id {
|
||||
if let Some(cam) = cam_map.get(&cam_id) {
|
||||
if let Some(cam_id) = cell.camera_id.as_ref() {
|
||||
if let Some(cam) = cam_map.get(cam_id.as_str()) {
|
||||
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
||||
if let Some((paintable, badge)) =
|
||||
ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area)
|
||||
|
|
@ -1149,7 +1152,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
}
|
||||
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||
animate_layout_swap(&st.window, &grid);
|
||||
}
|
||||
});
|
||||
|
|
@ -1401,14 +1404,14 @@ fn recompute_global_state() {
|
|||
let mut hot_set: std::collections::HashSet<PoolKey> = std::collections::HashSet::new();
|
||||
let mut max_cooling_secs: u32 = 0;
|
||||
|
||||
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
|
||||
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
||||
let cam_map: HashMap<&str, &crate::bundle::BundleCamera> =
|
||||
bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect();
|
||||
|
||||
// Snapshot per-display active layout id outside any borrow of WARM_CAMERAS.
|
||||
let active: Vec<(u32, Option<u32>)> = DISPLAYS.with(|ds| {
|
||||
let active: Vec<(String, Option<String>)> = DISPLAYS.with(|ds| {
|
||||
ds.borrow()
|
||||
.iter()
|
||||
.map(|(id, st)| (*id, st.current_layout_id))
|
||||
.map(|(id, st)| (id.clone(), st.current_layout_id.clone()))
|
||||
.collect()
|
||||
});
|
||||
|
||||
|
|
@ -1417,7 +1420,7 @@ fn recompute_global_state() {
|
|||
// missing or no streams).
|
||||
fn cell_keys(
|
||||
layout: &crate::bundle::BundleLayout,
|
||||
cam_map: &HashMap<u32, &crate::bundle::BundleCamera>,
|
||||
cam_map: &HashMap<&str, &crate::bundle::BundleCamera>,
|
||||
out: &mut std::collections::HashSet<PoolKey>,
|
||||
) {
|
||||
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
|
||||
|
|
@ -1425,24 +1428,24 @@ fn recompute_global_state() {
|
|||
if cell.content_type != "camera" {
|
||||
continue;
|
||||
}
|
||||
let Some(cam_id) = cell.camera_id else {
|
||||
let Some(cam_id) = cell.camera_id.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(cam) = cam_map.get(&cam_id) else {
|
||||
let Some(cam) = cam_map.get(cam_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
||||
if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) {
|
||||
out.insert((cam_id, badge));
|
||||
out.insert((cam_id.clone(), badge));
|
||||
}
|
||||
}
|
||||
// Preload cameras have no cell context — let pick_stream choose
|
||||
// (typically sub). Different layouts that actually render them will
|
||||
// promote whichever badge they end up using.
|
||||
for cam_id in &layout.preload_camera_ids {
|
||||
if let Some(cam) = cam_map.get(cam_id) {
|
||||
if let Some(cam) = cam_map.get(cam_id.as_str()) {
|
||||
if let Some((_, badge)) = cam.pick_stream(None, 0.0) {
|
||||
out.insert((*cam_id, badge));
|
||||
out.insert((cam_id.clone(), badge));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1452,7 +1455,7 @@ fn recompute_global_state() {
|
|||
let active_id = active
|
||||
.iter()
|
||||
.find(|(id, _)| *id == bd.id)
|
||||
.and_then(|(_, l)| *l);
|
||||
.and_then(|(_, l)| l.clone());
|
||||
if let Some(cur_id) = active_id {
|
||||
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
||||
cell_keys(layout, &cam_map, &mut warm_set);
|
||||
|
|
@ -1475,7 +1478,7 @@ fn recompute_global_state() {
|
|||
let active_id = active
|
||||
.iter()
|
||||
.find(|(id, _)| *id == bd.id)
|
||||
.and_then(|(_, l)| *l);
|
||||
.and_then(|(_, l)| l.clone());
|
||||
if let Some(cur_id) = active_id {
|
||||
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
||||
web_keys_for_layout(layout, &mut warm_webs);
|
||||
|
|
@ -1525,7 +1528,7 @@ fn recompute_pool_states(
|
|||
continue;
|
||||
}
|
||||
if max_cooling_secs == 0 {
|
||||
to_remove.push(*key);
|
||||
to_remove.push(key.clone());
|
||||
to_stop.push(entry.pipeline.clone());
|
||||
} else {
|
||||
entry.state = WarmthState::Cooling;
|
||||
|
|
@ -1551,15 +1554,15 @@ fn recompute_pool_states(
|
|||
/// Remove warm camera entries for cameras no longer in the bundle.
|
||||
/// Immediately stops pipelines — no cooling period.
|
||||
fn purge_removed_cameras(bundle_cameras: &[crate::bundle::BundleCamera]) {
|
||||
let valid_ids: std::collections::HashSet<u32> = bundle_cameras.iter().map(|c| c.id).collect();
|
||||
let valid_ids: std::collections::HashSet<&str> = bundle_cameras.iter().map(|c| c.id.as_str()).collect();
|
||||
let mut to_remove: Vec<PoolKey> = Vec::new();
|
||||
let mut to_stop: Vec<gstreamer::Pipeline> = Vec::new();
|
||||
|
||||
WARM_CAMERAS.with(|w| {
|
||||
let mut warm = w.borrow_mut();
|
||||
for (key, entry) in warm.iter() {
|
||||
if !valid_ids.contains(&key.0) {
|
||||
to_remove.push(*key);
|
||||
if !valid_ids.contains(key.0.as_str()) {
|
||||
to_remove.push(key.clone());
|
||||
to_stop.push(entry.pipeline.clone());
|
||||
}
|
||||
}
|
||||
|
|
@ -1588,7 +1591,7 @@ fn expire_cooling_pipelines() {
|
|||
.filter(|(_, e)| {
|
||||
e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
for k in keys {
|
||||
if let Some(e) = warm.remove(&k) {
|
||||
|
|
@ -1771,13 +1774,13 @@ fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool {
|
|||
/// that sibling entry alone — recompute_pool_states will demote it to Cooling
|
||||
/// so it can be reused if the cell flips back before the cooldown elapses.
|
||||
fn ensure_warm(
|
||||
cam_id: u32,
|
||||
cam_id: &str,
|
||||
cam: &crate::bundle::BundleCamera,
|
||||
selector: Option<&str>,
|
||||
area_fraction: f32,
|
||||
) -> Option<(gtk::gdk::Paintable, char)> {
|
||||
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
|
||||
let key: PoolKey = (cam_id, desired_badge);
|
||||
let key: PoolKey = (cam_id.to_string(), desired_badge);
|
||||
|
||||
let cached = WARM_CAMERAS.with(|w| {
|
||||
w.borrow()
|
||||
|
|
@ -2179,7 +2182,7 @@ fn add_css(widget: &impl IsA<gtk::Widget>, css: &str) {
|
|||
|
||||
thread_local! {
|
||||
static TERMINAL_CODE_WIDGET: RefCell<Option<gtk::Widget>> = const { RefCell::new(None) };
|
||||
static TERMINAL_CODE_SAVED_CHILD: RefCell<Option<(u32, gtk::Widget)>> = const { RefCell::new(None) };
|
||||
static TERMINAL_CODE_SAVED_CHILD: RefCell<Option<(String, gtk::Widget)>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn show_terminal_code_overlay(code: &str) {
|
||||
|
|
@ -2189,7 +2192,7 @@ fn show_terminal_code_overlay(code: &str) {
|
|||
// Instead, replace the first display window's child with the code
|
||||
// overlay and restore it when dismissed.
|
||||
let display_id = DISPLAYS.with(|ds| {
|
||||
ds.borrow().keys().next().copied()
|
||||
ds.borrow().keys().next().cloned()
|
||||
});
|
||||
let Some(display_id) = display_id else { return };
|
||||
|
||||
|
|
@ -2201,7 +2204,7 @@ fn show_terminal_code_overlay(code: &str) {
|
|||
// Save current child for restore.
|
||||
let old_child = win.child();
|
||||
if let Some(ref c) = old_child {
|
||||
TERMINAL_CODE_SAVED_CHILD.with(|s| *s.borrow_mut() = Some((display_id, c.clone())));
|
||||
TERMINAL_CODE_SAVED_CHILD.with(|s| *s.borrow_mut() = Some((display_id.clone(), c.clone())));
|
||||
}
|
||||
|
||||
// Match the pairing screen layout but with red warning theme.
|
||||
|
|
|
|||
|
|
@ -151,23 +151,21 @@ async fn handle_message(
|
|||
} else if text.contains("\"type\":\"standby\"") {
|
||||
let display_id = serde_json::from_str::<serde_json::Value>(text)
|
||||
.ok()
|
||||
.and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32));
|
||||
.and_then(|m| m.get("display_id").and_then(flexible_id_from_value));
|
||||
let _ = tx.send(ServerMsg::Standby(display_id));
|
||||
} else if text.contains("\"type\":\"wake\"") {
|
||||
let display_id = serde_json::from_str::<serde_json::Value>(text)
|
||||
.ok()
|
||||
.and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32));
|
||||
.and_then(|m| m.get("display_id").and_then(flexible_id_from_value));
|
||||
let _ = tx.send(ServerMsg::Wake(display_id));
|
||||
} else if text.contains("\"type\":\"layout-switch\"") {
|
||||
let msg = serde_json::from_str::<serde_json::Value>(text).ok();
|
||||
let layout_id = msg.as_ref()
|
||||
.and_then(|m| m.get("layout_id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
.and_then(flexible_id_from_value);
|
||||
let display_id = msg.as_ref()
|
||||
.and_then(|m| m.get("display_id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
.and_then(flexible_id_from_value);
|
||||
if let Some(layout_id) = layout_id {
|
||||
let _ = tx.send(ServerMsg::SwitchLayout { display_id, layout_id });
|
||||
}
|
||||
|
|
@ -366,6 +364,16 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Extract an ID from a JSON value that may be a string or a number.
|
||||
/// Mirrors the flexible ID deserialization in bundle.rs.
|
||||
fn flexible_id_from_value(v: &serde_json::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ws_url(http_url: &str, token: &str) -> String {
|
||||
let base = if let Some(rest) = http_url.strip_prefix("https://") {
|
||||
format!("wss://{}", rest.split('/').next().unwrap_or(rest))
|
||||
|
|
|
|||
Loading…
Reference in a new issue