e0b9442acc
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
131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
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 });
|
|
});
|
|
});
|