diff --git a/kiosk/src/bundle.rs b/kiosk/src/bundle.rs index a4be63d..756c078 100644 --- a/kiosk/src/bundle.rs +++ b/kiosk/src/bundle.rs @@ -120,6 +120,10 @@ pub struct BundleCamera { pub onvif_username: Option, #[serde(default)] pub onvif_password_encrypted: Option, + #[serde(default)] + pub event_source: Option, + #[serde(default)] + pub event_sink: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/kiosk/src/onvif_events.rs b/kiosk/src/onvif_events.rs index b52b76f..b4bb973 100644 --- a/kiosk/src/onvif_events.rs +++ b/kiosk/src/onvif_events.rs @@ -27,6 +27,42 @@ use crate::bundle::BundleCamera; /// to know when to stop (camera removed from bundle / bundle changed). static ACTIVE: Mutex>> = Mutex::new(None); +/// Subscription status per camera — reported in heartbeat for admin visibility. +static STATUS: Mutex>> = Mutex::new(None); + +#[derive(Clone, serde::Serialize)] +pub struct SubStatus { + pub state: &'static str, // "subscribing", "active", "failed", "stopped" + pub last_event_at: Option, + pub error: Option, +} + +fn set_status(cam_id: u32, state: &'static str, error: Option) { + 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 { + state: "subscribing", + last_event_at: None, + error: None, + }); + entry.state = state; + entry.error = error; +} + +fn mark_event_received(cam_id: u32) { + let mut map = STATUS.lock().unwrap(); + if let Some(map) = map.as_mut() { + 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 { + STATUS.lock().unwrap().clone().unwrap_or_default() +} + /// Start event subscription workers for all ONVIF cameras in the bundle. /// Idempotent — stops old workers (via ACTIVE flag) before starting new. pub fn start( @@ -35,9 +71,19 @@ pub fn start( server_url: &str, kiosk_key: &str, ) { + // Only subscribe to cameras where event_source is "auto" or "kiosk:" + // (not "server" or another kiosk). For "auto", this kiosk subscribes because + // the server put the camera in this kiosk's bundle — meaning it's reachable. let onvif_cams: Vec<_> = cameras .iter() - .filter(|c| c.cam_type == "onvif" && c.onvif_host.is_some()) + .filter(|c| { + if c.cam_type != "onvif" || c.onvif_host.is_none() { return false; } + match c.event_source.as_deref() { + Some("server") => false, // server handles this one + Some(s) if s.starts_with("kiosk:") => true, // pinned to a kiosk (might be us) + _ => true, // "auto" or missing → this kiosk subscribes + } + }) .cloned() .collect(); @@ -95,15 +141,18 @@ fn run_subscription( } // 1. CreatePullPointSubscription + 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)); std::thread::sleep(Duration::from_secs(30)); continue; } }; info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address); + set_status(cam.id, "active", None); // 2. Poll loop let poll_interval = Duration::from_secs(3); diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index f2ea4df..4a938e5 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -427,6 +427,7 @@ pub fn heartbeat( "local_port": local_port, "reported_hostname": hostname, "network_interfaces": network_interfaces, + "onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(), })) .timeout(Duration::from_secs(5)) .send() diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 4529262..4bfc562 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -35,7 +35,7 @@ import { renderDisplayLayouts, renderDefaultLayoutSelect, } from "../../web-templates/admin-pages.js"; -import { discover as onvifDiscover } from "../../shared/onvif.js"; +import { discover as onvifDiscover, getEventProperties as onvifGetEventProperties } from "../../shared/onvif.js"; import { generateBundle } from "../../shared/bundle.js"; import { captureSnapshot } from "../../shared/snapshot.js"; import { stripSecrets } from "../../shared/strip-secrets.js"; @@ -1390,6 +1390,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { patch["onvif_username"] = body?.["onvif_username"] || null; if (body?.["onvif_password"]) patch["onvif_password"] = body["onvif_password"]; } + // Event routing config + if (body?.["event_source"] != null) patch["event_source"] = body["event_source"] || "auto"; + if (body?.["event_sink"] != null) patch["event_sink"] = body["event_sink"] || "auto"; deps.repo.updateCamera(id, patch as any); // Also update main stream URI for RTSP cameras @@ -1451,6 +1454,40 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } }); }); + // Refresh supported ONVIF event topics from the camera. + app.post("/admin/cameras/:id/refresh-events", async (event) => { + const id = Number(getRouterParam(event, "id")); + const cam = deps.repo.getCameraById(id); + if (!cam || cam.type !== "onvif" || !cam.onvif_host) { + return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); + } + // Determine which kiosk (or server) to run the SOAP call through. + const runner = cam.event_source === "server" ? "server" + : cam.event_source.startsWith("kiosk:") ? cam.event_source + : (() => { + // Auto: pick a kiosk that has this camera in its bundle. + const kiosks = deps.repo.listKiosksWithCameraInBundle(id); + const online = kiosks.find((k) => k.last_seen_at && Date.now() - new Date(k.last_seen_at).getTime() < 120_000); + return online ? `kiosk:${online.id}` : "server"; + })(); + const soapTransport = runner.startsWith("kiosk:") + ? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length))) + : undefined; + try { + const topics = await onvifGetEventProperties({ + host: cam.onvif_host, + port: cam.onvif_port ?? 80, + username: cam.onvif_username ?? "", + password: cam.onvif_password ?? "", + soapTransport, + }); + deps.repo.updateCamera(id, { supported_event_topics: JSON.stringify(topics) } as any); + } catch { + // Camera offline or events not supported — leave existing topics. + } + return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); + }); + app.post("/admin/cameras/:id/delete", (event) => { const id = Number(getRouterParam(event, "id")); deps.repo.deleteCamera(id); diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index f707c24..b6374c7 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -162,6 +162,9 @@ export function rowToCamera(r: Row): Camera { enabled: b(r["enabled"]), last_seen_at: sn(r["last_seen_at"]), created_at: s(r["created_at"]), + event_source: s(r["event_source"] ?? "auto"), + event_sink: s(r["event_sink"] ?? "auto"), + supported_event_topics: j(r["supported_event_topics"], []), }; } diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 49ad0a5..78760c3 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -959,4 +959,12 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ // --- display active layout --- addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL"); }, + + // ONVIF event routing: per-camera event_source (who polls), event_sink + // (where push callbacks go), and discovered supported topics. + (db: DatabaseSync) => { + addColumnIfNotExists(db, "cameras", "event_source", "TEXT NOT NULL DEFAULT 'auto'"); + addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'"); + addColumnIfNotExists(db, "cameras", "supported_event_topics", "TEXT NOT NULL DEFAULT '[]'"); + }, ]; diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index c65a59f..7d3cea7 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -17,6 +17,8 @@ export interface BundleCamera { onvif_port: number | null; onvif_username: string | null; onvif_password_encrypted: string | null; + event_source: string; + event_sink: string; stream_policy: string; streams: Array<{ id: number; @@ -235,6 +237,8 @@ export function generateBundle( onvif_port: cam.onvif_port, onvif_username: cam.onvif_username, onvif_password_encrypted: onvifPwEncrypted, + event_source: cam.event_source, + event_sink: cam.event_sink, stream_policy: cam.stream_policy, streams: effectiveStreams.map((s) => ({ id: s.id, diff --git a/server/src/shared/onvif.ts b/server/src/shared/onvif.ts index 17a80fc..d8077ef 100644 --- a/server/src/shared/onvif.ts +++ b/server/src/shared/onvif.ts @@ -429,3 +429,77 @@ export async function discover(input: DiscoverInput): Promise { + const timeoutMs = input.timeoutMs ?? 8000; + const endpoint = normalizeEndpoint(input); + const header = wsseHeader(input.username, input.password); + const eventUrl = `${endpoint.origin}/onvif/event_service`; + + const body = buildEnvelope(header, + ``); + + let xml: string; + try { + xml = await soap(eventUrl, + "http://www.onvif.org/ver10/events/wsdl/EventPortType/GetEventPropertiesRequest", + body, timeoutMs, input.soapTransport); + } catch { + return []; + } + + // Parse TopicSet — extract all topic paths. ONVIF nests topics as XML + // elements under TopicSet. Each leaf element with wstop:topic="true" is + // a subscribable topic. The full path is the concatenation of ancestor + // element names separated by "/". + const topics: string[] = []; + + // Strategy: find all elements with topic="true" attribute and walk + // their path. Simpler: extract all text between and + // , find elements with topic="true", reconstruct paths. + const topicSetMatch = xml.match(/<[^:]*:?TopicSet[^>]*>([\s\S]*?)<\/[^:]*:?TopicSet>/); + if (!topicSetMatch) return topics; + const topicSetXml = topicSetMatch[1] ?? ""; + + // Walk the XML naively: track element depth + names. + const stack: string[] = []; + const tagRe = /<\/?([^\s>\/]+)[^>]*?(\/?)>/g; + let match; + while ((match = tagRe.exec(topicSetXml)) !== null) { + const full = match[0]!; + const tagName = match[1]!; + const selfClose = match[2] === "/"; + const isClose = full.startsWith(" 0 ? `tns1:${path}` : path; + topics.push(topicPath); + } + if (selfClose) { + stack.pop(); + } + } + } + + return topics; +} diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index baefe1e..f0c14d4 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -106,6 +106,9 @@ export interface Display { active_layout_id: number | null; } +export type EventSourceMode = "auto" | "server" | string; // string = "kiosk:" +export type EventSinkMode = "auto" | "server" | string; + export interface Camera { id: number; name: string; @@ -114,12 +117,15 @@ export interface Camera { onvif_host: string | null; onvif_port: number | null; onvif_username: string | null; - onvif_password: string | null; // fernet-encrypted ciphertext + onvif_password: string | null; capabilities: string[]; stream_policy: StreamPolicy; enabled: boolean; last_seen_at: string | null; created_at: string; + event_source: EventSourceMode; + event_sink: EventSinkMode; + supported_event_topics: string[]; } export interface CameraStream { diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 858c321..4b0d638 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1298,11 +1298,70 @@ export function CameraEditPage(props: CameraEditProps) { {" "}Enabled + {cam.type === "onvif" && ( +
+

Event Routing

+
+
+ + +
+
+ + +
+
+
+ )} + Back + {cam.type === "onvif" && ( +
+

Supported Event Topics

+

+ Topics this camera advertises via GetEventProperties. Click refresh + to re-query the camera (via the designated event source). +

+
+ +
+ {cam.supported_event_topics.length > 0 ? ( +
+ + + + {cam.supported_event_topics.map((t) => ( + + ))} + +
Topic
{t}
+
+ ) : ( +

No topics discovered yet. Click refresh above.

+ )} +
+ )} +

Labels