feat(booth): live lane busy/free barrier lights from camera vehicle detection

A Hikvision vehicle detection (eventType=VMD, targetType=vehicle) on a
camera bound to entry/exit now marks that lane "busy" and shows it as a
barrier light beside the scan input on the booth (green=free, red=busy).
Advisory only — it gates nothing (never blocks a ticket or opens a barrier).

- Parse eventState (active/inactive) from the Hik payload.
- LaneStatus tracker: a vehicle `active` event marks the camera's bound lane
  busy + arms an auto-clear timer. This camera class sends no leave/`inactive`
  signal, so "free" is timeout-driven (LANE_BUSY_TTL_MS, default 90s; the
  camera re-fires `active` while a car sits there, refreshing the timer). A
  "both"-direction camera marks both lanes.
- Push lane-status over the existing booth WS (+ in the hello snapshot);
  live-store holds { entry, exit }; two BarrierLight icons render it.
- i18n booth.laneEntry/laneExit (sq + en).

Tests: lane-status.test.ts (7 — busy/free, TTL auto-clear, timer re-arm,
no re-emit while busy, both/exit direction, unknown device). server 120/120;
web + server build/lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-22 17:42:54 +02:00
parent 6f4e390c05
commit e0b9442acc
11 changed files with 366 additions and 17 deletions
+10 -3
View File
@@ -25,6 +25,7 @@ import { userRoutes } from "./routes/users.js";
import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js";
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
import { LaneStatus } from "./lane-status.js";
import { eventRoutes } from "./routes/events.js";
import { reportRoutes } from "./routes/reports.js";
import { recycleBinRoutes } from "./routes/recycle-bin.js";
@@ -115,10 +116,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// the device's lane_devices config (written on assign).
await deviceRoutes(app, db);
// Lane busy/free tracker: a camera's vehicle detection marks its bound lane busy
// (advisory barrier lights on the booth); auto-clears on a timeout. See lane-status.ts.
const laneStatus = new LaneStatus(db, app.log);
app.addHook("onClose", async () => laneStatus.stop());
// Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on
// each detected target (vehicle). Source-IP guarded + optional Digest; records the raw
// payload as a `kind:"alarm"` device_event (discovery-first). See routes/hikvision-alarm.ts.
await hikvisionAlarmRoutes(app, db);
// payload as a `kind:"alarm"` device_event AND drives lane busy/free for vehicles.
// See routes/hikvision-alarm.ts.
await hikvisionAlarmRoutes(app, db, laneStatus);
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
// pushes changes to the booth UI. setupRoutes() has already registered the
@@ -160,7 +167,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor);
await wsRoutes(app, db, deviceMonitor, laneStatus);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db);