fix(entry): enforce the camera press-gate + duplicate-ticket defenses
Field report (park-buzi): a BLINKING entry button still printed — the lamp encoded blink-vs-solid (radar-only vs radar+camera) but #suppressReason only checked the radar, so a radar false-positive (rain, pedestrian) minted a real signed ticket. Three layered fixes: 1. CAMERA gate on the physical press: with an entry camera configured, a press is live only in the lamp's SOLID state (LaneStatus.entry busy, mirrored into EntryFlow via onLaneStatus). Suppress-only — the camera stays advisory (never opens, never traps). Camera-less sites keep the radar-only gate; a faulty camera is dropped via the existing bypassPresenceCamera admin toggle. 2. Cooldown as a REAL backstop behind presence: the presence branch returned early, so entryCooldownSec was dead wherever a loop was wired. Now it bounds the stationary-car double-ticket (a motion radar drops a motionless car → spurious loop-clear re-arms one-car-one-ticket → same car reprints). 3. Post-hoc duplicate-plate anomaly (entry-side twin of plateSwapSuspected): when entry ANPR recognizes a plate already OPEN under another session entered within ENTRY_DUP_PLATE_WINDOW_MIN (default 15 min), sign ONE entry.duplicatePlate anomaly naming both tickets for the operator to void. ANPR stays non-blocking (rides the post-open snapshot as before). REJECTED: camera-vetoed re-arm (defer re-arm until the lane flips free). The camera has no leave events — "free" is a ~30s silence timeout that never lapses inside a queue, so every queued car after the first would be suppressed until an operator intervened. Blocking legit entry at peak beats nothing; the proper preventive fix is a pass-through sensor (passedInput) — recorded as open in wiki/concepts/entry-double-press.md. Also: setup.relayTest reason was missing from both web catalogs (parity is only enforced sq<->en, so the build passed) — added. Tests: entry-press-gate.test.ts (blink suppresses / solid prints / camera-less unaffected / bypass honored / cooldown catches the dropout re-press / residual risk documented / still-present re-press stays suppressed) + entry-duplicate-plate.test.ts (flags open dup, ignores closed/stale/self/other plates). Suite 258 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||
import { and, eq, gte, sessions, deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||
import { reasonPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
|
||||
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
||||
@@ -79,6 +81,10 @@ interface SnapshotJob {
|
||||
/** Optional vision client — when present, ANPR runs on each captured image from an
|
||||
* `anpr`-enabled camera and records the plate against `identity`. Advisory only. */
|
||||
readonly vision?: VisionClient | null;
|
||||
/** Optional signed ledger — when present (the transient ENTRY path passes it), a
|
||||
* recognized entry plate that is already OPEN under another recent session signs an
|
||||
* `entry.duplicatePlate` anomaly (same car, second ticket). Post-hoc; never a gate. */
|
||||
readonly log?: EventLog | null;
|
||||
}
|
||||
|
||||
/** Camera config flag opting it into snapshot-triggered ANPR. */
|
||||
@@ -93,7 +99,7 @@ interface CameraConfig {
|
||||
* The caller must NOT block its open path on this.
|
||||
*/
|
||||
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||
const { db, direction, identity, logger, vision } = job;
|
||||
const { db, direction, identity, logger, vision, log } = job;
|
||||
const rows = devicesByDirection(db, "camera", direction);
|
||||
if (rows.length === 0) return Promise.resolve([]);
|
||||
|
||||
@@ -129,7 +135,7 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||
// ANPR off the SAME image, tied to the SAME session — when vision is enabled
|
||||
// and this camera opts in. Fire-and-forget: never delays the open path.
|
||||
if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) {
|
||||
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger);
|
||||
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger, log);
|
||||
}
|
||||
return id;
|
||||
} catch (err) {
|
||||
@@ -157,6 +163,7 @@ async function recognizePlate(
|
||||
snapshotId: string,
|
||||
shot: { bytes: Buffer; contentType: string },
|
||||
logger: FastifyBaseLogger,
|
||||
log?: EventLog | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||
@@ -187,11 +194,81 @@ async function recognizePlate(
|
||||
// The session's entry/exit event already shipped without this (async) plate — tell the
|
||||
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
|
||||
deviceEvents.emitPlateRecognized({ identity, plate, direction });
|
||||
|
||||
// ENTRY-SIDE duplicate check: this plate already OPEN under another recent session is
|
||||
// most likely the SAME car that minted a second ticket (a motion radar drops a
|
||||
// stationary car → the button re-arms). Signed anomaly for the operator to void.
|
||||
if (direction === "entry" && log) {
|
||||
await flagDuplicateEntryPlate({ db, log, identity, plate, snapshotId, logger });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
||||
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
||||
* re-pressing within minutes; a long window would flag legit re-visits. */
|
||||
function dupPlateWindowMs(): number {
|
||||
const raw = Number(process.env.ENTRY_DUP_PLATE_WINDOW_MIN ?? 15);
|
||||
return (Number.isFinite(raw) && raw > 0 ? raw : 15) * 60_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag a freshly-recognized ENTRY plate that is already open under a DIFFERENT recent
|
||||
* session: sign ONE `entry.duplicatePlate` anomaly keyed to the new session, pointing at
|
||||
* the prior one. Mirrors the exit-side plateSwapSuspected pattern (advisory, post-hoc —
|
||||
* the barrier already opened; the operator voids the duplicate ticket). Exported for tests.
|
||||
*/
|
||||
export async function flagDuplicateEntryPlate(opts: {
|
||||
db: Db;
|
||||
log: EventLog;
|
||||
/** The session the plate was just recognized for (the NEW ticket). */
|
||||
identity: string;
|
||||
plate: string;
|
||||
snapshotId: string;
|
||||
logger: FastifyBaseLogger;
|
||||
}): Promise<void> {
|
||||
const { db, log, identity, plate, snapshotId, logger } = opts;
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - dupPlateWindowMs()).toISOString();
|
||||
// Recent entry-plate reads (unsigned `kind:"read"` telemetry, written above) for the
|
||||
// same plate under a different identity. detail is JSON — filter in JS; read volume
|
||||
// inside the window is tiny (one row per entry).
|
||||
const reads = db
|
||||
.select()
|
||||
.from(deviceEventsTable)
|
||||
.where(and(eq(deviceEventsTable.kind, "read"), gte(deviceEventsTable.occurredAt, cutoff)))
|
||||
.all();
|
||||
const prior = reads
|
||||
.map((r) => r.detail as { identity?: string; direction?: string; plate?: string })
|
||||
.find((d) => d.direction === "entry" && d.plate === plate && d.identity && d.identity !== identity);
|
||||
if (!prior?.identity) return;
|
||||
// Only a still-OPEN prior session is a duplicate suspect (a closed one drove off).
|
||||
const open = db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.id, prior.identity), eq(sessions.state, "open")))
|
||||
.get();
|
||||
if (!open) return;
|
||||
await log.append({
|
||||
type: "anomaly",
|
||||
identity,
|
||||
payload: {
|
||||
...reasonPayload("entry.duplicatePlate", { plate, otherIdentity: prior.identity }),
|
||||
duplicateEntrySuspected: true,
|
||||
plate,
|
||||
otherIdentity: prior.identity,
|
||||
snapshotId,
|
||||
},
|
||||
});
|
||||
logger.warn(`duplicate entry suspected: plate ${plate} on ${identity} already open under ${prior.identity}`);
|
||||
} catch (err) {
|
||||
// Best-effort, post-hoc — never let the duplicate check surface on the open path.
|
||||
logger.error(`duplicate-plate check failed (${identity}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
|
||||
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
|
||||
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
||||
|
||||
Reference in New Issue
Block a user