diff --git a/apps/server/src/entry-duplicate-plate.test.ts b/apps/server/src/entry-duplicate-plate.test.ts new file mode 100644 index 0000000..ceed037 --- /dev/null +++ b/apps/server/src/entry-duplicate-plate.test.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { deviceEvents as deviceEventsTable, ledgerEvents, sessions, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { flagDuplicateEntryPlate } from "./snapshot.js"; +import { makeLog, silentLogger } from "./test-helpers.js"; +import type { EventLog } from "./event-log.js"; + +// Entry-side duplicate-plate reconciliation (2026-07-04): when ANPR recognizes a plate on +// a fresh transient entry and that plate is already OPEN under another RECENT session, +// the same car most likely minted a second ticket (a motion radar dropped the stationary +// car → the button re-armed). We sign ONE entry.duplicatePlate anomaly for the operator +// to void. Post-hoc + advisory: recognition never gates the (already-open) barrier — +// exactly the non-blocking role the plate can play here. + +let db: Db; +let log: EventLog; + +const PLATE = "AA111BB"; +const OLD = "11111111111"; +const NEW = "22222222222"; + +beforeEach(() => { + ({ db } = createTestDb()); + log = makeLog(db); +}); + +/** Seed the prior entry's unsigned plate-read telemetry (what recognizePlate records). */ +function seedPriorRead(opts: { identity?: string; plate?: string; direction?: string; agoMs?: number } = {}) { + db.insert(deviceEventsTable).values({ + id: randomUUID(), + deviceId: "cam-entry", + category: "camera", + kind: "read", + detail: { + identity: opts.identity ?? OLD, + direction: opts.direction ?? "entry", + plate: opts.plate ?? PLATE, + snapshotId: "snap-old", + source: "entry-exit-snapshot", + }, + occurredAt: new Date(Date.now() - (opts.agoMs ?? 60_000)).toISOString(), + }).run(); +} + +function seedSession(id: string, state: "open" | "closed") { + db.insert(sessions).values({ + id, + identity: id, + source: "ticket", + enteredAt: new Date(Date.now() - 60_000).toISOString(), + state, + }).run(); +} + +const flag = () => + flagDuplicateEntryPlate({ db, log, identity: NEW, plate: PLATE, snapshotId: "snap-new", logger: silentLogger() }); + +const anomalies = () => + db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly"); + +describe("flagDuplicateEntryPlate", () => { + it("same plate OPEN under another recent session → signs ONE entry.duplicatePlate anomaly", async () => { + seedPriorRead(); + seedSession(OLD, "open"); + await flag(); + expect(anomalies()).toHaveLength(1); + const a = anomalies()[0]; + expect(a.identity).toBe(NEW); // keyed to the NEW (suspect) ticket + expect(a.payload).toMatchObject({ + reasonCode: "entry.duplicatePlate", + duplicateEntrySuspected: true, + plate: PLATE, + otherIdentity: OLD, + snapshotId: "snap-new", + }); + }); + + it("prior session already CLOSED → no anomaly (that car drove off; a re-visit is legit)", async () => { + seedPriorRead(); + seedSession(OLD, "closed"); + await flag(); + expect(anomalies()).toHaveLength(0); + }); + + it("prior read outside the window → no anomaly (stale coincidence, not a double press)", async () => { + seedPriorRead({ agoMs: 30 * 60_000 }); // beyond the 15-min default window + seedSession(OLD, "open"); + await flag(); + expect(anomalies()).toHaveLength(0); + }); + + it("own read (same identity) never flags itself", async () => { + seedPriorRead({ identity: NEW }); + seedSession(NEW, "open"); + await flag(); + expect(anomalies()).toHaveLength(0); + }); + + it("different plate / exit-side reads are ignored", async () => { + seedPriorRead({ plate: "ZZ999ZZ" }); + seedPriorRead({ direction: "exit" }); + seedSession(OLD, "open"); + await flag(); + expect(anomalies()).toHaveLength(0); + }); +}); diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 2c6f2ee..93fa2bc 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -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(); /** 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}`), ); } diff --git a/apps/server/src/entry-press-gate.test.ts b/apps/server/src/entry-press-gate.test.ts new file mode 100644 index 0000000..c755d55 --- /dev/null +++ b/apps/server/src/entry-press-gate.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { devices, siteConfig, ledgerEvents, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { registry, type PrinterDevice } from "@parking/devices"; +import { EntryFlow } from "./entry-flow.js"; +import { makeLog, silentLogger } from "./test-helpers.js"; + +// The PHYSICAL entry button's press gate (#suppressReason), layered (2026-07-04): +// CAMERA — with an entry camera configured, a press is live only while the entry lane +// camera confirms a vehicle (the button lamp's SOLID state). Blink (radar-only) prints +// nothing. Camera-less sites skip this; the admin camera bypass drops it. +// PRESENCE — one-car-one-ticket off the loop (unchanged). +// COOLDOWN — now a BACKSTOP behind presence, not an alternative: a motion radar drops a +// stationary car (no doppler return), spuriously re-arming the guard; the cooldown bounds +// how fast that re-armed press can mint a second ticket for the same car. +// A suppressed press is unsigned telemetry (entrySuppressed), never a ledger anomaly. + +let db: Db; +let flow: EntryFlow; + +const CTL = "ctl-entry"; +const BUTTON_INPUT = 1; +const PRESENCE_INPUT = 2; + +// A no-op printer that always succeeds, so the happy path reaches the signed +// vehicle_entry (the real drivers need hardware). Registered once (registry is global). +const noopPrinter: PrinterDevice = { + driverId: "test-printer-ok", + connect: async () => {}, + disconnect: async () => {}, + healthCheck: async () => ({ status: "ready" as const }), + printTicket: async () => {}, + printReport: async () => {}, + printSubscriptionCard: async () => {}, + printReceipt: async () => {}, + printWindowChargeNotice: async () => {}, +}; +if (!registry.get("test-printer-ok")) { + registry.register({ + id: "test-printer-ok", + category: "printer", + label: "Test printer", + description: "always-succeeds stub for tests", + transports: [], + configFields: [], + create: () => noopPrinter, + }); +} + +beforeEach(() => { + ({ db } = createTestDb()); + db.insert(devices).values({ + id: CTL, + category: "access", + driverId: "stub-access", + config: { + relays: [{ relay: 1, direction: "entry" }], + inputs: [ + { input: BUTTON_INPUT, role: "button", relay: 1 }, + { input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" }, + ], + }, + enabled: true, + }).run(); + db.insert(devices).values({ + id: "printer-entry", + category: "printer", + driverId: "test-printer-ok", + config: { direction: "entry" }, + enabled: true, + }).run(); + flow = new EntryFlow(db, makeLog(db), silentLogger()); +}); + +/** Add an entry camera row. The driver never builds (unknown id) — only its EXISTENCE + * matters to the press gate; snapshot capture failing is the normal fire-and-forget path. */ +function addEntryCamera() { + db.insert(devices).values({ + id: "cam-entry", + category: "camera", + driverId: "no-such-camera-driver", + config: { direction: "entry" }, + enabled: true, + }).run(); +} + +function setCameraBypass(on: boolean) { + db.insert(siteConfig) + .values({ id: 1, bypassPresenceCamera: on }) + .onConflictDoUpdate({ target: siteConfig.id, set: { bypassPresenceCamera: on } }) + .run(); +} + +async function edge(input: number, edge: "on" | "off") { + await flow.onInput({ + driverId: "stub-access", + deviceId: CTL, + input, + edge, + at: new Date().toISOString(), + source: "poll", + }); +} + +const press = () => edge(BUTTON_INPUT, "on"); +const radar = (present: boolean) => edge(PRESENCE_INPUT, present ? "on" : "off"); + +const entries = () => + db.select().from(ledgerEvents).all().filter((r) => r.type === "vehicle_entry"); +const suppressed = () => + db.select().from(deviceEventsTable).all() + .map((r) => r.detail as { entrySuppressed?: boolean; reason?: string }) + .filter((d) => d.entrySuppressed === true); + +describe("entry press gate — camera (blink vs solid)", () => { + it("BLINK state (radar present, no camera confirmation) → press suppressed, nothing signed", async () => { + addEntryCamera(); + await radar(true); // lamp would blink: radar sees something, camera does not + await press(); + expect(entries()).toHaveLength(0); + expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // no anomaly either — telemetry only + expect(suppressed()).toHaveLength(1); + expect(suppressed()[0].reason).toMatch(/camera/); + }); + + it("SOLID state (radar present + camera busy) → press prints and signs a vehicle_entry", async () => { + addEntryCamera(); + await radar(true); + flow.onLaneStatus({ entry: true, exit: false }); // camera confirms → SOLID + await press(); + expect(entries()).toHaveLength(1); + expect(suppressed()).toHaveLength(0); + }); + + it("camera-less site → the camera gate does not apply (radar-only, as before)", async () => { + await radar(true); // no camera row; lane state irrelevant + await press(); + expect(entries()).toHaveLength(1); + }); + + it("camera bypassed (faulty camera) → press prints without camera confirmation", async () => { + addEntryCamera(); + setCameraBypass(true); + await radar(true); + await press(); + expect(entries()).toHaveLength(1); + }); + + it("no car at all (radar clear too) → suppressed even with the camera bypassed", async () => { + addEntryCamera(); + setCameraBypass(true); + await press(); // radar never went on + expect(entries()).toHaveLength(0); + expect(suppressed()[0].reason).toMatch(/presence loop clear/); + }); +}); + +describe("entry press gate — cooldown backstop behind presence", () => { + /** Same lane but the button carries a cooldown, making it a backstop behind the loop. */ + function setButtonCooldown(sec: number) { + db.delete(devices).run(); + db.insert(devices).values({ + id: CTL, + category: "access", + driverId: "stub-access", + config: { + relays: [{ relay: 1, direction: "entry" }], + inputs: [ + { input: BUTTON_INPUT, role: "button", relay: 1, cooldownSec: sec }, + { input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" }, + ], + }, + enabled: true, + }).run(); + db.insert(devices).values({ + id: "printer-entry", + category: "printer", + driverId: "test-printer-ok", + config: { direction: "entry" }, + enabled: true, + }).run(); + } + + it("radar dropout re-arm + quick re-press → caught by the cooldown (one ticket)", async () => { + setButtonCooldown(60); + await radar(true); + await press(); // ticket 1 (no camera configured — radar-only site) + expect(entries()).toHaveLength(1); + // The motion radar loses the STATIONARY car and re-fires: off (re-arms!) then on. + await radar(false); + await radar(true); + await press(); // presence gate says yes (present + re-armed) — the backstop must catch it + expect(entries()).toHaveLength(1); + expect(suppressed().some((d) => /cooldown/.test(d.reason ?? ""))).toBe(true); + }); + + it("without a cooldown the dropout re-press mints a second ticket (the documented residual risk)", async () => { + await radar(true); + await press(); + await radar(false); + await radar(true); + await press(); + expect(entries()).toHaveLength(2); + }); + + it("still-present car re-pressing (no dropout) stays suppressed by one-car-one-ticket", async () => { + await radar(true); + await press(); + await press(); // car never left the loop → not re-armed + expect(entries()).toHaveLength(1); + expect(suppressed().some((d) => /already issued/.test(d.reason ?? ""))).toBe(true); + }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 014c962..056c56c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -203,6 +203,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise unsubscribeEntry()); + // The camera press-gate: the entry flow mirrors the entry lane's camera state so a + // physical press is live only in the lamp's SOLID state (see entry-flow.ts). + const unsubscribeEntryLane = deviceEvents.onLaneStatus((s) => entryFlow.onLaneStatus(s)); + app.addHook("onClose", async () => unsubscribeEntryLane()); // Button-light indicator: drives the entry button's lamp on a spare relay from the // RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera, diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts index bd07572..a9b0ce6 100644 --- a/apps/server/src/snapshot.ts +++ b/apps/server/src/snapshot.ts @@ -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 { - 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 { // 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 { 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 { + 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 { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 4b3e016..a608ad9 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -282,6 +282,7 @@ export const en: Catalog = { "entry.held.noTicket": "Entry held — ticket not printed: {{detail}}", "entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)", "entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry", + "entry.duplicatePlate": "Possible duplicate entry — plate {{plate}} is already inside under ticket {{otherIdentity}}", "exit.refused.closed": "Exit refused — session already closed", "exit.refused.noSession": "Exit refused — unknown ticket", "exit.refused.unpaid": "Exit refused — not paid (take payment first)", @@ -299,6 +300,7 @@ export const en: Catalog = { "sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)", "sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth", "void.ticketCancelled": "Ticket cancelled — {{reason}}", + "setup.relayTest": "Relay test — admin {{operator}} pulsed relay {{relay}} on controller {{controller}} from Setup", }, tariff: { title: "Tariff", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index cee4505..864c4cb 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -285,6 +285,7 @@ export const sq = { "entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}", "entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)", "entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja", + "entry.duplicatePlate": "Hyrje e dyfishtë e mundshme — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}", "exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë", "exit.refused.noSession": "Dalja u refuzua — biletë e panjohur", "exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)", @@ -302,6 +303,7 @@ export const sq = { "sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)", "sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë", "void.ticketCancelled": "Bileta u anulua — {{reason}}", + "setup.relayTest": "Test releje — admini {{operator}} aktivizoi relenë {{relay}} te kontrolluesi {{controller}} nga Konfigurimi", }, tariff: { title: "Tarifa", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 10fd3b0..98f31d2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -374,6 +374,11 @@ export const REASON_CODES = [ // vehicle presence (radar + camera). See wiki/concepts/operator-issued-entry.md. "entry.operatorIssued", "entry.issue.noPresence", + // entry-side plate reconciliation: the plate recognized on a fresh transient entry is + // already OPEN under another recent session — likely the SAME car minting a second + // ticket (e.g. a motion radar dropped the stationary car and re-armed the button). + // Post-hoc + advisory (ANPR never gates); the operator voids the duplicate. + "entry.duplicatePlate", // exit refusals "exit.refused.closed", "exit.refused.noSession", @@ -421,6 +426,7 @@ export const REASON_EN: Record = { "entry.held.noTicket": "entry held — ticket not printed: {detail}", "entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)", "entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry", + "entry.duplicatePlate": "possible duplicate entry — plate {plate} is already inside under ticket {otherIdentity}", "exit.refused.closed": "exit refused — session already closed", "exit.refused.noSession": "exit refused — no open session for ticket", "exit.refused.unpaid": "exit refused — not paid (take payment first)", diff --git a/wiki/concepts/entry-double-press.md b/wiki/concepts/entry-double-press.md index 5898ca2..c44994f 100644 --- a/wiki/concepts/entry-double-press.md +++ b/wiki/concepts/entry-double-press.md @@ -2,7 +2,7 @@ type: concept tags: [parking, entry, anti-fraud, safety, devices] sources: [] -updated: 2026-06-19 +updated: 2026-07-04 status: open --- @@ -53,7 +53,42 @@ car to pull through, short enough not to block the next legitimate car). It is a mitigation, not a guarantee** — a determined abuser can wait it out. Use it only where presence feedback isn't available; prefer wiring a loop. -The two can coexist (presence first, cooldown as a backstop), but presence is authoritative when set. +The two **coexist** (2026-07-04): presence is checked first, and the cooldown now genuinely runs as +a **backstop behind it** (before that date the presence branch returned early and the cooldown never +ran when a loop was wired). The backstop exists because of the **stationary-car radar dropout**: a +motion (doppler) radar loses a car that stops moving — the flow sees a spurious loop-clear, re-arms +one-car-one-ticket, and the *same* car's next press mints a second ticket. A configured cooldown +bounds how fast that can happen. Keep it short (~10–15 s): in presence mode it only fires on a +press the loop already approved, which includes the *next legitimate car in a queue*. + +### CAMERA gate (2026-07-04 — when an entry camera is configured) +The [[button-light-indicator]] lamp always encoded the intended UX — **blink** = radar-only +(something in the zone, no confirmed car), **solid** = radar + camera agree — but the press handler +only ever checked the radar, so a blinking button still printed (radar false-positives: rain, a +pedestrian, reflections). Now the press gate enforces the lamp's rule: with an entry camera +configured, a press is live **only while the entry lane's camera confirms a vehicle** +([[lane-presence-and-anpr-entry|LaneStatus]] busy — the SOLID state). This keeps the camera +**advisory** in the safety sense: it only ever *suppresses a ticket*, never opens a barrier and +never traps a car. A camera-less site keeps the radar-only gate; a faulty camera is dropped via the +admin [[entry-presence-bypass]] (`bypassPresenceCamera`), same as the operator-issued path. + +### REJECTED: camera-vetoed re-arm (the queue trap) +The obvious fix for the stationary-car dropout — *don't re-arm on loop-clear while the camera still +sees a car; re-arm when the lane flips free* — was designed and **rejected** (2026-07-04). The +entry camera sends no leave events; "free" is a ~30 s detection-silence timeout. In any queue the +next car occupies the zone before that timeout can lapse, so the lane never flips free between two +legitimate cars — every queued car after the first would be suppressed until an operator intervened. +Blocking legitimate entry at peak load is strictly worse than the occasional duplicate ticket. The +duplicate is handled **post-hoc** instead: + +### Duplicate-plate reconciliation (post-hoc, entry-side twin of [[plate-reconciliation]]) +ANPR already rides the entry snapshot (never blocking — the barrier is open before recognition +starts). When the recognized plate is **already OPEN under another session entered within the last +~15 min** (`ENTRY_DUP_PLATE_WINDOW_MIN`), the flow signs ONE `entry.duplicatePlate` **anomaly** +pointing at both tickets; the operator voids the duplicate. Window short on purpose — a closed +session or an old read is a legit re-visit, not a double press. The proper *preventive* fix is a +**pass-through sensor** (closing loop / photocell past the barrier) as an unambiguous "a car went +through" signal — noted below as open. ## A suppressed press is a NO-OP, not an anomaly @@ -82,17 +117,27 @@ in telemetry if ever needed. through `inputsOf`, carry `presenceInput`/`entryCooldownSec` onto the `ResolvedRelay`, and only ever gate entry/both relays. An exit radar = a `presence` row on the exit relay. - `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge` - tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the - telemetry. The guard disarms + stamps the cooldown on **print success** (not on open). + tracks the loop, `#suppressReason` decides camera → presence → cooldown (in that order), + `#recordSuppressedPress` writes the telemetry. The guard disarms + stamps the cooldown on + **print success** (not on open). The camera state is a live `LaneStatus` mirror + (`onLaneStatus`, wired in `server.ts`); "an entry camera is configured" is read per press so + adding/removing one needs no restart. The duplicate-plate check is + `flagDuplicateEntryPlate` in `snapshot.ts`, called from the ANPR ride-along when the entry + flow passes its `EventLog`. - [[first-run-setup|SetupWizard]] relay editor: entry/both relays expose a **Presence loop (terminal)** field and, when no loop is set, a **Cooldown after ticket (s)** field (localized sq+en — see [[i18n]]). ## Open -- **No automated test yet** (the standing harness gap) — verify on hardware: with a loop, a held - button issues one ticket; after the car clears the loop a new car gets a fresh one. Without a loop, - a cooldown blocks the repeat and the suppressed press lands in telemetry. +- ~~No automated test yet~~ **Covered 2026-07-04**: `entry-press-gate.test.ts` pins the camera + gate (blink suppresses / solid prints / camera-less unaffected / bypass honored), the cooldown + backstop behind presence, and one-car-one-ticket; `entry-duplicate-plate.test.ts` pins the + post-hoc plate anomaly. Hardware verification on park-buzi still worthwhile. +- **Pass-through sensor** (closing loop / photocell past the barrier, a `passedInput` role): the + only *unambiguous* "the car went through" signal. Would let re-arm key on actual passage instead + of loop-clear, killing the stationary-car dropout without the queue trap. Procurement + wiring + question for the lanes. - **Exit side:** the symmetric concern (re-reading a ticket at exit) is already handled differently — exit validates against an open session, so a second read finds the session closed (no double-exit). No presence gate needed there today. diff --git a/wiki/log.md b/wiki/log.md index f7f0b19..68c5aa5 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2207,3 +2207,19 @@ all interfaces; plus correctness/hygiene (cwd-relative `.env`, `/health` always- swallowing failures, unbounded `min_confidence`). Reassurance recorded: a forged image can't open a barrier (server re-gates at 0.85 + debounce), content-type isn't trusted, non-root, `.env` not baked into the image. Nothing fixed yet — this is the backlog to work from. + +## [2026-07-04] update | Entry press gate: camera enforced, cooldown backstop, duplicate-plate anomaly + +Field report from park-buzi: a BLINKING entry button (radar-only, no camera confirmation) still +printed a ticket — the [[button-light-indicator]] encoded blink-vs-solid but `#suppressReason` +only ever checked the radar. Fixed in [[entry-double-press]]: (1) CAMERA gate on the physical +press — live only in the lamp's SOLID state when an entry camera is configured; honors +[[entry-presence-bypass]]; suppress-only, so the camera stays advisory; (2) cooldown now a REAL +backstop behind presence (the old code returned early, so `entryCooldownSec` was dead in presence +mode) — bounds the stationary-car motion-radar-dropout double-ticket; (3) post-hoc +`entry.duplicatePlate` signed anomaly when the recognized entry plate is already open under a +recent session (entry-side twin of [[plate-reconciliation]]; ANPR stays non-blocking). REJECTED +along the way: camera-vetoed re-arm (defer re-arm until the lane flips free) — the camera's ~30 s +silence-timeout "free" never fires inside a queue, so it would suppress every queued car after the +first. Proper preventive fix noted open: a pass-through sensor (`passedInput`). 13 new tests +(`entry-press-gate.test.ts`, `entry-duplicate-plate.test.ts`); suite 258 green.