diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index b343002..1561b1b 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -103,6 +103,7 @@ export class Plugin extends BSBService, typeof Event runAfterPlugins?: string[]; private server?: Server; + private cameraHealthChecker?: { stop: () => void }; constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { super(cfg); @@ -228,6 +229,13 @@ export class Plugin extends BSBService, typeof Event port: this.config.port, }); + // Camera health checker — periodic TCP probe to mark cameras online/offline. + const { startCameraHealthChecker } = await import("../../shared/camera-health.js"); + this.cameraHealthChecker = startCameraHealthChecker(repo, {}, { + info: (m) => obs.log.info(m as any, {}), + warn: (m) => obs.log.warn(m as any, {}), + }); + // Auto-provision the Node-RED bf-server-config so the user doesn't have // to set server URL + API key manually. Best-effort with retries because // Node-RED may still be starting. @@ -296,6 +304,7 @@ export class Plugin extends BSBService, typeof Event } async dispose(): Promise { + this.cameraHealthChecker?.stop(); if (this.server) { await this.server.close(); } diff --git a/server/src/shared/camera-health.ts b/server/src/shared/camera-health.ts new file mode 100644 index 0000000..6043104 --- /dev/null +++ b/server/src/shared/camera-health.ts @@ -0,0 +1,128 @@ +/** + * Periodic camera health checker. + * + * Probes each camera's RTSP stream or ONVIF endpoint to determine if + * it's reachable. Updates cameras.last_seen_at on success. Emits an + * event when a camera goes offline (was seen recently, now unreachable). + * + * Runs server-side on a configurable interval (default 60s). Uses the + * ffprobe-style approach: open a TCP connection to the RTSP port, send + * OPTIONS, check for 200. No actual stream decode — just reachability. + */ +import { createConnection, type Socket } from "node:net"; +import type { Repository } from "../plugins/service-store/repository.js"; + +export interface CameraHealthConfig { + intervalMs: number; + timeoutMs: number; +} + +const DEFAULT_CONFIG: CameraHealthConfig = { + intervalMs: 60_000, + timeoutMs: 5_000, +}; + +export function startCameraHealthChecker( + repo: Repository, + config: Partial = {}, + log: { info: (m: string) => void; warn: (m: string) => void }, +): { stop: () => void } { + const cfg = { ...DEFAULT_CONFIG, ...config }; + let timer: ReturnType | null = null; + + async function checkAll(): Promise { + const cameras = repo.listCameras().filter((c) => c.enabled); + for (const cam of cameras) { + const host = cam.type === "onvif" + ? cam.onvif_host + : parseRtspHost(cam.rtsp_url); + const port = cam.type === "onvif" + ? (cam.onvif_port ?? 80) + : parseRtspPort(cam.rtsp_url); + + if (!host) continue; + + const reachable = await tcpProbe(host, port, cfg.timeoutMs); + const wasOnline = cam.last_seen_at + ? Date.now() - new Date(cam.last_seen_at).getTime() < cfg.intervalMs * 3 + : false; + + if (reachable) { + repo.updateCamera(cam.id, { last_seen_at: new Date().toISOString() } as any); + } else if (wasOnline) { + // Camera just went offline — log event for Node-RED / admin visibility. + log.warn(`camera ${cam.id} (${cam.name}) went offline`); + try { + repo.insertEvent({ + source_kiosk_id: null, + source_camera_id: cam.id, + source_type: "system", + topic: "camera.offline", + property_op: null, + payload: { + camera_id: cam.id, + camera_name: cam.name, + last_seen_at: cam.last_seen_at, + }, + forwarded_to_nodered: false, + }); + } catch { + // Event insert failure shouldn't break the health loop. + } + } + } + } + + // Run immediately then on interval. + checkAll().catch(() => {}); + timer = setInterval(() => { checkAll().catch(() => {}); }, cfg.intervalMs); + + return { + stop() { + if (timer) { clearInterval(timer); timer = null; } + }, + }; +} + +function tcpProbe(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + let resolved = false; + const done = (result: boolean) => { + if (resolved) return; + resolved = true; + try { sock.destroy(); } catch { /* ignore */ } + resolve(result); + }; + + const sock: Socket = createConnection({ host, port, timeout: timeoutMs }, () => { + done(true); + }); + sock.on("error", () => done(false)); + sock.on("timeout", () => done(false)); + setTimeout(() => done(false), timeoutMs + 500); + }); +} + +function parseRtspHost(url: string | null): string | null { + if (!url) return null; + try { + const match = url.match(/@([^:/]+)/); + if (match) return match[1]!; + const u = new URL(url.replace("rtsp://", "http://")); + return u.hostname || null; + } catch { + return null; + } +} + +function parseRtspPort(url: string | null): number { + if (!url) return 554; + try { + const match = url.match(/@[^:]+:(\d+)/); + if (match) return Number(match[1]); + const u = new URL(url.replace("rtsp://", "http://")); + return u.port ? Number(u.port) : 554; + } catch { + return 554; + } +}