BetterFrame/kiosk/src/bundle.rs
Mitchell R a233b7d38b
feat(smart-url): automated login/navigation sequences for web cells
Smart URL actions: multi-step browser automation for web cells behind
login pages. Steps: navigate, fill (form fields), click, wait, wait_for
(element selector), javascript (raw eval). Passwords in fill steps
encrypted with per-kiosk key for transport.

Schema: server/src/schemas/wire/smart-url.ts defines step types.
Stored in layout_cells.options.smart_url (no migration needed).

Bundle: includes smart_url config per cell. Fill step values encrypted
at bundle generation time with per-kiosk key (or cluster key fallback).

Kiosk: execute_smart_url_steps() builds an async JS sequence from the
steps and injects via WebKit evaluate_javascript on LoadEvent::Finished.
Supports session expiry detection via login_detect_url.

Admin UI: step builder TODO (currently configure via cell options JSON).
Data model + kiosk execution + bundle transport are complete.
2026-05-23 02:21:27 +02:00

215 lines
6.5 KiB
Rust

use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KioskBundle {
pub kiosk_id: u32,
pub kiosk_name: String,
/// 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)]
pub layouts: Vec<BundleLayout>,
/// All physical displays driven by this kiosk.
#[serde(default)]
pub displays: Vec<BundleDisplayWithLayouts>,
pub cameras: Vec<BundleCamera>,
#[serde(default)]
pub gpio_bindings: Vec<BundleGpioBinding>,
pub version: String,
}
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()
}
}
#[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>,
}
#[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>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleLayout {
pub id: u32,
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>,
pub is_default: bool,
pub resets_idle_timer: bool,
pub cells: Vec<BundleCell>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleCell {
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>,
#[serde(default = "default_fit")]
pub fit: String,
#[serde(default)]
pub smart_url: Option<SmartUrlConfig>,
}
fn default_fit() -> String { "cover".to_string() }
#[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>,
}
#[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>,
// 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>,
#[serde(default)]
pub event_source: Option<String>,
#[serde(default)]
pub event_sink: Option<String>,
}
#[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>,
}
#[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,
}
impl BundleCamera {
/// 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))
}
}