mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
feat(cameras): periodic offline detection via TCP probe + camera.offline events
This commit is contained in:
parent
caf6095b6e
commit
a92e927b3b
2 changed files with 137 additions and 0 deletions
|
|
@ -103,6 +103,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
runAfterPlugins?: string[];
|
||||
|
||||
private server?: Server;
|
||||
private cameraHealthChecker?: { stop: () => void };
|
||||
|
||||
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||
super(cfg);
|
||||
|
|
@ -228,6 +229,13 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof Event
|
|||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.cameraHealthChecker?.stop();
|
||||
if (this.server) {
|
||||
await this.server.close();
|
||||
}
|
||||
|
|
|
|||
128
server/src/shared/camera-health.ts
Normal file
128
server/src/shared/camera-health.ts
Normal file
|
|
@ -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<CameraHealthConfig> = {},
|
||||
log: { info: (m: string) => void; warn: (m: string) => void },
|
||||
): { stop: () => void } {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function checkAll(): Promise<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue