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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user