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
+2
View File
@@ -95,6 +95,8 @@ export const en: Catalog = {
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
laneEntry: "Entry",
laneExit: "Exit",
open: "Open",
occupancy: "Occupancy",
occUnavailable: "occupancy unavailable",
+2
View File
@@ -97,6 +97,8 @@ export const sq = {
booth: {
processTicket: "Proceso biletën",
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
laneEntry: "Hyrje",
laneExit: "Dalje",
open: "Hap",
occupancy: "Prania",
occUnavailable: "zënia e padisponueshme",
+13 -1
View File
@@ -10,6 +10,12 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
export type WsStatus = "connecting" | "open" | "closed";
/** Per-lane busy/free from camera vehicle detection (advisory barrier lights). */
export interface LaneStatus {
entry: boolean; // true = busy
exit: boolean; // true = busy
}
/** Cap the in-memory live feed so a long-running booth session can't grow it
* unbounded — the full history is always available via the /api/events query. */
const MAX_FEED = 200;
@@ -23,6 +29,8 @@ interface LiveState {
/** Live device status keyed by device id (for the footer): set from the WS
* hello snapshot, then upserted per device on each device-status push. */
devices: Record<string, DeviceStatus>;
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
lanes: LaneStatus | null;
setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void;
@@ -30,6 +38,8 @@ interface LiveState {
setDevices: (list: DeviceStatus[]) => void;
/** Upsert one device's status (a device-status push). */
upsertDevice: (d: DeviceStatus) => void;
/** Set lane busy/free (WS hello + each lane-status push). */
setLanes: (l: LaneStatus) => void;
reset: () => void;
}
@@ -45,6 +55,7 @@ export const useLiveStore = create<LiveState>((set) => ({
occupancy: null,
feed: [],
devices: {},
lanes: null,
setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) =>
@@ -54,5 +65,6 @@ export const useLiveStore = create<LiveState>((set) => ({
})),
setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }),
setLanes: (lanes) => set({ lanes }),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
}));
+8 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore } from "./live-store.js";
import { useLiveStore, type LaneStatus } from "./live-store.js";
import { wsUrl } from "./origin.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
@@ -14,15 +14,16 @@ import { wsUrl } from "./origin.js";
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage =
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus };
| { kind: "device-status"; event: DeviceStatus }
| { kind: "lane-status"; lanes: LaneStatus };
export function useLiveFeed(): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
@@ -54,8 +55,11 @@ export function useLiveFeed(): void {
setOccupancy(msg.occupancy);
// Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices);
if (msg.lanes) setLanes(msg.lanes);
} else if (msg.kind === "device-status") {
upsertDevice(msg.event);
} else if (msg.kind === "lane-status") {
setLanes(msg.lanes);
} else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy);
pushEvent(msg.event);