a2bdf99db2
Controlled in/out test on the camera: the `active` re-fire rate is MOVEMENT-driven, not steady — ~1-3s apart while the car moves, but up to ~15-25s when it sits MOTIONLESS in the zone. A 5s TTL would flicker a parked car free; the TTL must exceed the still-car gap. The camera has ~no dwell lag (goes silent within ~1s of the car leaving — measured: last event 16:15:17 vs car-left ~16:15:30), so 30s keeps a motionless car busy while clearing promptly after departure. This also confirms vision-based tracking isn't warranted: the camera's leave signal is already tight. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
104 lines
4.1 KiB
TypeScript
104 lines
4.1 KiB
TypeScript
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 so a still-present car keeps the
|
|
* lane busy. MEASURED on the test unit (controlled in/out test): the re-fire rate is
|
|
* MOVEMENT-driven, not a fixed rate — ~1-3s apart while the car moves, but stretching
|
|
* to ~15-25s when it sits MOTIONLESS in the zone. So the TTL must clear the still-car
|
|
* gap (~25s) or a parked car flickers free. The camera has ~no dwell lag (it goes
|
|
* silent within a second of the car leaving), so 30s clears promptly after departure
|
|
* while keeping a motionless car solidly busy. Override with LANE_BUSY_TTL_MS. */
|
|
export function busyTtlMs(): number {
|
|
const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 30_000);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : 30_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);
|
|
}
|
|
}
|