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:
@@ -12,7 +12,7 @@ import {
|
||||
} from "@parking/devices";
|
||||
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import type { DeviceInputEvent, LaneStatusEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
@@ -48,6 +48,14 @@ import type { VisionClient } from "./vision-client.js";
|
||||
// input edges to track presence + "armed" per relay.
|
||||
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
||||
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
||||
// When a loop IS wired the cooldown still runs as a BACKSTOP behind it: a motion
|
||||
// radar can drop a STATIONARY car (no doppler return) and spuriously re-arm, and the
|
||||
// cooldown bounds how fast that re-armed press can mint a second ticket.
|
||||
// - CAMERA (when an entry camera is configured): a press is live only while the entry
|
||||
// lane camera confirms a vehicle — the button lamp's SOLID state (button-light.ts).
|
||||
// A radar false-positive (rain, a pedestrian) blinks the lamp but prints nothing.
|
||||
// Camera-less sites keep the radar-only gate; a faulty camera is dropped via the
|
||||
// admin bypass (wiki/concepts/entry-presence-bypass.md).
|
||||
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
||||
// See wiki/concepts/entry-double-press.md.
|
||||
|
||||
@@ -76,6 +84,10 @@ export class EntryFlow {
|
||||
readonly #guard = new Map<string, RelayGuardState>();
|
||||
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
|
||||
readonly #vision: VisionClient | null;
|
||||
/** Live entry-lane camera state (LaneStatus mirror, fed by onLaneStatus). Gates the
|
||||
* physical press when an entry camera is configured — advisory sensor, but here it
|
||||
* only ever SUPPRESSES a reprint; it never opens a barrier or traps a car. */
|
||||
#entryBusy = false;
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
||||
this.#db = db;
|
||||
@@ -125,6 +137,12 @@ export class EntryFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/** Track the entry lane's camera state (wired to deviceEvents.onLaneStatus in
|
||||
* server.ts). LaneStatus emits on every flip, so this mirror stays current. */
|
||||
onLaneStatus(s: LaneStatusEvent): void {
|
||||
this.#entryBusy = s.entry;
|
||||
}
|
||||
|
||||
/** Stable per-relay key for the guard map. */
|
||||
#relayKey(r: ResolvedRelay): string {
|
||||
return `${r.controller.id}:${r.relay}`;
|
||||
@@ -157,22 +175,35 @@ export class EntryFlow {
|
||||
}
|
||||
|
||||
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
||||
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
|
||||
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
|
||||
* Three layered gates: CAMERA (when an entry camera is configured), PRESENCE
|
||||
* (when a loop is wired), and COOLDOWN — no longer alternatives: the cooldown
|
||||
* runs as a backstop BEHIND presence, because a motion radar can drop a
|
||||
* stationary car and spuriously re-arm one-car-one-ticket. */
|
||||
#suppressReason(r: ResolvedRelay): string | null {
|
||||
const s = this.#guardState(r);
|
||||
const bypass = this.#presenceBypass();
|
||||
|
||||
// CAMERA GATE — the lamp's blink-vs-solid rule, enforced at the press: with an entry
|
||||
// camera configured, a press is live only once the camera confirms a vehicle in the
|
||||
// entry zone (SOLID). Blink (radar-only — rain, a pedestrian, a reflection) prints
|
||||
// nothing. Only ever suppresses a ticket; never opens or traps (advisory rule kept).
|
||||
// A camera-less site skips this; a faulty camera is dropped via the admin bypass.
|
||||
if (!bypass.camera && !this.#entryBusy && this.#entryCameraConfigured()) {
|
||||
return "no camera-confirmed vehicle in the entry zone";
|
||||
}
|
||||
|
||||
// Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints.
|
||||
// We fall THROUGH to the cooldown backstop below (a dead loop can't re-arm one-car-one-
|
||||
// ticket, so the time cooldown is what stops a held button minting a burst). If no
|
||||
// cooldown is configured there's no anti-double-press left — that's the admin's accepted
|
||||
// tradeoff while bypassed. See wiki/concepts/entry-presence-bypass.md.
|
||||
if (typeof r.presenceInput === "number" && !this.#presenceBypass().radar) {
|
||||
// A dead loop can't re-arm one-car-one-ticket, so the cooldown below is what stops a
|
||||
// held button minting a burst. If no cooldown is configured there's no anti-double-press
|
||||
// left — that's the admin's accepted tradeoff while bypassed. See
|
||||
// wiki/concepts/entry-presence-bypass.md.
|
||||
if (typeof r.presenceInput === "number" && !bypass.radar) {
|
||||
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
||||
// ticket already issued for this still-present car).
|
||||
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
||||
if (!s.armed) return "ticket already issued for the car at the barrier";
|
||||
return null;
|
||||
// Fall THROUGH to the cooldown backstop: a presence-approved press can still be the
|
||||
// SAME stationary car after a radar dropout re-armed the guard.
|
||||
}
|
||||
|
||||
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
||||
@@ -185,6 +216,13 @@ export class EntryFlow {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Is at least one enabled camera bound to the entry lane? The camera gate applies only
|
||||
* then — a site with no entry camera keeps the radar-only press gate. Read live (like
|
||||
* the bypass flags) so adding/removing a camera needs no restart. */
|
||||
#entryCameraConfigured(): boolean {
|
||||
return devicesByDirection(this.#db, "camera", "entry").length > 0;
|
||||
}
|
||||
|
||||
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
||||
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
||||
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
||||
@@ -452,7 +490,9 @@ export class EntryFlow {
|
||||
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
||||
* held car is exactly when the operator wants the photo. */
|
||||
#fireSnapshot(direction: "entry", identity: string): void {
|
||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch(
|
||||
// `log` lets the ANPR ride-along flag a duplicate-plate entry (a signed anomaly) —
|
||||
// still fire-and-forget; recognition never gates the open. See snapshot.ts.
|
||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision, log: this.#log }).catch(
|
||||
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user