2026-05-10 02:18:40 +00:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct KioskBundle {
|
|
|
|
|
pub kiosk_id: u32,
|
|
|
|
|
pub kiosk_name: String,
|
2026-05-12 23:18:22 +00:00
|
|
|
/// Legacy single-display field (mirrors `displays[0]`). New code should
|
|
|
|
|
/// iterate `displays` instead.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub display: Option<BundleDisplay>,
|
|
|
|
|
/// Legacy single-display layouts (mirrors `displays[0].layouts`). Kept for
|
|
|
|
|
/// backward compatibility with older bundles that pre-date multi-display.
|
|
|
|
|
#[serde(default)]
|
2026-05-10 02:18:40 +00:00
|
|
|
pub layouts: Vec<BundleLayout>,
|
2026-05-12 23:18:22 +00:00
|
|
|
/// All physical displays driven by this kiosk.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub displays: Vec<BundleDisplayWithLayouts>,
|
2026-05-10 02:18:40 +00:00
|
|
|
pub cameras: Vec<BundleCamera>,
|
2026-05-12 23:18:22 +00:00
|
|
|
#[serde(default)]
|
|
|
|
|
pub gpio_bindings: Vec<BundleGpioBinding>,
|
2026-05-10 02:18:40 +00:00
|
|
|
pub version: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
impl KioskBundle {
|
|
|
|
|
/// Normalize the bundle: if `displays` is empty (old server), synthesize it
|
|
|
|
|
/// from the legacy single `display` + `layouts` fields so the rest of the
|
|
|
|
|
/// kiosk only deals with one shape.
|
|
|
|
|
pub fn normalized_displays(&self) -> Vec<BundleDisplayWithLayouts> {
|
|
|
|
|
if !self.displays.is_empty() {
|
|
|
|
|
return self.displays.clone();
|
|
|
|
|
}
|
|
|
|
|
if let Some(d) = &self.display {
|
|
|
|
|
return vec![BundleDisplayWithLayouts {
|
|
|
|
|
id: d.id,
|
|
|
|
|
name: d.name.clone(),
|
|
|
|
|
width_px: d.width_px,
|
|
|
|
|
height_px: d.height_px,
|
|
|
|
|
idle_timeout_seconds: d.idle_timeout_seconds,
|
|
|
|
|
sleep_timeout_seconds: d.sleep_timeout_seconds,
|
|
|
|
|
default_layout_id: d.default_layout_id,
|
|
|
|
|
layouts: self.layouts.clone(),
|
|
|
|
|
}];
|
|
|
|
|
}
|
|
|
|
|
Vec::new()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 02:18:40 +00:00
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct BundleDisplay {
|
|
|
|
|
pub id: u32,
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub width_px: u32,
|
|
|
|
|
pub height_px: u32,
|
|
|
|
|
pub idle_timeout_seconds: u32,
|
|
|
|
|
pub sleep_timeout_seconds: u32,
|
|
|
|
|
pub default_layout_id: Option<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct BundleDisplayWithLayouts {
|
|
|
|
|
pub id: u32,
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub width_px: u32,
|
|
|
|
|
pub height_px: u32,
|
|
|
|
|
pub idle_timeout_seconds: u32,
|
|
|
|
|
pub sleep_timeout_seconds: u32,
|
|
|
|
|
pub default_layout_id: Option<u32>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub layouts: Vec<BundleLayout>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 02:18:40 +00:00
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct BundleLayout {
|
|
|
|
|
pub id: u32,
|
|
|
|
|
pub name: String,
|
2026-05-10 19:39:09 +00:00
|
|
|
pub grid_cols: u32,
|
|
|
|
|
pub grid_rows: u32,
|
2026-05-10 02:18:40 +00:00
|
|
|
pub priority: String,
|
|
|
|
|
pub cooling_timeout_seconds: Option<u32>,
|
|
|
|
|
pub preload_camera_ids: Vec<u32>,
|
|
|
|
|
pub is_default: bool,
|
|
|
|
|
pub resets_idle_timer: bool,
|
|
|
|
|
pub cells: Vec<BundleCell>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
2026-05-10 19:55:19 +00:00
|
|
|
pub struct BundleCell {
|
2026-05-10 02:18:40 +00:00
|
|
|
pub row: u32,
|
|
|
|
|
pub col: u32,
|
|
|
|
|
pub row_span: u32,
|
|
|
|
|
pub col_span: u32,
|
|
|
|
|
pub content_type: String,
|
|
|
|
|
pub camera_id: Option<u32>,
|
|
|
|
|
pub stream_selector: Option<String>,
|
|
|
|
|
pub web_url: Option<String>,
|
|
|
|
|
pub html_content: Option<String>,
|
|
|
|
|
pub cooling_timeout_seconds: Option<u32>,
|
2026-05-11 11:52:22 +00:00
|
|
|
#[serde(default = "default_fit")]
|
|
|
|
|
pub fit: String,
|
2026-05-23 00:21:27 +00:00
|
|
|
#[serde(default)]
|
|
|
|
|
pub smart_url: Option<SmartUrlConfig>,
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-11 11:52:22 +00:00
|
|
|
fn default_fit() -> String { "cover".to_string() }
|
|
|
|
|
|
2026-05-23 00:21:27 +00:00
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct SmartUrlConfig {
|
|
|
|
|
pub steps: Vec<SmartUrlStep>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub login_detect_url: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub session_check_interval_ms: Option<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct SmartUrlStep {
|
|
|
|
|
#[serde(rename = "type")]
|
|
|
|
|
pub step_type: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub url: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub selector: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub value: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub value_encrypted: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub delay_ms: Option<u32>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub timeout_ms: Option<u32>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub script: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 02:18:40 +00:00
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct BundleCamera {
|
|
|
|
|
pub id: u32,
|
|
|
|
|
pub name: String,
|
|
|
|
|
#[serde(rename = "type")]
|
|
|
|
|
pub cam_type: String,
|
|
|
|
|
pub rtsp_url: Option<String>,
|
|
|
|
|
pub stream_policy: String,
|
|
|
|
|
pub streams: Vec<BundleStream>,
|
feat(onvif-events): PullPoint subscription for all ONVIF cameras
New kiosk/src/onvif_events.rs: for each ONVIF camera in the bundle,
creates a PullPoint subscription, polls every 3s, parses
NotificationMessage XML into structured JSON (topic + source key/values
+ data key/values + timestamp), and POSTs to /api/kiosk/event with
source_type=onvif + camera_id.
Forwards ALL event topics: motion, ANPR (LicensePlateRecognition),
line crossing, intrusion, digital input, analytics, tamper — everything
the camera exposes. Node-RED sorts what matters.
Subscription lifecycle:
- CreatePullPointSubscription with 60s InitialTerminationTime
- Renew every 55s before timeout
- Unsubscribe on bundle change / shutdown
- Auto-resubscribe on pull/renew failure (30s backoff)
- Generation tracking via Weak<()> so old workers self-terminate
when start() is called with a new bundle
WSSE PasswordDigest auth for SOAP calls — same scheme the server's
onvif.ts uses. sha1 crate added.
BundleCamera extended with onvif_host/port/username/password_encrypted
fields (server already ships them; kiosk just wasn't deserializing).
Gated by BF_ENABLE_ONVIF_EVENTS=1. Enabled by default in the pi-gen
image env file.
TODO: cluster-key-based decryption of onvif_password_encrypted. For
now relies on the RTSP URI having plaintext credentials embedded (which
the ONVIF import path already ensures via rtspWithCredentials).
2026-05-21 10:03:30 +00:00
|
|
|
// ONVIF fields — present when cam_type=="onvif". Password is encrypted
|
|
|
|
|
// with the cluster key; kiosk decrypts for ONVIF SOAP auth.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub onvif_host: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub onvif_port: Option<u16>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub onvif_username: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub onvif_password_encrypted: Option<String>,
|
2026-05-22 22:38:54 +00:00
|
|
|
#[serde(default)]
|
|
|
|
|
pub event_source: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub event_sink: Option<String>,
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct BundleStream {
|
|
|
|
|
pub id: u32,
|
|
|
|
|
pub role: String,
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub rtsp_uri: String,
|
|
|
|
|
pub width: Option<u32>,
|
|
|
|
|
pub height: Option<u32>,
|
|
|
|
|
pub encoding: Option<String>,
|
|
|
|
|
pub framerate: Option<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct BundleGpioBinding {
|
|
|
|
|
pub id: u32,
|
|
|
|
|
pub chip: String,
|
|
|
|
|
pub pin: u32,
|
|
|
|
|
pub direction: String,
|
|
|
|
|
pub pull: Option<String>,
|
|
|
|
|
pub edge: Option<String>,
|
|
|
|
|
pub topic: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 02:18:40 +00:00
|
|
|
impl BundleCamera {
|
2026-05-11 09:05:38 +00:00
|
|
|
/// Pick stream URI + role tag for this camera given selector and cell area fraction.
|
|
|
|
|
/// Heuristic: when selector=auto, cell ≥20% of grid → main, else sub.
|
|
|
|
|
/// Returns (uri, role_letter) where role_letter is 'M' or 'S' (or empty if single stream).
|
|
|
|
|
pub fn pick_stream(&self, selector: Option<&str>, area_fraction: f32) -> Option<(String, char)> {
|
|
|
|
|
let has_main = self.streams.iter().any(|s| s.role == "main");
|
|
|
|
|
let has_sub = self.streams.iter().any(|s| s.role == "sub");
|
|
|
|
|
let multi = has_main && has_sub;
|
|
|
|
|
|
|
|
|
|
let sel = selector.unwrap_or("auto");
|
|
|
|
|
let role_pref = match sel {
|
|
|
|
|
"main" => "main",
|
|
|
|
|
"sub" => "sub",
|
|
|
|
|
_ => if area_fraction >= 0.2 { "main" } else { "sub" },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let stream = self.streams.iter().find(|s| s.role == role_pref)
|
|
|
|
|
.or_else(|| self.streams.iter().find(|s| s.role == "main"))
|
|
|
|
|
.or_else(|| self.streams.first());
|
|
|
|
|
|
|
|
|
|
let uri = stream.map(|s| s.rtsp_uri.clone())
|
|
|
|
|
.or_else(|| self.rtsp_url.clone())?;
|
|
|
|
|
let badge = if !multi {
|
|
|
|
|
' '
|
|
|
|
|
} else if stream.map(|s| s.role.as_str()) == Some("main") {
|
|
|
|
|
'M'
|
|
|
|
|
} else {
|
|
|
|
|
'S'
|
|
|
|
|
};
|
|
|
|
|
Some((uri, badge))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|