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:
2026-07-04 18:41:06 +02:00
parent 094e963e5e
commit b4f1418858
10 changed files with 532 additions and 20 deletions
@@ -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);
});
});
+50 -10
View File
@@ -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}`),
);
}
+213
View File
@@ -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);
});
});
+4
View File
@@ -203,6 +203,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
void entryFlow.onInput(e);
});
app.addHook("onClose", async () => 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,
+80 -3
View File
@@ -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 {
+2
View File
@@ -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",
+2
View File
@@ -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",