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:
Mitchell R 2026-05-26 13:09:32 +02:00
parent 0c74e26e42
commit 908fd417c0
No known key found for this signature in database
9 changed files with 467 additions and 129 deletions

138
kiosk/Cargo.lock generated
View file

@ -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"

View file

@ -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

View file

@ -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,

View file

@ -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) {

View file

@ -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,

View file

@ -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.

View file

@ -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();

View file

@ -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.

View file

@ -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))