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:
@@ -76,6 +76,16 @@ export interface DeviceStatusEvent {
|
|||||||
readonly checkedAt: string; // ISO-8601
|
readonly checkedAt: string; // ISO-8601
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lane occupancy from a camera's vehicle detection — a per-direction "busy/free"
|
||||||
|
* the booth shows as barrier lights. ADVISORY ONLY: a detection is a hint, never a
|
||||||
|
* gate (it never blocks a ticket or opens a barrier). "busy" is set by a vehicle
|
||||||
|
* `active` event; it auto-clears to "free" after a timeout (this camera class sends
|
||||||
|
* no leave/`inactive` signal — see wiki/entities/lpr-camera.md). */
|
||||||
|
export interface LaneStatusEvent {
|
||||||
|
readonly entry: boolean; // true = busy (a vehicle is at the entry vicinity)
|
||||||
|
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
||||||
|
}
|
||||||
|
|
||||||
class DeviceEventBus extends EventEmitter {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -128,6 +138,16 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("ledger", cb);
|
this.on("ledger", cb);
|
||||||
return () => this.off("ledger", cb);
|
return () => this.off("ledger", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted whenever a lane's busy/free state CHANGES (from camera vehicle
|
||||||
|
* detection). Drives the booth's barrier lights. Advisory only. */
|
||||||
|
emitLaneStatus(event: LaneStatusEvent): void {
|
||||||
|
this.emit("lane-status", event);
|
||||||
|
}
|
||||||
|
onLaneStatus(cb: (event: LaneStatusEvent) => void): () => void {
|
||||||
|
this.on("lane-status", cb);
|
||||||
|
return () => this.off("lane-status", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LaneStatus } from "./lane-status.js";
|
||||||
|
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// LaneStatus: a camera's vehicle detection marks its bound lane busy, then auto-clears
|
||||||
|
// after a timeout (this camera class sends no leave signal). Advisory; emits a
|
||||||
|
// lane-status change only when the busy/free state actually flips.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Seed a controller (relay 1=entry, 2=exit, 3=both) + a camera bound to the relay
|
||||||
|
* whose direction we want, so directionOf resolves from the real bound relay. */
|
||||||
|
function seedCamera(direction: "entry" | "exit" | "both"): string {
|
||||||
|
const controllerId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: controllerId,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
{ relay: 3, direction: "both" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const relay = direction === "entry" ? 1 : direction === "exit" ? 2 : 3;
|
||||||
|
const camId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: camId,
|
||||||
|
category: "camera",
|
||||||
|
driverId: "hikvision",
|
||||||
|
config: { host: "10.0.0.9", controllerId, relay },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
return camId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture lane-status events emitted during `fn`. */
|
||||||
|
function captureEmits(fn: () => void): LaneStatusEvent[] {
|
||||||
|
const got: LaneStatusEvent[] = [];
|
||||||
|
const off = deviceEvents.onLaneStatus((e) => got.push(e));
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return got;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LaneStatus", () => {
|
||||||
|
it("marks the camera's bound lane busy on a vehicle detection, free until then", () => {
|
||||||
|
const cam = seedCamera("entry");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
|
||||||
|
|
||||||
|
const emits = captureEmits(() => lane.vehicleDetected(cam));
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: true, exit: false });
|
||||||
|
expect(emits).toEqual([{ entry: true, exit: false }]); // emitted on the flip
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-clears to free after the TTL (no leave signal from the camera)", () => {
|
||||||
|
const cam = seedCamera("entry");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
expect(lane.snapshot().entry).toBe(true);
|
||||||
|
|
||||||
|
const emits = captureEmits(() => vi.advanceTimersByTime(90_001));
|
||||||
|
expect(lane.snapshot().entry).toBe(false);
|
||||||
|
expect(emits).toEqual([{ entry: false, exit: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-arms the timer on each detection (a parked car keeps the lane busy)", () => {
|
||||||
|
const cam = seedCamera("entry");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
// Re-fire just before the TTL — should NOT clear, and should push the clear out.
|
||||||
|
vi.advanceTimersByTime(80_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
vi.advanceTimersByTime(80_000); // 160s total, but only 80s since the last detect
|
||||||
|
expect(lane.snapshot().entry).toBe(true);
|
||||||
|
// Now let it lapse fully.
|
||||||
|
vi.advanceTimersByTime(90_001);
|
||||||
|
expect(lane.snapshot().entry).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT re-emit on a repeat detection while already busy (only state flips)", () => {
|
||||||
|
const cam = seedCamera("entry");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam); // flip -> emits
|
||||||
|
const emits = captureEmits(() => {
|
||||||
|
lane.vehicleDetected(cam); // already busy -> no emit
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
});
|
||||||
|
expect(emits).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 'both'-direction camera marks BOTH lanes busy", () => {
|
||||||
|
const cam = seedCamera("both");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: true, exit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exit camera marks only the exit lane", () => {
|
||||||
|
const cam = seedCamera("exit");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: false, exit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unknown device id", () => {
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected("nope");
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { directionOf } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Lane busy/free, driven by a camera's vehicle detection. ADVISORY ONLY — a detection
|
||||||
|
// is a hint the booth shows as barrier lights; it never gates a ticket or opens a
|
||||||
|
// barrier (see wiki/entities/lpr-camera.md, the advisory-only rule).
|
||||||
|
//
|
||||||
|
// A vehicle `active` event on a camera bound to entry/exit marks THAT lane busy and
|
||||||
|
// (re)arms an auto-clear timer. This camera class sends NO leave/`inactive` signal, so
|
||||||
|
// "free" is timeout-driven: the camera re-fires `active` while a car sits in the zone
|
||||||
|
// (each refreshing the timer); once the car leaves, the actives stop and the lane
|
||||||
|
// flips free after BUSY_TTL_MS. A "both"-direction camera marks BOTH lanes.
|
||||||
|
|
||||||
|
/** How long after the last vehicle detection a lane stays "busy" before clearing.
|
||||||
|
* Must exceed the camera's `active` re-fire interval (observed ~30-80s on the test
|
||||||
|
* unit) so a parked car keeps the lane busy. Override with LANE_BUSY_TTL_MS. */
|
||||||
|
export function busyTtlMs(): number {
|
||||||
|
const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 90_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 90_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LaneStatus {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #ttlMs: number;
|
||||||
|
#entry = false;
|
||||||
|
#exit = false;
|
||||||
|
#entryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
#exitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, ttlMs = busyTtlMs()) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#ttlMs = ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot (for the WS hello). */
|
||||||
|
snapshot(): LaneStatusEvent {
|
||||||
|
return { entry: this.#entry, exit: this.#exit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vehicle was detected by camera `deviceId`. Resolves the camera's bound direction
|
||||||
|
* and marks that lane busy + (re)arms its auto-clear. Best-effort: an unknown camera
|
||||||
|
* or a non-vehicle caller is the caller's concern — this only handles a confirmed
|
||||||
|
* vehicle detection. Emits a lane-status change only when the state actually flips.
|
||||||
|
*/
|
||||||
|
vehicleDetected(deviceId: string): void {
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
|
if (!row) return;
|
||||||
|
const dir = directionOf(this.#db, row);
|
||||||
|
if (dir === "entry" || dir === "both") this.#mark("entry");
|
||||||
|
if (dir === "exit" || dir === "both") this.#mark("exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#mark(lane: "entry" | "exit"): void {
|
||||||
|
const was = lane === "entry" ? this.#entry : this.#exit;
|
||||||
|
if (lane === "entry") this.#entry = true;
|
||||||
|
else this.#exit = true;
|
||||||
|
|
||||||
|
// (Re)arm the auto-clear — each detection pushes the free-flip further out.
|
||||||
|
const existing = lane === "entry" ? this.#entryTimer : this.#exitTimer;
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
const timer = setTimeout(() => this.#clear(lane), this.#ttlMs);
|
||||||
|
timer.unref?.(); // never hold the process open
|
||||||
|
if (lane === "entry") this.#entryTimer = timer;
|
||||||
|
else this.#exitTimer = timer;
|
||||||
|
|
||||||
|
if (!was) {
|
||||||
|
this.#logger.info(`lane-status: ${lane} -> busy`);
|
||||||
|
this.#emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear(lane: "entry" | "exit"): void {
|
||||||
|
if (lane === "entry") {
|
||||||
|
this.#entry = false;
|
||||||
|
this.#entryTimer = null;
|
||||||
|
} else {
|
||||||
|
this.#exit = false;
|
||||||
|
this.#exitTimer = null;
|
||||||
|
}
|
||||||
|
this.#logger.info(`lane-status: ${lane} -> free`);
|
||||||
|
this.#emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
#emit(): void {
|
||||||
|
deviceEvents.emitLaneStatus(this.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear timers on shutdown. */
|
||||||
|
stop(): void {
|
||||||
|
if (this.#entryTimer) clearTimeout(this.#entryTimer);
|
||||||
|
if (this.#exitTimer) clearTimeout(this.#exitTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db
|
|||||||
import { deviceEvents } from "../device-events.js";
|
import { deviceEvents } from "../device-events.js";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { verifyDigest } from "../digest-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 →
|
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
|
||||||
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
|
// 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. */
|
* payload" — the raw body is always stored so nothing is lost. */
|
||||||
interface AlarmSummary {
|
interface AlarmSummary {
|
||||||
eventType?: string;
|
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;
|
target?: string;
|
||||||
plate?: string;
|
plate?: string;
|
||||||
dateTime?: string;
|
dateTime?: string;
|
||||||
@@ -79,6 +83,7 @@ function pick(s: string, re: RegExp): string | undefined {
|
|||||||
function summarize(body: string): AlarmSummary {
|
function summarize(body: string): AlarmSummary {
|
||||||
return {
|
return {
|
||||||
eventType: pick(body, /<eventType>([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i),
|
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:
|
target:
|
||||||
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
|
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
|
||||||
pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/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,
|
// 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
|
// 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
|
// 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.
|
// Loud log so the operator can SEE the payload during testing.
|
||||||
app.log.info(
|
app.log.info(
|
||||||
`[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` +
|
`[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 });
|
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
|
// 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
|
// "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.
|
// 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,
|
contentType: (d.contentType as string) ?? null,
|
||||||
bytes: (d.bytes as number) ?? 0,
|
bytes: (d.bytes as number) ?? 0,
|
||||||
eventType: (d.eventType as string) ?? null,
|
eventType: (d.eventType as string) ?? null,
|
||||||
|
eventState: (d.eventState as string) ?? null,
|
||||||
target: (d.target as string) ?? null,
|
target: (d.target as string) ?? null,
|
||||||
plate: (d.plate as string) ?? null,
|
plate: (d.plate as string) ?? null,
|
||||||
rawHead: (d.rawHead 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 { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
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 { enrichEvent } from "../event-enrich.js";
|
||||||
import type { DeviceMonitor } from "../device-monitor.js";
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
import { getOccupancy } from "../occupancy.js";
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
// 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 =
|
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: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { 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(
|
app.get(
|
||||||
"/api/ws",
|
"/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:
|
// Initial snapshot so the client renders immediately, before any event:
|
||||||
// occupancy AND the current device-status set (for the footer).
|
// 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
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
// 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) => {
|
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
||||||
send({ kind: "device-status", 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", () => {
|
socket.on("close", () => {
|
||||||
offLedger();
|
offLedger();
|
||||||
offPrinter();
|
offPrinter();
|
||||||
offDevice();
|
offDevice();
|
||||||
|
offLane();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { userRoutes } from "./routes/users.js";
|
|||||||
import { roleRoutes } from "./routes/roles.js";
|
import { roleRoutes } from "./routes/roles.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||||
|
import { LaneStatus } from "./lane-status.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { reportRoutes } from "./routes/reports.js";
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
import { recycleBinRoutes } from "./routes/recycle-bin.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).
|
// the device's lane_devices config (written on assign).
|
||||||
await deviceRoutes(app, db);
|
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
|
// Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on
|
||||||
// each detected target (vehicle). Source-IP guarded + optional Digest; records the raw
|
// 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.
|
// payload as a `kind:"alarm"` device_event AND drives lane busy/free for vehicles.
|
||||||
await hikvisionAlarmRoutes(app, db);
|
// See routes/hikvision-alarm.ts.
|
||||||
|
await hikvisionAlarmRoutes(app, db, laneStatus);
|
||||||
|
|
||||||
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
|
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
|
||||||
// pushes changes to the booth UI. setupRoutes() has already registered the
|
// 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
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
// 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.
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
await snapshotRoutes(app, db);
|
await snapshotRoutes(app, db);
|
||||||
|
|||||||
@@ -112,6 +112,44 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
|
||||||
|
* from camera detection). Advisory only; it gates nothing. */
|
||||||
|
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
|
||||||
|
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
|
||||||
|
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="5" y1="21" x2="5" y2="9" />
|
||||||
|
<line x1="5" y1="10" x2="21" y2="6" />
|
||||||
|
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
|
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
|
||||||
|
{busy ? "●" : "○"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
|
||||||
|
function LaneIndicators() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const lanes = useLiveStore((s) => s.lanes);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
|
||||||
|
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BoothScreen() {
|
export function BoothScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
|
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
|
||||||
@@ -198,10 +236,16 @@ export function BoothScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||||
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
{/* Ticket input spans both columns at the top — the operator's primary action.
|
||||||
|
The lane barrier lights sit beside it (live vehicle-detection busy/free). */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Panel title={t("booth.processTicket")}>
|
<Panel title={t("booth.processTicket")}>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="min-w-[260px] flex-1">
|
||||||
<TicketInput onSubmit={setActiveTicket} />
|
<TicketInput onSubmit={setActiveTicket} />
|
||||||
|
</div>
|
||||||
|
<LaneIndicators />
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const en: Catalog = {
|
|||||||
booth: {
|
booth: {
|
||||||
processTicket: "Process ticket",
|
processTicket: "Process ticket",
|
||||||
scanPlaceholder: "Scan or type ticket number…",
|
scanPlaceholder: "Scan or type ticket number…",
|
||||||
|
laneEntry: "Entry",
|
||||||
|
laneExit: "Exit",
|
||||||
open: "Open",
|
open: "Open",
|
||||||
occupancy: "Occupancy",
|
occupancy: "Occupancy",
|
||||||
occUnavailable: "occupancy unavailable",
|
occUnavailable: "occupancy unavailable",
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export const sq = {
|
|||||||
booth: {
|
booth: {
|
||||||
processTicket: "Proceso biletën",
|
processTicket: "Proceso biletën",
|
||||||
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||||
|
laneEntry: "Hyrje",
|
||||||
|
laneExit: "Dalje",
|
||||||
open: "Hap",
|
open: "Hap",
|
||||||
occupancy: "Prania",
|
occupancy: "Prania",
|
||||||
occUnavailable: "zënia e padisponueshme",
|
occUnavailable: "zënia e padisponueshme",
|
||||||
|
|||||||
@@ -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. */
|
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
|
||||||
export type WsStatus = "connecting" | "open" | "closed";
|
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
|
/** 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. */
|
* unbounded — the full history is always available via the /api/events query. */
|
||||||
const MAX_FEED = 200;
|
const MAX_FEED = 200;
|
||||||
@@ -23,6 +29,8 @@ interface LiveState {
|
|||||||
/** Live device status keyed by device id (for the footer): set from the WS
|
/** 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. */
|
* hello snapshot, then upserted per device on each device-status push. */
|
||||||
devices: Record<string, DeviceStatus>;
|
devices: Record<string, DeviceStatus>;
|
||||||
|
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
||||||
|
lanes: LaneStatus | null;
|
||||||
setStatus: (s: WsStatus) => void;
|
setStatus: (s: WsStatus) => void;
|
||||||
setOccupancy: (o: Occupancy) => void;
|
setOccupancy: (o: Occupancy) => void;
|
||||||
pushEvent: (e: LedgerEvent) => void;
|
pushEvent: (e: LedgerEvent) => void;
|
||||||
@@ -30,6 +38,8 @@ interface LiveState {
|
|||||||
setDevices: (list: DeviceStatus[]) => void;
|
setDevices: (list: DeviceStatus[]) => void;
|
||||||
/** Upsert one device's status (a device-status push). */
|
/** Upsert one device's status (a device-status push). */
|
||||||
upsertDevice: (d: DeviceStatus) => void;
|
upsertDevice: (d: DeviceStatus) => void;
|
||||||
|
/** Set lane busy/free (WS hello + each lane-status push). */
|
||||||
|
setLanes: (l: LaneStatus) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +55,7 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
occupancy: null,
|
occupancy: null,
|
||||||
feed: [],
|
feed: [],
|
||||||
devices: {},
|
devices: {},
|
||||||
|
lanes: null,
|
||||||
setStatus: (status) => set({ status }),
|
setStatus: (status) => set({ status }),
|
||||||
setOccupancy: (occupancy) => set({ occupancy }),
|
setOccupancy: (occupancy) => set({ occupancy }),
|
||||||
pushEvent: (e) =>
|
pushEvent: (e) =>
|
||||||
@@ -54,5 +65,6 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
})),
|
})),
|
||||||
setDevices: (list) => set({ devices: byId(list) }),
|
setDevices: (list) => set({ devices: byId(list) }),
|
||||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||||
import { qk } from "./query.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";
|
import { wsUrl } from "./origin.js";
|
||||||
|
|
||||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
// 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). */
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
|
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
|
||||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus };
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
|
| { kind: "lane-status"; lanes: LaneStatus };
|
||||||
|
|
||||||
|
|
||||||
export function useLiveFeed(): void {
|
export function useLiveFeed(): void {
|
||||||
const qc = useQueryClient();
|
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
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<WebSocket | null>(null);
|
||||||
@@ -54,8 +55,11 @@ export function useLiveFeed(): void {
|
|||||||
setOccupancy(msg.occupancy);
|
setOccupancy(msg.occupancy);
|
||||||
// Initial device-status snapshot for the footer.
|
// Initial device-status snapshot for the footer.
|
||||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||||
|
if (msg.lanes) setLanes(msg.lanes);
|
||||||
} else if (msg.kind === "device-status") {
|
} else if (msg.kind === "device-status") {
|
||||||
upsertDevice(msg.event);
|
upsertDevice(msg.event);
|
||||||
|
} else if (msg.kind === "lane-status") {
|
||||||
|
setLanes(msg.lanes);
|
||||||
} else if (msg.kind === "ledger") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
|
|||||||
Reference in New Issue
Block a user