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:
@@ -4,6 +4,7 @@ import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { verifyDigest } from "../digest-auth.js";
|
||||
import type { LaneStatus } from "../lane-status.js";
|
||||
|
||||
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
|
||||
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
|
||||
@@ -53,6 +54,9 @@ function isOn(v: unknown): boolean {
|
||||
* payload" — the raw body is always stored so nothing is lost. */
|
||||
interface AlarmSummary {
|
||||
eventType?: string;
|
||||
/** `active` (target entered the region) | `inactive` (target left). The edge that
|
||||
* drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */
|
||||
eventState?: string;
|
||||
target?: string;
|
||||
plate?: string;
|
||||
dateTime?: string;
|
||||
@@ -79,6 +83,7 @@ function pick(s: string, re: RegExp): string | undefined {
|
||||
function summarize(body: string): AlarmSummary {
|
||||
return {
|
||||
eventType: pick(body, /<eventType>([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i),
|
||||
eventState: pick(body, /<eventState>([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i),
|
||||
target:
|
||||
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
|
||||
pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i),
|
||||
@@ -90,7 +95,7 @@ function summarize(body: string): AlarmSummary {
|
||||
};
|
||||
}
|
||||
|
||||
export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db, laneStatus?: LaneStatus): Promise<void> {
|
||||
// Accept ANY content-type as a raw Buffer (the camera may POST application/xml,
|
||||
// multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415
|
||||
// or empty these — we want the bytes verbatim. Scoped to THIS app instance via a
|
||||
@@ -186,10 +191,22 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis
|
||||
// Loud log so the operator can SEE the payload during testing.
|
||||
app.log.info(
|
||||
`[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` +
|
||||
`event=${summary.eventType ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
|
||||
`event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
|
||||
);
|
||||
record({ deviceId, method, accepted: true, ip, contentType, raw, summary });
|
||||
|
||||
// Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory,
|
||||
// for the booth barrier lights). Only on a vehicle target that's `active` — an
|
||||
// `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a
|
||||
// timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent.
|
||||
if (
|
||||
laneStatus &&
|
||||
(summary.target ?? "").toLowerCase() === "vehicle" &&
|
||||
(summary.eventState ?? "active").toLowerCase() !== "inactive"
|
||||
) {
|
||||
laneStatus.vehicleDetected(deviceId);
|
||||
}
|
||||
|
||||
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
|
||||
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
|
||||
// entry/exit) is the deliberate next step once we know the real payload.
|
||||
@@ -237,6 +254,7 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis
|
||||
contentType: (d.contentType as string) ?? null,
|
||||
bytes: (d.bytes as number) ?? 0,
|
||||
eventType: (d.eventType as string) ?? null,
|
||||
eventState: (d.eventState as string) ?? null,
|
||||
target: (d.target as string) ?? null,
|
||||
plate: (d.plate as string) ?? null,
|
||||
rawHead: (d.rawHead as string) ?? null,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user