mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +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.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
|
|
@ -138,6 +173,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
name = "betterframe-kiosk"
|
name = "betterframe-kiosk"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
@ -149,11 +185,14 @@ dependencies = [
|
||||||
"gstreamer-video",
|
"gstreamer-video",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hkdf",
|
||||||
"hostname",
|
"hostname",
|
||||||
|
"md5",
|
||||||
"rand",
|
"rand",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
|
@ -161,6 +200,7 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
|
"urlencoding",
|
||||||
"webkit6",
|
"webkit6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -263,6 +303,16 @@ dependencies = [
|
||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
@ -326,9 +376,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "curve25519-dalek"
|
name = "curve25519-dalek"
|
||||||
version = "4.1.3"
|
version = "4.1.3"
|
||||||
|
|
@ -381,6 +441,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -743,6 +804,16 @@ dependencies = [
|
||||||
"wasip3",
|
"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]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.20.12"
|
version = "0.20.12"
|
||||||
|
|
@ -1151,6 +1222,24 @@ version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
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]]
|
[[package]]
|
||||||
name = "hostname"
|
name = "hostname"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
|
|
@ -1430,6 +1519,15 @@ dependencies = [
|
||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
|
|
@ -1546,6 +1644,12 @@ version = "0.7.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md5"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
|
|
@ -1655,6 +1759,12 @@ version = "1.21.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.79"
|
version = "0.10.79"
|
||||||
|
|
@ -1786,6 +1896,18 @@ version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -2696,6 +2818,16 @@ version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
@ -2714,6 +2846,12 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf-8"
|
name = "utf-8"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ urlencoding = "2"
|
||||||
|
|
||||||
# ONVIF WSSE PasswordDigest auth
|
# ONVIF WSSE PasswordDigest auth
|
||||||
sha1 = "0.10"
|
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
|
# Hardware-bound at-rest encryption of state files (kiosk_key + bundle cache
|
||||||
# contain camera RTSP credentials in URL form). Keys derived via HKDF from
|
# 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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct KioskBundle {
|
pub struct KioskBundle {
|
||||||
|
|
@ -49,37 +79,43 @@ impl KioskBundle {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleDisplay {
|
pub struct BundleDisplay {
|
||||||
pub id: u32,
|
#[serde(deserialize_with = "de_flexible_id")]
|
||||||
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub width_px: u32,
|
pub width_px: u32,
|
||||||
pub height_px: u32,
|
pub height_px: u32,
|
||||||
pub idle_timeout_seconds: u32,
|
pub idle_timeout_seconds: u32,
|
||||||
pub sleep_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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleDisplayWithLayouts {
|
pub struct BundleDisplayWithLayouts {
|
||||||
pub id: u32,
|
#[serde(deserialize_with = "de_flexible_id")]
|
||||||
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub width_px: u32,
|
pub width_px: u32,
|
||||||
pub height_px: u32,
|
pub height_px: u32,
|
||||||
pub idle_timeout_seconds: u32,
|
pub idle_timeout_seconds: u32,
|
||||||
pub sleep_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)]
|
#[serde(default)]
|
||||||
pub layouts: Vec<BundleLayout>,
|
pub layouts: Vec<BundleLayout>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleLayout {
|
pub struct BundleLayout {
|
||||||
pub id: u32,
|
#[serde(deserialize_with = "de_flexible_id")]
|
||||||
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub grid_cols: u32,
|
pub grid_cols: u32,
|
||||||
pub grid_rows: u32,
|
pub grid_rows: u32,
|
||||||
pub priority: String,
|
pub priority: String,
|
||||||
pub cooling_timeout_seconds: Option<u32>,
|
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 is_default: bool,
|
||||||
pub resets_idle_timer: bool,
|
pub resets_idle_timer: bool,
|
||||||
pub cells: Vec<BundleCell>,
|
pub cells: Vec<BundleCell>,
|
||||||
|
|
@ -137,7 +173,8 @@ pub struct SmartUrlStep {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleCamera {
|
pub struct BundleCamera {
|
||||||
pub id: u32,
|
#[serde(deserialize_with = "de_flexible_id")]
|
||||||
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub cam_type: String,
|
pub cam_type: String,
|
||||||
|
|
@ -162,7 +199,8 @@ pub struct BundleCamera {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleStream {
|
pub struct BundleStream {
|
||||||
pub id: u32,
|
#[serde(deserialize_with = "de_flexible_id")]
|
||||||
|
pub id: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub rtsp_uri: String,
|
pub rtsp_uri: String,
|
||||||
|
|
@ -174,7 +212,8 @@ pub struct BundleStream {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleGpioBinding {
|
pub struct BundleGpioBinding {
|
||||||
pub id: u32,
|
#[serde(deserialize_with = "de_flexible_id")]
|
||||||
|
pub id: String,
|
||||||
pub chip: String,
|
pub chip: String,
|
||||||
pub pin: u32,
|
pub pin: u32,
|
||||||
pub direction: String,
|
pub direction: String,
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ async fn local_info_handler(
|
||||||
|
|
||||||
async fn local_layout_handler(
|
async fn local_layout_handler(
|
||||||
State(state): State<LocalServerState>,
|
State(state): State<LocalServerState>,
|
||||||
Path(id): Path<u32>,
|
Path(id): Path<String>,
|
||||||
Query(auth): Query<LocalAuth>,
|
Query(auth): Query<LocalAuth>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if !constant_time_eq(&auth.key, &state.local_key) {
|
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 {
|
if let Err(e) = tx.send(WorkerMsg::SwitchLayout {
|
||||||
display_id: None,
|
display_id: None,
|
||||||
layout_id: id,
|
layout_id: id.clone(),
|
||||||
}) {
|
}) {
|
||||||
warn!("local-server: send SwitchLayout failed: {e}");
|
warn!("local-server: send SwitchLayout failed: {e}");
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "send failed").into_response();
|
return (StatusCode::INTERNAL_SERVER_ERROR, "send failed").into_response();
|
||||||
|
|
@ -152,7 +152,7 @@ async fn local_layout_handler(
|
||||||
/// "good enough" and isolated.
|
/// "good enough" and isolated.
|
||||||
async fn local_snapshot_handler(
|
async fn local_snapshot_handler(
|
||||||
State(state): State<LocalServerState>,
|
State(state): State<LocalServerState>,
|
||||||
Path(camera_id): Path<u32>,
|
Path(camera_id): Path<String>,
|
||||||
Query(auth): Query<LocalAuth>,
|
Query(auth): Query<LocalAuth>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if !constant_time_eq(&auth.key, &state.local_key) {
|
if !constant_time_eq(&auth.key, &state.local_key) {
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,14 @@ pub use ui::WorkerMsg;
|
||||||
|
|
||||||
pub enum ServerMsg {
|
pub enum ServerMsg {
|
||||||
ReloadBundle,
|
ReloadBundle,
|
||||||
Standby(Option<u32>),
|
Standby(Option<String>),
|
||||||
Wake(Option<u32>),
|
Wake(Option<String>),
|
||||||
/// 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, optionally scoped to one display.
|
/// Switch to a specific layout by ID, optionally scoped to one display.
|
||||||
SwitchLayout {
|
SwitchLayout {
|
||||||
display_id: Option<u32>,
|
display_id: Option<String>,
|
||||||
layout_id: u32,
|
layout_id: String,
|
||||||
},
|
},
|
||||||
/// Server-pushed "go check for a firmware update now".
|
/// Server-pushed "go check for a firmware update now".
|
||||||
FirmwareCheck,
|
FirmwareCheck,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ use crate::bundle::BundleCamera;
|
||||||
|
|
||||||
/// Active subscriptions keyed by camera id. Worker threads check this
|
/// Active subscriptions keyed by camera id. Worker threads check this
|
||||||
/// to know when to stop (camera removed from bundle / bundle changed).
|
/// 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
|
/// Holds the current generation Arc. When start() replaces it, the old
|
||||||
/// Arc drops → old threads' Weak::upgrade() returns None → they exit.
|
/// 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);
|
static GENERATION: Mutex<Option<Arc<()>>> = Mutex::new(None);
|
||||||
|
|
||||||
/// Subscription status per camera — reported in heartbeat for admin visibility.
|
/// 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)]
|
#[derive(Clone, serde::Serialize)]
|
||||||
pub struct SubStatus {
|
pub struct SubStatus {
|
||||||
|
|
@ -43,10 +43,10 @@ pub struct SubStatus {
|
||||||
pub error: Option<String>,
|
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 mut map = STATUS.lock().unwrap();
|
||||||
let map = map.get_or_insert_with(HashMap::new);
|
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",
|
state: "subscribing",
|
||||||
last_event_at: None,
|
last_event_at: None,
|
||||||
error: None,
|
error: None,
|
||||||
|
|
@ -55,17 +55,17 @@ fn set_status(cam_id: u32, state: &'static str, error: Option<String>) {
|
||||||
entry.error = error;
|
entry.error = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_event_received(cam_id: u32) {
|
fn mark_event_received(cam_id: &str) {
|
||||||
let mut map = STATUS.lock().unwrap();
|
let mut map = STATUS.lock().unwrap();
|
||||||
if let Some(map) = map.as_mut() {
|
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
|
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.
|
/// 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()
|
STATUS.lock().unwrap().clone().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ pub fn start(
|
||||||
|
|
||||||
// Signal old workers to stop.
|
// Signal old workers to stop.
|
||||||
let mut active = ACTIVE.lock().unwrap();
|
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);
|
*active = Some(new_map);
|
||||||
drop(active);
|
drop(active);
|
||||||
|
|
||||||
|
|
@ -146,18 +146,18 @@ fn run_subscription(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. CreatePullPointSubscription
|
// 1. CreatePullPointSubscription
|
||||||
set_status(cam.id, "subscribing", None);
|
set_status(&cam.id, "subscribing", None);
|
||||||
let sub = match create_pullpoint(&event_url, user, pass) {
|
let sub = match create_pullpoint(&event_url, user, pass) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("onvif-events: cam {} CreatePullPoint failed: {e}", cam.id);
|
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));
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address);
|
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
|
// 2. Poll loop
|
||||||
let poll_interval = Duration::from_secs(3);
|
let poll_interval = Duration::from_secs(3);
|
||||||
|
|
@ -184,12 +184,12 @@ fn run_subscription(
|
||||||
match pull_messages(&sub.address, user, pass) {
|
match pull_messages(&sub.address, user, pass) {
|
||||||
Ok(events) => {
|
Ok(events) => {
|
||||||
for evt in 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) => {
|
Err(e) => {
|
||||||
warn!("onvif-events: cam {} pull failed: {e}", cam.id);
|
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));
|
std::thread::sleep(Duration::from_secs(15));
|
||||||
break; // resubscribe after backoff
|
break; // resubscribe after backoff
|
||||||
}
|
}
|
||||||
|
|
@ -542,12 +542,23 @@ fn extract_attr_inline(xml: &str, attr: &str) -> Option<String> {
|
||||||
|
|
||||||
// ---- Forward to BF server --------------------------------------------------
|
// ---- Forward to BF server --------------------------------------------------
|
||||||
|
|
||||||
fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent) {
|
fn forward_event(
|
||||||
let payload = serde_json::json!({
|
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,
|
"source": evt.source,
|
||||||
"data": evt.data,
|
"data": evt.data,
|
||||||
"timestamp": evt.timestamp,
|
"timestamp": evt.timestamp,
|
||||||
});
|
});
|
||||||
|
if !attachments.is_empty() {
|
||||||
|
payload["attachments"] = serde_json::json!(attachments);
|
||||||
|
}
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"topic": evt.topic,
|
"topic": evt.topic,
|
||||||
"source_type": "onvif",
|
"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"))
|
.post(format!("{server}/api/kiosk/event"))
|
||||||
.header("Authorization", format!("Bearer {kiosk_key}"))
|
.header("Authorization", format!("Bearer {kiosk_key}"))
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.timeout(Duration::from_secs(5))
|
.timeout(Duration::from_secs(10))
|
||||||
.send();
|
.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 ------------------------------------------------
|
// ---- Cluster key decryption ------------------------------------------------
|
||||||
|
|
||||||
/// Decrypt a value encrypted with secrets.encryptForCluster on the server.
|
/// 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(
|
pub fn report_layout_change(
|
||||||
server: &str,
|
server: &str,
|
||||||
key: &str,
|
key: &str,
|
||||||
display_id: u32,
|
display_id: &str,
|
||||||
layout_id: u32,
|
layout_id: &str,
|
||||||
layout_name: &str,
|
layout_name: &str,
|
||||||
) {
|
) {
|
||||||
let client = reqwest::blocking::Client::new();
|
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.
|
/// even though the GTK main loop is shared.
|
||||||
struct DisplayState {
|
struct DisplayState {
|
||||||
window: ApplicationWindow,
|
window: ApplicationWindow,
|
||||||
current_layout_id: Option<u32>,
|
current_layout_id: Option<String>,
|
||||||
last_activity: Instant,
|
last_activity: Instant,
|
||||||
is_asleep: bool,
|
is_asleep: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +58,7 @@ struct PipelineEntry {
|
||||||
/// per (main, sub, other) stream — each with independent warmth state. When a
|
/// 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
|
/// 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.
|
/// 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 —
|
/// WebView pool entry. Same Hot/Warm/Cooling/Cold lifecycle as cameras —
|
||||||
/// switching to a layout that doesn't reference a previously-loaded URL/HTML
|
/// 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"));
|
static CURRENT_SYNC_LABEL: RefCell<String> = RefCell::new(String::from("unknown"));
|
||||||
|
|
||||||
/// Per-display state, keyed by bundle display id.
|
/// 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?
|
/// Has the idle-watchdog already been installed on the main loop?
|
||||||
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
||||||
|
|
@ -171,7 +171,7 @@ fn activate(app: &Application) {
|
||||||
// cached on-disk bundle and keep retrying every 30s in the background.
|
// cached on-disk bundle and keep retrying every 30s in the background.
|
||||||
let initial = match server::fetch_bundle(&server, &key) {
|
let initial = match server::fetch_bundle(&server, &key) {
|
||||||
Some(b) => {
|
Some(b) => {
|
||||||
crate::axiom::set_kiosk_id(b.kiosk_id.to_string());
|
crate::axiom::set_kiosk_id(b.kiosk_id.clone());
|
||||||
info!(
|
info!(
|
||||||
"bundle: {} cameras, {} display(s)",
|
"bundle: {} cameras, {} display(s)",
|
||||||
b.cameras.len(),
|
b.cameras.len(),
|
||||||
|
|
@ -329,14 +329,14 @@ fn activate(app: &Application) {
|
||||||
display_id,
|
display_id,
|
||||||
layout_id,
|
layout_id,
|
||||||
} => {
|
} => {
|
||||||
if let Some(display_id) = display_id {
|
if let Some(display_id) = &display_id {
|
||||||
render_layout(display_id, layout_id);
|
render_layout(display_id, &layout_id);
|
||||||
} else {
|
} else {
|
||||||
switch_layout_anywhere(layout_id);
|
switch_layout_anywhere(&layout_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WorkerMsg::Standby(display_id) => standby_display(display_id),
|
WorkerMsg::Standby(display_id) => standby_display(display_id.as_deref()),
|
||||||
WorkerMsg::Wake(display_id) => wake_display(display_id),
|
WorkerMsg::Wake(display_id) => wake_display(display_id.as_deref()),
|
||||||
WorkerMsg::ShowTerminalCode(code) => show_terminal_code_overlay(&code),
|
WorkerMsg::ShowTerminalCode(code) => show_terminal_code_overlay(&code),
|
||||||
WorkerMsg::DismissTerminalCode => dismiss_terminal_code_overlay(),
|
WorkerMsg::DismissTerminalCode => dismiss_terminal_code_overlay(),
|
||||||
WorkerMsg::UpdateProgress(progress) => show_update_banner(progress),
|
WorkerMsg::UpdateProgress(progress) => show_update_banner(progress),
|
||||||
|
|
@ -350,11 +350,11 @@ pub enum WorkerMsg {
|
||||||
ShowPairingCode(String),
|
ShowPairingCode(String),
|
||||||
RenderBundle(KioskBundle, String, String),
|
RenderBundle(KioskBundle, String, String),
|
||||||
SwitchLayout {
|
SwitchLayout {
|
||||||
display_id: Option<u32>,
|
display_id: Option<String>,
|
||||||
layout_id: u32,
|
layout_id: String,
|
||||||
},
|
},
|
||||||
Standby(Option<u32>),
|
Standby(Option<String>),
|
||||||
Wake(Option<u32>),
|
Wake(Option<String>),
|
||||||
ShowTerminalCode(String),
|
ShowTerminalCode(String),
|
||||||
DismissTerminalCode,
|
DismissTerminalCode,
|
||||||
/// Update progress banner — shown as overlay on all displays.
|
/// Update progress banner — shown as overlay on all displays.
|
||||||
|
|
@ -362,7 +362,7 @@ pub enum WorkerMsg {
|
||||||
UpdateProgress(Option<(String, u8)>),
|
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| {
|
CURRENT_BUNDLE.with(|b| {
|
||||||
b.borrow()
|
b.borrow()
|
||||||
.as_ref()
|
.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(display_id) = display_id {
|
||||||
if let Some(output_name) = output_name_for_display(display_id) {
|
if let Some(output_name) = output_name_for_display(display_id) {
|
||||||
cec::standby_output(&output_name);
|
cec::standby_output(&output_name);
|
||||||
|
|
@ -384,7 +384,7 @@ fn standby_display(display_id: Option<u32>) {
|
||||||
cec::standby();
|
cec::standby();
|
||||||
}
|
}
|
||||||
DISPLAYS.with(|ds| {
|
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;
|
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(display_id) = display_id {
|
||||||
if let Some(output_name) = output_name_for_display(display_id) {
|
if let Some(output_name) = output_name_for_display(display_id) {
|
||||||
cec::wake_output(&output_name);
|
cec::wake_output(&output_name);
|
||||||
|
|
@ -406,7 +406,7 @@ fn wake_display(display_id: Option<u32>) {
|
||||||
cec::wake();
|
cec::wake();
|
||||||
}
|
}
|
||||||
DISPLAYS.with(|ds| {
|
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.is_asleep = false;
|
||||||
st.last_activity = Instant::now();
|
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.
|
/// 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| {
|
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();
|
st.last_activity = Instant::now();
|
||||||
if st.is_asleep {
|
if st.is_asleep {
|
||||||
info!("activity while asleep → waking display {display_id}");
|
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))| {
|
.map(|(index, (name, width_px, height_px))| {
|
||||||
let bundle_id = bundle_displays
|
let bundle_id = bundle_displays
|
||||||
.get(index)
|
.get(index)
|
||||||
.map(|d| d.id)
|
.map(|d| d.id.clone())
|
||||||
.or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id));
|
.or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id.clone()));
|
||||||
let power_state = bundle_id
|
let power_state = bundle_id
|
||||||
|
.as_deref()
|
||||||
.and_then(|id| {
|
.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" })
|
.map(|is_asleep| if is_asleep { "standby" } else { "awake" })
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
|
|
@ -614,8 +615,8 @@ fn install_idle_watchdog() {
|
||||||
|
|
||||||
// Snapshot per-display timing decisions so we can act outside the borrow.
|
// Snapshot per-display timing decisions so we can act outside the borrow.
|
||||||
struct Action {
|
struct Action {
|
||||||
display_id: u32,
|
display_id: String,
|
||||||
revert_to: Option<u32>,
|
revert_to: Option<String>,
|
||||||
sleep: bool,
|
sleep: bool,
|
||||||
}
|
}
|
||||||
let mut actions: Vec<Action> = Vec::new();
|
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 idle_to = d.idle_timeout_seconds as u64;
|
||||||
let sleep_to = d.sleep_timeout_seconds as u64;
|
let sleep_to = d.sleep_timeout_seconds as u64;
|
||||||
let elapsed = st.last_activity.elapsed();
|
let elapsed = st.last_activity.elapsed();
|
||||||
let default_id = d.default_layout_id;
|
let default_id = d.default_layout_id.clone();
|
||||||
|
|
||||||
let mut act = Action {
|
let mut act = Action {
|
||||||
display_id: *display_id,
|
display_id: display_id.clone(),
|
||||||
revert_to: None,
|
revert_to: None,
|
||||||
sleep: false,
|
sleep: false,
|
||||||
};
|
};
|
||||||
|
|
@ -643,12 +644,13 @@ fn install_idle_watchdog() {
|
||||||
if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
|
if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
|
||||||
let cur_resets_idle = st
|
let cur_resets_idle = st
|
||||||
.current_layout_id
|
.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)
|
.map(|l| l.resets_idle_timer)
|
||||||
.unwrap_or(false);
|
.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 {
|
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",
|
"idle timeout reached → reverting display {} to default",
|
||||||
a.display_id
|
a.display_id
|
||||||
);
|
);
|
||||||
render_layout(a.display_id, layout_id);
|
render_layout(&a.display_id, &layout_id);
|
||||||
}
|
}
|
||||||
if a.sleep {
|
if a.sleep {
|
||||||
info!(
|
info!(
|
||||||
|
|
@ -790,11 +792,11 @@ fn render_bundle(
|
||||||
|
|
||||||
// Collect camera IDs actually referenced in layout cells.
|
// Collect camera IDs actually referenced in layout cells.
|
||||||
let displays = bundle.normalized_displays();
|
let displays = bundle.normalized_displays();
|
||||||
let layout_cam_ids: std::collections::HashSet<u32> = displays
|
let layout_cam_ids: std::collections::HashSet<String> = displays
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|d| d.layouts.iter())
|
.flat_map(|d| d.layouts.iter())
|
||||||
.flat_map(|l| l.cells.iter())
|
.flat_map(|l| l.cells.iter())
|
||||||
.filter_map(|c| c.camera_id)
|
.filter_map(|c| c.camera_id.clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Only subscribe to ONVIF events for cameras in layouts (not all bundle cameras).
|
// Only subscribe to ONVIF events for cameras in layouts (not all bundle cameras).
|
||||||
|
|
@ -825,12 +827,12 @@ fn render_bundle(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Tear down any previous per-display windows we no longer need.
|
// Tear down any previous per-display windows we no longer need.
|
||||||
let keep_ids: std::collections::HashSet<u32> = displays.iter().map(|d| d.id).collect();
|
let keep_ids: std::collections::HashSet<&str> = displays.iter().map(|d| d.id.as_str()).collect();
|
||||||
let to_remove: Vec<u32> = DISPLAYS.with(|ds| {
|
let to_remove: Vec<String> = DISPLAYS.with(|ds| {
|
||||||
ds.borrow()
|
ds.borrow()
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|id| !keep_ids.contains(id))
|
.filter(|id| !keep_ids.contains(id.as_str()))
|
||||||
.copied()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
for id in to_remove {
|
for id in to_remove {
|
||||||
|
|
@ -845,7 +847,7 @@ fn render_bundle(
|
||||||
// displays is correct once the loop finishes.
|
// displays is correct once the loop finishes.
|
||||||
|
|
||||||
// Build/reuse window per bundle display, then render its initial layout.
|
// 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() {
|
for (i, bd) in displays.iter().enumerate() {
|
||||||
let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id));
|
let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id));
|
||||||
let (window, was_asleep) = match existing {
|
let (window, was_asleep) = match existing {
|
||||||
|
|
@ -872,7 +874,7 @@ fn render_bundle(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
new_state.insert(
|
new_state.insert(
|
||||||
bd.id,
|
bd.id.clone(),
|
||||||
DisplayState {
|
DisplayState {
|
||||||
window,
|
window,
|
||||||
current_layout_id: None,
|
current_layout_id: None,
|
||||||
|
|
@ -892,7 +894,7 @@ fn render_bundle(
|
||||||
for bd in &displays {
|
for bd in &displays {
|
||||||
let target = pick_initial_layout(bd);
|
let target = pick_initial_layout(bd);
|
||||||
if let Some(layout_id) = target {
|
if let Some(layout_id) = target {
|
||||||
render_layout(bd.id, layout_id);
|
render_layout(&bd.id, &layout_id);
|
||||||
} else {
|
} else {
|
||||||
warn!("display {} has no default layout", bd.id);
|
warn!("display {} has no default layout", bd.id);
|
||||||
DISPLAYS.with(|ds| {
|
DISPLAYS.with(|ds| {
|
||||||
|
|
@ -905,19 +907,19 @@ fn render_bundle(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<u32> {
|
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<String> {
|
||||||
bd.default_layout_id
|
bd.default_layout_id.clone()
|
||||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id))
|
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id.clone()))
|
||||||
.or_else(|| bd.layouts.first().map(|l| l.id))
|
.or_else(|| bd.layouts.first().map(|l| l.id.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find which display owns a given layout_id and render it there.
|
/// 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 bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
|
||||||
let Some(bundle) = bundle else { return };
|
let Some(bundle) = bundle else { return };
|
||||||
for bd in bundle.normalized_displays() {
|
for bd in bundle.normalized_displays() {
|
||||||
if bd.layouts.iter().any(|l| l.id == layout_id) {
|
if bd.layouts.iter().any(|l| l.id == layout_id) {
|
||||||
render_layout(bd.id, layout_id);
|
render_layout(&bd.id, layout_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -925,7 +927,7 @@ fn switch_layout_anywhere(layout_id: u32) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a specific layout id on a specific display.
|
/// 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);
|
mark_activity(display_id);
|
||||||
|
|
||||||
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
|
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
|
||||||
|
|
@ -950,7 +952,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
||||||
warn!(
|
warn!(
|
||||||
"render_layout: layout {layout_id} not on display {display_id}, falling back to default"
|
"render_layout: layout {layout_id} not on display {display_id}, falling back to default"
|
||||||
);
|
);
|
||||||
bd.default_layout_id
|
bd.default_layout_id.as_deref()
|
||||||
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
|
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
|
||||||
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
|
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
|
||||||
});
|
});
|
||||||
|
|
@ -958,7 +960,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
||||||
let Some(layout) = layout else {
|
let Some(layout) = layout else {
|
||||||
warn!("render_layout: no usable layout on display {display_id}");
|
warn!("render_layout: no usable layout on display {display_id}");
|
||||||
DISPLAYS.with(|ds| {
|
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);
|
show_empty_display_reference(&st.window, &bundle, bd);
|
||||||
st.current_layout_id = None;
|
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 previous_layout_id = DISPLAYS.with(|ds| {
|
||||||
let prev = ds
|
let prev = ds
|
||||||
.borrow()
|
.borrow()
|
||||||
.get(&display_id)
|
.get(display_id)
|
||||||
.and_then(|s| s.current_layout_id);
|
.and_then(|s| s.current_layout_id.clone());
|
||||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
if let Some(st) = ds.borrow_mut().get_mut(display_id) {
|
||||||
st.current_layout_id = Some(layout.id);
|
st.current_layout_id = Some(layout.id.clone());
|
||||||
}
|
}
|
||||||
prev
|
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
|
// Notify the server when the active layout actually changes so Node-RED
|
||||||
// sees idle reverts + any other kiosk-initiated switch. Skip when the
|
// sees idle reverts + any other kiosk-initiated switch. Skip when the
|
||||||
// layout id is unchanged (re-render of the same layout).
|
// 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_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 server = server_url.clone();
|
||||||
let key = kiosk_key.clone();
|
let key = kiosk_key.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
server::report_layout_change(
|
server::report_layout_change(
|
||||||
&server,
|
&server,
|
||||||
&key,
|
&key,
|
||||||
display_id,
|
&display_id_for_report,
|
||||||
layout_id_for_report,
|
&layout_id_for_report,
|
||||||
&layout_name,
|
&layout_name,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -1012,7 +1015,7 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
||||||
warn!("layout has no cells");
|
warn!("layout has no cells");
|
||||||
recompute_global_state();
|
recompute_global_state();
|
||||||
DISPLAYS.with(|ds| {
|
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);
|
show_logo(&st.window);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1033,21 +1036,21 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
||||||
grid.set_vexpand(true);
|
grid.set_vexpand(true);
|
||||||
grid.set_hexpand(true);
|
grid.set_hexpand(true);
|
||||||
|
|
||||||
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
|
let cam_map: HashMap<&str, &crate::bundle::BundleCamera> =
|
||||||
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
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;
|
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
|
||||||
|
|
||||||
// Ensure preloaded cameras have pipelines even if not visible.
|
// Ensure preloaded cameras have pipelines even if not visible.
|
||||||
for cam_id in &layout.preload_camera_ids {
|
for cam_id in &layout.preload_camera_ids {
|
||||||
if let Some(cam) = cam_map.get(cam_id) {
|
if let Some(cam) = cam_map.get(cam_id.as_str()) {
|
||||||
ensure_warm(*cam_id, cam, None, 0.0);
|
ensure_warm(cam_id, cam, None, 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for cell in &layout.cells {
|
for cell in &layout.cells {
|
||||||
let cell_key: Option<String> = match cell.content_type.as_str() {
|
let cell_key: Option<String> = match cell.content_type.as_str() {
|
||||||
"camera" => cell.camera_id.map(|id| {
|
"camera" => cell.camera_id.as_ref().map(|id| {
|
||||||
format!(
|
format!(
|
||||||
"cam:{id}:{}",
|
"cam:{id}:{}",
|
||||||
cell.stream_selector.as_deref().unwrap_or("auto")
|
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() {
|
let widget: gtk::Widget = match cell.content_type.as_str() {
|
||||||
"camera" => {
|
"camera" => {
|
||||||
if let Some(cam_id) = cell.camera_id {
|
if let Some(cam_id) = cell.camera_id.as_ref() {
|
||||||
if let Some(cam) = cam_map.get(&cam_id) {
|
if let Some(cam) = cam_map.get(cam_id.as_str()) {
|
||||||
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
||||||
if let Some((paintable, badge)) =
|
if let Some((paintable, badge)) =
|
||||||
ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area)
|
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| {
|
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);
|
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 hot_set: std::collections::HashSet<PoolKey> = std::collections::HashSet::new();
|
||||||
let mut max_cooling_secs: u32 = 0;
|
let mut max_cooling_secs: u32 = 0;
|
||||||
|
|
||||||
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
|
let cam_map: HashMap<&str, &crate::bundle::BundleCamera> =
|
||||||
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect();
|
||||||
|
|
||||||
// Snapshot per-display active layout id outside any borrow of WARM_CAMERAS.
|
// Snapshot per-display active layout id outside any borrow of WARM_CAMERAS.
|
||||||
let active: Vec<(u32, Option<u32>)> = DISPLAYS.with(|ds| {
|
let active: Vec<(String, Option<String>)> = DISPLAYS.with(|ds| {
|
||||||
ds.borrow()
|
ds.borrow()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, st)| (*id, st.current_layout_id))
|
.map(|(id, st)| (id.clone(), st.current_layout_id.clone()))
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1417,7 +1420,7 @@ fn recompute_global_state() {
|
||||||
// missing or no streams).
|
// missing or no streams).
|
||||||
fn cell_keys(
|
fn cell_keys(
|
||||||
layout: &crate::bundle::BundleLayout,
|
layout: &crate::bundle::BundleLayout,
|
||||||
cam_map: &HashMap<u32, &crate::bundle::BundleCamera>,
|
cam_map: &HashMap<&str, &crate::bundle::BundleCamera>,
|
||||||
out: &mut std::collections::HashSet<PoolKey>,
|
out: &mut std::collections::HashSet<PoolKey>,
|
||||||
) {
|
) {
|
||||||
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
|
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
|
||||||
|
|
@ -1425,24 +1428,24 @@ fn recompute_global_state() {
|
||||||
if cell.content_type != "camera" {
|
if cell.content_type != "camera" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(cam_id) = cell.camera_id else {
|
let Some(cam_id) = cell.camera_id.as_ref() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(cam) = cam_map.get(&cam_id) else {
|
let Some(cam) = cam_map.get(cam_id.as_str()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
||||||
if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) {
|
if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) {
|
||||||
out.insert((cam_id, badge));
|
out.insert((cam_id.clone(), badge));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Preload cameras have no cell context — let pick_stream choose
|
// Preload cameras have no cell context — let pick_stream choose
|
||||||
// (typically sub). Different layouts that actually render them will
|
// (typically sub). Different layouts that actually render them will
|
||||||
// promote whichever badge they end up using.
|
// promote whichever badge they end up using.
|
||||||
for cam_id in &layout.preload_camera_ids {
|
for cam_id in &layout.preload_camera_ids {
|
||||||
if let Some(cam) = cam_map.get(cam_id) {
|
if let Some(cam) = cam_map.get(cam_id.as_str()) {
|
||||||
if let Some((_, badge)) = cam.pick_stream(None, 0.0) {
|
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
|
let active_id = active
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(id, _)| *id == bd.id)
|
.find(|(id, _)| *id == bd.id)
|
||||||
.and_then(|(_, l)| *l);
|
.and_then(|(_, l)| l.clone());
|
||||||
if let Some(cur_id) = active_id {
|
if let Some(cur_id) = active_id {
|
||||||
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
||||||
cell_keys(layout, &cam_map, &mut warm_set);
|
cell_keys(layout, &cam_map, &mut warm_set);
|
||||||
|
|
@ -1475,7 +1478,7 @@ fn recompute_global_state() {
|
||||||
let active_id = active
|
let active_id = active
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(id, _)| *id == bd.id)
|
.find(|(id, _)| *id == bd.id)
|
||||||
.and_then(|(_, l)| *l);
|
.and_then(|(_, l)| l.clone());
|
||||||
if let Some(cur_id) = active_id {
|
if let Some(cur_id) = active_id {
|
||||||
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
||||||
web_keys_for_layout(layout, &mut warm_webs);
|
web_keys_for_layout(layout, &mut warm_webs);
|
||||||
|
|
@ -1525,7 +1528,7 @@ fn recompute_pool_states(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if max_cooling_secs == 0 {
|
if max_cooling_secs == 0 {
|
||||||
to_remove.push(*key);
|
to_remove.push(key.clone());
|
||||||
to_stop.push(entry.pipeline.clone());
|
to_stop.push(entry.pipeline.clone());
|
||||||
} else {
|
} else {
|
||||||
entry.state = WarmthState::Cooling;
|
entry.state = WarmthState::Cooling;
|
||||||
|
|
@ -1551,15 +1554,15 @@ fn recompute_pool_states(
|
||||||
/// Remove warm camera entries for cameras no longer in the bundle.
|
/// Remove warm camera entries for cameras no longer in the bundle.
|
||||||
/// Immediately stops pipelines — no cooling period.
|
/// Immediately stops pipelines — no cooling period.
|
||||||
fn purge_removed_cameras(bundle_cameras: &[crate::bundle::BundleCamera]) {
|
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_remove: Vec<PoolKey> = Vec::new();
|
||||||
let mut to_stop: Vec<gstreamer::Pipeline> = Vec::new();
|
let mut to_stop: Vec<gstreamer::Pipeline> = Vec::new();
|
||||||
|
|
||||||
WARM_CAMERAS.with(|w| {
|
WARM_CAMERAS.with(|w| {
|
||||||
let mut warm = w.borrow_mut();
|
let mut warm = w.borrow_mut();
|
||||||
for (key, entry) in warm.iter() {
|
for (key, entry) in warm.iter() {
|
||||||
if !valid_ids.contains(&key.0) {
|
if !valid_ids.contains(key.0.as_str()) {
|
||||||
to_remove.push(*key);
|
to_remove.push(key.clone());
|
||||||
to_stop.push(entry.pipeline.clone());
|
to_stop.push(entry.pipeline.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1588,7 +1591,7 @@ fn expire_cooling_pipelines() {
|
||||||
.filter(|(_, e)| {
|
.filter(|(_, e)| {
|
||||||
e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t)
|
e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t)
|
||||||
})
|
})
|
||||||
.map(|(k, _)| *k)
|
.map(|(k, _)| k.clone())
|
||||||
.collect();
|
.collect();
|
||||||
for k in keys {
|
for k in keys {
|
||||||
if let Some(e) = warm.remove(&k) {
|
if let Some(e) = warm.remove(&k) {
|
||||||
|
|
@ -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
|
/// 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.
|
/// so it can be reused if the cell flips back before the cooldown elapses.
|
||||||
fn ensure_warm(
|
fn ensure_warm(
|
||||||
cam_id: u32,
|
cam_id: &str,
|
||||||
cam: &crate::bundle::BundleCamera,
|
cam: &crate::bundle::BundleCamera,
|
||||||
selector: Option<&str>,
|
selector: Option<&str>,
|
||||||
area_fraction: f32,
|
area_fraction: f32,
|
||||||
) -> Option<(gtk::gdk::Paintable, char)> {
|
) -> Option<(gtk::gdk::Paintable, char)> {
|
||||||
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
|
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
|
||||||
let key: PoolKey = (cam_id, desired_badge);
|
let key: PoolKey = (cam_id.to_string(), desired_badge);
|
||||||
|
|
||||||
let cached = WARM_CAMERAS.with(|w| {
|
let cached = WARM_CAMERAS.with(|w| {
|
||||||
w.borrow()
|
w.borrow()
|
||||||
|
|
@ -2179,7 +2182,7 @@ fn add_css(widget: &impl IsA<gtk::Widget>, css: &str) {
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static TERMINAL_CODE_WIDGET: RefCell<Option<gtk::Widget>> = const { RefCell::new(None) };
|
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) {
|
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
|
// Instead, replace the first display window's child with the code
|
||||||
// overlay and restore it when dismissed.
|
// overlay and restore it when dismissed.
|
||||||
let display_id = DISPLAYS.with(|ds| {
|
let display_id = DISPLAYS.with(|ds| {
|
||||||
ds.borrow().keys().next().copied()
|
ds.borrow().keys().next().cloned()
|
||||||
});
|
});
|
||||||
let Some(display_id) = display_id else { return };
|
let Some(display_id) = display_id else { return };
|
||||||
|
|
||||||
|
|
@ -2201,7 +2204,7 @@ fn show_terminal_code_overlay(code: &str) {
|
||||||
// Save current child for restore.
|
// Save current child for restore.
|
||||||
let old_child = win.child();
|
let old_child = win.child();
|
||||||
if let Some(ref c) = old_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.
|
// Match the pairing screen layout but with red warning theme.
|
||||||
|
|
|
||||||
|
|
@ -151,23 +151,21 @@ async fn handle_message(
|
||||||
} else if text.contains("\"type\":\"standby\"") {
|
} else if text.contains("\"type\":\"standby\"") {
|
||||||
let display_id = serde_json::from_str::<serde_json::Value>(text)
|
let display_id = serde_json::from_str::<serde_json::Value>(text)
|
||||||
.ok()
|
.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));
|
let _ = tx.send(ServerMsg::Standby(display_id));
|
||||||
} else if text.contains("\"type\":\"wake\"") {
|
} else if text.contains("\"type\":\"wake\"") {
|
||||||
let display_id = serde_json::from_str::<serde_json::Value>(text)
|
let display_id = serde_json::from_str::<serde_json::Value>(text)
|
||||||
.ok()
|
.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));
|
let _ = tx.send(ServerMsg::Wake(display_id));
|
||||||
} else if text.contains("\"type\":\"layout-switch\"") {
|
} else if text.contains("\"type\":\"layout-switch\"") {
|
||||||
let msg = serde_json::from_str::<serde_json::Value>(text).ok();
|
let msg = serde_json::from_str::<serde_json::Value>(text).ok();
|
||||||
let layout_id = msg.as_ref()
|
let layout_id = msg.as_ref()
|
||||||
.and_then(|m| m.get("layout_id"))
|
.and_then(|m| m.get("layout_id"))
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(flexible_id_from_value);
|
||||||
.map(|v| v as u32);
|
|
||||||
let display_id = msg.as_ref()
|
let display_id = msg.as_ref()
|
||||||
.and_then(|m| m.get("display_id"))
|
.and_then(|m| m.get("display_id"))
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(flexible_id_from_value);
|
||||||
.map(|v| v as u32);
|
|
||||||
if let Some(layout_id) = layout_id {
|
if let Some(layout_id) = layout_id {
|
||||||
let _ = tx.send(ServerMsg::SwitchLayout { display_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 {
|
fn build_ws_url(http_url: &str, token: &str) -> String {
|
||||||
let base = if let Some(rest) = http_url.strip_prefix("https://") {
|
let base = if let Some(rest) = http_url.strip_prefix("https://") {
|
||||||
format!("wss://{}", rest.split('/').next().unwrap_or(rest))
|
format!("wss://{}", rest.split('/').next().unwrap_or(rest))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue