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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user