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
+17 -5
View File
@@ -2,9 +2,10 @@ import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { deviceEvents } from "../device-events.js";
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
import { enrichEvent } from "../event-enrich.js";
import type { DeviceMonitor } from "../device-monitor.js";
import type { LaneStatus } from "../lane-status.js";
import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
@@ -52,12 +53,18 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
}
type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown };
| { kind: "device-status"; event: unknown }
| { kind: "lane-status"; lanes: LaneStatusEvent };
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
export async function wsRoutes(
app: FastifyInstance,
db: Db,
deviceMonitor: DeviceMonitor,
laneStatus: LaneStatus,
): Promise<void> {
app.get(
"/api/ws",
{
@@ -89,7 +96,7 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
// Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() });
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
// Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative.
@@ -106,11 +113,16 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event });
});
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
const offLane = deviceEvents.onLaneStatus((lanes) => {
send({ kind: "lane-status", lanes });
});
socket.on("close", () => {
offLedger();
offPrinter();
offDevice();
offLane();
});
},
);