Merge branch 'dev' into stage
Build & push images / images (push) Failing after 41s

This commit is contained in:
2026-07-04 19:35:42 +02:00
22 changed files with 1217 additions and 34 deletions
+7
View File
@@ -24,6 +24,13 @@ export interface DeviceReadEvent {
readonly deviceId: string; // devices id of the reader/scanner/camera
readonly value: string; // the ticket id / plate / card number
readonly kind: "ticket" | "plate" | "qr" | "card";
/** The CONFIRMED physical channel the value arrived on, when the reader tags it
* (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by
* the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader
* with no prefixes configured (channel unknown — flows must not assume). Lets the
* subscription match refuse an OPTICAL decode claiming an RF credential (a printed
* copy of a card's UID must not clone the card). */
readonly channel?: "optical" | "rf";
readonly at: string; // ISO-8601
}
@@ -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);
});
});
@@ -0,0 +1,103 @@
import { randomUUID } from "node:crypto";
import { beforeEach, describe, expect, it } from "vitest";
import { devices, deviceEvents as deviceEventsTable, ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { ReadDispatcher } from "./read-dispatch.js";
import { ExitFlow } from "./exit-flow.js";
import { SubscriptionFlow } from "./subscription-flow.js";
import type { DeviceReadEvent } from "./device-events.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// STRUCTURAL FILTER at the dispatcher (2026-07-04): a reader value that matched
// nothing AND can't possibly be a credential we issued (no ticket Luhn shape, no
// SUB-/SUBSESS- prefix, not a confirmed-RF read) is refused with UNSIGNED telemetry
// instead of reaching the exit flow and signing a noSession anomaly. Born from the
// park-buzi phantom optical decodes: red "who is exiting?" rows for NOBODY train the
// operator to ignore the signed feed. Anything plausibly ours STILL signs normally.
let db: Db;
let dispatcher: ReadDispatcher;
const READER = "reader-exit";
beforeEach(() => {
({ db } = createTestDb());
db.insert(devices).values({
id: "ctl-exit",
category: "access",
driverId: "stub-access",
config: { relays: [{ relay: 1, direction: "exit" }] },
enabled: true,
}).run();
db.insert(devices).values({
id: READER,
category: "reader",
driverId: "dingtian-qr-reader",
config: { serial: "H05MA5B0", direction: "exit" },
enabled: true,
}).run();
const log = makeLog(db);
dispatcher = new ReadDispatcher(db, new ExitFlow(db, log, silentLogger()), new SubscriptionFlow(db, log, silentLogger()), silentLogger());
});
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
return {
driverId: "dingtian-qr-reader",
deviceId: READER,
value,
kind: opts.kind ?? "qr",
...(opts.channel ? { channel: opts.channel } : {}),
at: new Date().toISOString(),
};
}
const ledger = () => db.select().from(ledgerEvents).all();
const unrecognized = () =>
db.select().from(deviceEventsTable).all()
.map((r) => r.detail as { unrecognizedRead?: boolean; value?: string })
.filter((d) => d.unrecognizedRead === true);
describe("read-dispatch structural filter", () => {
it("phantom 6-digit optical decode → refused, telemetry only, NOTHING signed", async () => {
const out = await dispatcher.dispatch(read("999459", { channel: "optical" }));
expect(out.accepted).toBe(false);
expect(out.reason).toMatch(/unrecognized/);
expect(ledger()).toHaveLength(0); // the whole point: no red row in the feed
expect(unrecognized()).toHaveLength(1);
expect(unrecognized()[0].value).toBe("999459");
});
it("legacy untagged garbage ('C') → filtered too (works before prefixes are deployed)", async () => {
const out = await dispatcher.dispatch(read("C"));
expect(out.accepted).toBe(false);
expect(ledger()).toHaveLength(0);
expect(unrecognized()).toHaveLength(1);
});
it("Luhn-valid unknown ticket → NOT filtered: the exit flow signs the noSession anomaly", async () => {
const out = await dispatcher.dispatch(read("00000000000")); // valid shape, no session
expect(out.accepted).toBe(false);
expect(unrecognized()).toHaveLength(0);
const anomalies = ledger().filter((r) => r.type === "anomaly");
expect(anomalies.length).toBeGreaterThan(0); // a real probe stays in the signed feed
});
it("unknown card on a CONFIRMED RF channel → NOT filtered (a physical card is a real event)", async () => {
await dispatcher.dispatch(read("1A86A158", { kind: "card", channel: "rf" }));
expect(unrecognized()).toHaveLength(0);
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
});
it("unknown SUB- code → NOT filtered (our own prefix = an interesting probe)", async () => {
await dispatcher.dispatch(read("SUB-DOESNOTEXIST", { channel: "optical" }));
expect(unrecognized()).toHaveLength(0);
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
});
it("an ENROLLED credential is matched BEFORE the filter (never hidden by it)", async () => {
// A card UID that would fail every shape test — enrolled, so it must still match.
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: "sub-1", kind: "rf", value: "999459" }).run();
await dispatcher.dispatch(read("999459")); // legacy untagged read of it
expect(unrecognized()).toHaveLength(0); // reached the subscription flow, not the filter
});
});
+68 -1
View File
@@ -1,7 +1,9 @@
import { devices, eq, type Db } from "@parking/db";
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.js";
import { validateTicketCode } from "./entry-flow.js";
import type { SubscriptionFlow } from "./subscription-flow.js";
import { relayForDevice } from "./device-resolve.js";
@@ -17,6 +19,22 @@ import { relayForDevice } from "./device-resolve.js";
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
// reader the exit side; "both" defers to the flow's own inference (subscription:
// session state; transient: exit).
//
// STRUCTURAL FILTER (2026-07-04, operator-requested). The DT-008's scan engine
// false-decodes sunlight stripe patterns into short garbage codes (phantom reads —
// see wiki/entities/dingtian-dt008-reader.md), and each one was reaching the exit
// flow and signing an exit.refused.noSession anomaly: red "who is trying to exit?"
// rows for NOBODY, training the operator to ignore the feed (alarm fatigue is the
// adversary's friend). So a reader value that matched nothing AND cannot possibly be
// a credential we issued is dropped to UNSIGNED telemetry (device_events, still
// auditable) instead of the signed ledger. "Possibly ours" stays deliberately wide —
// any of these still reaches the flows and signs the normal refusal anomaly:
// - a Luhn-valid ticket shape (validateTicketCode — a forged/expired ticket is a
// real probe),
// - our issued-code prefixes (SUB- / SUBSESS-),
// - ANY read on a CONFIRMED RF channel (a physically present card, enrolled or
// not, is a real event — RF is never sun noise),
// - plates (different population; never shape-filtered here).
export class ReadDispatcher {
readonly #db: Db;
@@ -45,6 +63,20 @@ export class ReadDispatcher {
if (sub) {
return this.#subscription.run(resolved, e, sub);
}
// Matched nothing — if the value can't even BE one of ours, it's scanner noise
// (phantom optical decode): refuse with unsigned telemetry, keep the signed feed
// for events that involve an actual credential or an actual card.
if ((e.kind === "qr" || e.kind === "card" || e.kind === "ticket") && !plausibleCredential(e)) {
this.#recordUnrecognized(e);
this.#logger.info(`read filtered (not a credential shape): '${e.value}' from ${e.deviceId}${e.channel ? ` ch=${e.channel}` : ""}`);
return {
accepted: false,
direction: resolved.direction === "entry" ? "entry" : "exit",
reason: "unrecognized code (no credential shape — telemetry only)",
};
}
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
// transient exit (transient entry is the button flow, not a reader), so reject+log
// rather than treat an entry scan as an exit.
@@ -53,4 +85,39 @@ export class ReadDispatcher {
}
return this.#exit.handleAt(resolved, e);
}
/** Unsigned telemetry for a filtered read — auditable in device_events, out of the
* signed feed. Mirrors the entry flow's suppressed-press pattern. */
#recordUnrecognized(e: DeviceReadEvent): void {
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: e.deviceId,
category: "reader",
kind: "read",
detail: {
unrecognizedRead: true,
value: e.value,
readKind: e.kind,
...(e.channel ? { channel: e.channel } : {}),
reason: "no credential shape (phantom decode / garbage scan)",
},
occurredAt: e.at,
})
.run();
} catch (err) {
this.#logger.error(`unrecognized-read telemetry insert failed: ${(err as Error).message}`);
}
}
}
/** Could this reader value possibly be a credential WE issued (or a real card)?
* Deliberately WIDE — only shapes that can't be anything of ours are filtered. */
function plausibleCredential(e: DeviceReadEvent): boolean {
if (e.channel === "rf") return true; // a physically present card — never sun noise
if (validateTicketCode(e.value)) return true; // ticket shape (10–14 digits + Luhn)
if (/^SUB(SESS)?-/.test(e.value)) return true; // our subscription QR / window-slip ids
return false;
}
@@ -0,0 +1,101 @@
import Fastify from "fastify";
import { beforeEach, afterEach, describe, expect, it } from "vitest";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { qrReaderRoutes, splitChannel } from "./qr-reader.js";
import { CredentialCapture } from "../credential-capture.js";
import type { DeviceReadEvent, ReadOutcome } from "../device-events.js";
import type { ReadDispatcher } from "../read-dispatch.js";
// CHANNEL TAGGING (2026-07-04): the DT-008's "QRCode Output Prefix" / "Card Output
// Prefix" (vendor tool) mark which engine produced a push — Q: = optical, K: = RF.
// The route strips the prefix, tags the read's confirmed channel, and enrollment
// capture stores the BARE value. Unprefixed reads stay the legacy untagged shape so
// an unconfigured reader keeps working. These tests pin the route-side contract;
// the match-side enforcement is pinned in ../subscription-channel.test.ts.
const SERIAL = "H05MA5B0";
const READER_ID = "reader-exit";
let db: Db;
let app: ReturnType<typeof Fastify>;
let capture: CredentialCapture;
let seen: DeviceReadEvent[];
/** Dispatcher stub: records the event the route built, always rejects. */
const fakeDispatcher = {
dispatch: async (e: DeviceReadEvent): Promise<ReadOutcome> => {
seen.push(e);
return { accepted: false, reason: "test" };
},
} as unknown as ReadDispatcher;
beforeEach(async () => {
({ db } = createTestDb());
db.insert(devices).values({
id: READER_ID,
category: "reader",
driverId: "dingtian-qr-reader",
config: { serial: SERIAL },
enabled: true,
}).run();
seen = [];
capture = new CredentialCapture();
app = Fastify({ logger: false });
await qrReaderRoutes(app as never, db, fakeDispatcher, capture);
});
afterEach(async () => {
await app.close();
});
const scan = (cardid: string) =>
app.inject({ method: "GET", url: `/qa/mcardsea.php?cardid=${encodeURIComponent(cardid)}&cjihao=${SERIAL}&mjihao=1&status=10` });
describe("splitChannel", () => {
it("K: prefix → bare value, kind card, channel rf", () => {
expect(splitChannel("K:86A158")).toEqual({ value: "86A158", kind: "card", channel: "rf" });
});
it("Q: prefix → bare value, kind qr, channel optical", () => {
expect(splitChannel("Q:12345678901")).toEqual({ value: "12345678901", kind: "qr", channel: "optical" });
});
it("no prefix → value untouched, legacy untagged qr", () => {
expect(splitChannel("86A158")).toEqual({ value: "86A158", kind: "qr" });
});
});
describe("qr-reader route channel tagging", () => {
it("card-prefixed push dispatches a stripped, rf-tagged read", async () => {
const res = await scan("K:86A158");
expect(res.statusCode).toBe(200);
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ value: "86A158", kind: "card", channel: "rf", deviceId: READER_ID });
});
it("qr-prefixed push dispatches a stripped, optical-tagged read", async () => {
await scan("Q:00000000000");
expect(seen[0]).toMatchObject({ value: "00000000000", kind: "qr", channel: "optical" });
});
it("unprefixed push stays legacy: kind qr, no channel", async () => {
await scan("86A158");
expect(seen[0]).toMatchObject({ value: "86A158", kind: "qr" });
expect(seen[0].channel).toBeUndefined();
});
it("a bare prefix (empty value after strip) dispatches nothing", async () => {
await scan("K:");
expect(seen).toHaveLength(0);
});
it("enrollment capture stores the BARE value, not the prefixed one", async () => {
capture.arm(READER_ID);
const res = await scan("K:86A158");
expect(seen).toHaveLength(0); // intercepted — never dispatched to the access flow
const state = capture.state();
expect(state.status).toBe("captured");
if (state.status === "captured") expect(state.value).toBe("86A158");
// Beeps "ok" so the operator knows the card was read.
expect(res.json().data[0].status).toBe(1);
});
});
+42 -9
View File
@@ -27,6 +27,36 @@ interface ReaderQuery {
time?: string;
}
// ── CHANNEL TAGGING (2026-07-04) ────────────────────────────────────────────────
// The DT-008 push carries one opaque `cardid` whether its OPTICAL engine decoded a
// QR/barcode or its RF engine read a card — the server can't tell them apart. That
// enabled a cheap clone: print a card's UID (often written on the card face) as a
// barcode and the optical decode matches the RF credential. Fix: the vendor tool's
// "QRCode Output Prefix" / "Card Output Prefix" are set to the markers below on every
// reader; the route strips the prefix and tags the read's confirmed channel, and the
// subscription match refuses a channel-mismatched credential. A read with NO prefix
// stays the legacy untagged shape (kind "qr", channel undefined) so an unconfigured
// reader keeps working — the enforcement only bites where prefixes are deployed.
// ⚠️ Prefixes must MATCH the vendor tool; also FREEZE "Card Input format" (6H) — that
// setting defines the UID shape we enroll. See wiki/entities/dingtian-dt008-reader.md.
const QR_CHANNEL_PREFIX = process.env.READER_QR_PREFIX ?? "Q:";
const CARD_CHANNEL_PREFIX = process.env.READER_CARD_PREFIX ?? "K:";
/** Split a raw pushed `cardid` into its bare value + confirmed channel (if prefixed). */
export function splitChannel(raw: string): {
value: string;
kind: "qr" | "card";
channel?: "optical" | "rf";
} {
if (CARD_CHANNEL_PREFIX.length > 0 && raw.startsWith(CARD_CHANNEL_PREFIX)) {
return { value: raw.slice(CARD_CHANNEL_PREFIX.length), kind: "card", channel: "rf" };
}
if (QR_CHANNEL_PREFIX.length > 0 && raw.startsWith(QR_CHANNEL_PREFIX)) {
return { value: raw.slice(QR_CHANNEL_PREFIX.length), kind: "qr", channel: "optical" };
}
return { value: raw, kind: "qr" }; // legacy: unprefixed reader, channel unknown
}
export async function qrReaderRoutes(
app: FastifyInstance,
db: Db,
@@ -55,6 +85,7 @@ export async function qrReaderRoutes(
// See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md.
reply.header("connection", "close");
const cardid = (q.cardid ?? "").trim();
const scan = splitChannel(cardid); // bare value + confirmed channel (if prefixed)
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
const serial = (q.cjihao ?? "").trim();
@@ -65,35 +96,37 @@ export async function qrReaderRoutes(
const deviceId = matchedRowId ?? serial;
let accepted = false;
if (cardid) {
if (scan.value) {
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
// value for the subscription form and do NOT run the access flow (we must not
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
// Reads from the OTHER reader are untouched and dispatch normally below.
if (capture.tryConsume(deviceId, cardid)) {
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
// Captured BARE (prefix stripped) so enrolled values match future stripped reads.
if (capture.tryConsume(deviceId, scan.value)) {
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""}`);
accepted = true; // beep "ok" so the operator knows the card was read
} else {
const read: DeviceReadEvent = {
driverId: "dingtian-qr-reader",
deviceId,
value: cardid,
kind: "qr",
value: scan.value,
kind: scan.kind,
...(scan.channel ? { channel: scan.channel } : {}),
at: new Date().toISOString(),
};
try {
const outcome = await dispatcher.dispatch(read);
accepted = outcome.accepted;
// Per-read diagnostic: which reader (serial) sent it, which configured device
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
// the logs (e.g. an entry-side scan resolving to the exit relay).
// it mapped to, the confirmed channel (if prefixed), and the verdict — so a
// barrier/serial mismatch or a channel anomaly is visible in the logs.
app.log.info(
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
`card=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
);
} catch (err) {
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
app.log.error(`QR dispatch failed for ${scan.value}: ${(err as Error).message}`);
}
}
}
+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 {
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { SubscriptionFlow } from "./subscription-flow.js";
import type { DeviceReadEvent } from "./device-events.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// CHANNEL AGREEMENT in SubscriptionFlow.match (2026-07-04): when the reader CONFIRMED
// the physical channel (DT-008 output prefixes → DeviceReadEvent.channel), the
// credential kind must agree. An OPTICAL decode claiming an RF credential is the
// cheap clone (print the card's UID as a barcode) — refused + ONE signed anomaly.
// Legacy untagged reads (channel undefined) match as before, so readers without
// prefixes keep working.
let db: Db;
let flow: SubscriptionFlow;
const SUB = "sub-1";
const CARD_UID = "86A158";
const QR_CODE = "SUB-TESTQR";
beforeEach(() => {
({ db } = createTestDb());
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "rf", value: CARD_UID }).run();
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "qr", value: QR_CODE }).run();
flow = new SubscriptionFlow(db, makeLog(db), silentLogger());
});
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
return {
driverId: "dingtian-qr-reader",
deviceId: "reader-1",
value,
kind: opts.kind ?? "qr",
...(opts.channel ? { channel: opts.channel } : {}),
at: new Date().toISOString(),
};
}
const anomalies = () =>
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
describe("subscription match — credential channel agreement", () => {
it("OPTICAL read of an RF card's UID → no match + signed channelMismatch anomaly (the clone)", async () => {
const m = flow.match(read(CARD_UID, { kind: "qr", channel: "optical" }));
expect(m).toBeNull();
await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); // append is fire-and-forget
expect(anomalies()[0].identity).toBe(SUB);
expect(anomalies()[0].payload).toMatchObject({
reasonCode: "sub.refused.channelMismatch",
channelMismatch: true,
credentialKind: "rf",
channel: "optical",
value: CARD_UID,
});
});
it("RF read of the same card → matches (via card), nothing signed", () => {
const m = flow.match(read(CARD_UID, { kind: "card", channel: "rf" }));
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
expect(anomalies()).toHaveLength(0);
});
it("legacy untagged read of the card → still matches (unprefixed readers keep working)", () => {
const m = flow.match(read(CARD_UID)); // kind qr, channel undefined — today's shape
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
expect(anomalies()).toHaveLength(0);
});
it("OPTICAL read of a QR credential → matches (the legit path)", () => {
const m = flow.match(read(QR_CODE, { kind: "qr", channel: "optical" }));
expect(m).toMatchObject({ subscriptionId: SUB, via: "qr" });
});
it("RF read claiming a QR credential → refused symmetrically (mis-encoded clone tag)", async () => {
const m = flow.match(read(QR_CODE, { kind: "card", channel: "rf" }));
expect(m).toBeNull();
await vi.waitFor(() => expect(anomalies()).toHaveLength(1));
expect(anomalies()[0].payload).toMatchObject({ credentialKind: "qr", channel: "rf" });
});
it("unknown value → plain no-match, no anomaly (a phantom/typo is not a clone attempt)", () => {
const m = flow.match(read("999459", { kind: "qr", channel: "optical" }));
expect(m).toBeNull();
expect(anomalies()).toHaveLength(0);
});
});
+34
View File
@@ -77,6 +77,40 @@ export class SubscriptionFlow {
.where(eq(subscriptionCredentials.value, e.value))
.get();
if (cred) {
// CHANNEL AGREEMENT (clone defense, 2026-07-04). When the reader CONFIRMED the
// physical channel (DT-008 output prefixes), the credential kind must agree: an
// OPTICAL decode may not claim an RF credential — otherwise printing a card's
// UID (often written on the card face) as a barcode clones the card. Symmetric
// for an RF read claiming a QR credential (a mis-encoded clone tag). A legacy
// untagged read (channel undefined) matches as before — enforcement only bites
// where prefixes are deployed. The attempt itself is a fraud signal → signed
// anomaly, then treated as no-match (the flows refuse it as unknown).
const mismatch =
(e.channel === "optical" && cred.kind === "rf") ||
(e.channel === "rf" && cred.kind === "qr");
if (mismatch) {
this.#logger.warn(
`credential channel mismatch: ${cred.kind} credential '${e.value}' presented via ${e.channel} (sub ${cred.subscriptionId}) — possible clone`,
);
void this.#log
.append({
type: "anomaly",
identity: cred.subscriptionId,
payload: {
...reasonPayload("sub.refused.channelMismatch", {
credentialKind: cred.kind,
channel: e.channel === "optical" ? "optical" : "rf",
}),
channelMismatch: true,
credentialKind: cred.kind,
channel: e.channel,
value: e.value,
deviceId: e.deviceId,
},
})
.catch((err) => this.#logger.error(`channel-mismatch anomaly append failed: ${(err as Error).message}`));
return null;
}
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
+3
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)",
@@ -298,7 +299,9 @@ export const en: Catalog = {
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
"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",
"sub.refused.channelMismatch": "Credential refused — a {{credentialKind}} credential arrived via the {{channel}} channel (possible cloned credential)",
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
"setup.relayTest": "Relay test — admin {{operator}} pulsed relay {{relay}} on controller {{controller}} from Setup",
},
tariff: {
title: "Tariff",
+3
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)",
@@ -301,7 +302,9 @@ export const sq = {
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
"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ë",
"sub.refused.channelMismatch": "Kredenciali u refuzua — kredencial {{credentialKind}} i paraqitur në kanalin {{channel}} (kredencial i mundshëm i klonuar)",
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
"setup.relayTest": "Test releje — admini {{operator}} aktivizoi relenë {{relay}} te kontrolluesi {{controller}} nga Konfigurimi",
},
tariff: {
title: "Tarifa",
+11
View File
@@ -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",
@@ -400,6 +405,10 @@ export const REASON_CODES = [
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
"sub.refused.unpaidWindow",
// a credential value arrived on the WRONG physical channel (e.g. an RF card's UID
// presented as a printed barcode — a cloned-credential attempt). Channel comes from
// the reader's output prefixes; see wiki/entities/dingtian-dt008-reader.md.
"sub.refused.channelMismatch",
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
"void.ticketCancelled",
// an admin fired a barrier relay from Setup to test the wiring. The physical open is
@@ -421,6 +430,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
"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)",
@@ -437,6 +447,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
"sub.refused.channelMismatch": "credential refused — a {credentialKind} credential arrived via the {channel} channel (possible cloned credential)",
"void.ticketCancelled": "ticket cancelled — {reason}",
"setup.relayTest": "relay test — admin {operator} pulsed relay {relay} on controller {controller} from Setup",
};
+52 -7
View File
@@ -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.
+65
View File
@@ -0,0 +1,65 @@
---
type: concept
tags: [parking, entry, admin, devices, threat-model, presence]
sources: []
updated: 2026-07-04
status: settled
---
# Entry presence-gate bypass (faulty radar / faulty camera)
The transient entry gates require a **real vehicle** before a ticket can mint: the radar/loop
presence input and the camera vehicle-detection ([[entry-double-press]] for the physical button,
[[operator-issued-entry]] for the booth button). When one of those devices is **faulty** — a dead
radar, a camera that stopped pushing ([[g3h-anpr-push-gotchas|it happens]]) — the gate blocks
**legitimate** transient entry until support fixes the hardware. This feature lets the **admin**
drop a specific signal as a requirement until then. Built 2026-07-02 (migration `0020`).
## The principle: the admin is NOT the adversary
The [[threat-model]] adversary is the **booth operator**; the admin is the trusted party. So the
admin *may* weaken an anti-fraud gate — but weakening it stays **attributed and auditable**
(signed), because "trusted" never means "invisible" in this system. Compare
[[operator-issued-entry]], where the untrusted role gets capability + a red flag on every use;
here the trusted role gets a switch + a signed record of flipping it.
## Decisions (2026-07-02)
- **Granular, not a master switch.** One flag per signal: a faulty camera drops only the camera
check (radar still gates); a faulty radar drops only radar. Chosen over a single "bypass all"
toggle so a single broken device never silently disables the *other*, still-working gate.
- **Persists until turned OFF, and every flip is signed.** No auto-expiry (support visits are
unpredictable); instead each enable/disable appends a **`config_change`** ledger event
(`setting: entryPresenceBypass.<signal>`, `value`, `prev`, `operator`) — the append-only chain
records *who weakened the gate and when*, and the OFF transition too. A no-op write (same value)
signs nothing.
## What a bypass changes
- **Physical button** (`EntryFlow.#suppressReason`): camera bypassed → the camera press-gate is
skipped; radar bypassed → the presence-loop check is skipped and the press falls through to the
**cooldown backstop**. ⚠️ A dead loop can't re-arm one-car-one-ticket, so with radar bypassed
and **no `entryCooldownSec` configured there is no anti-double-press left** — the admin's
accepted tradeoff while bypassed (set a cooldown when bypassing radar).
- **Operator button** (`issueForOperator`): the bypassed signal is dropped as a requirement; a
refusal only happens when a **still-required** signal fails to confirm.
- **Auditability on every ticket:** a ticket minted under a bypass carries
`presenceBypassed: ["radar"|"camera", ...]` on its signed `vehicle_entry` (and refusal anomalies
record it too) — so reconciliation can always tell which entries happened under a weakened gate.
- **Booth lights** ([[booth-console]]): a bypassed signal renders as satisfied, so the operator's
button-enable logic matches the server's.
## As-built
- **Schema:** `site_config.bypass_presence_radar` / `bypass_presence_camera` (booleans, default
off = the normal both-required gate), migration `0020_entry_presence_bypass.sql`. Read **live**
per decision — a toggle needs no restart.
- **Endpoint:** `PUT /api/site-config/presence-bypass` (`routes/site.ts`), admin-only
(`site:update`), signs one `config_change` per actually-changed signal, 400 on non-boolean/empty.
- **UI:** SetupWizard "presence gate" panel (two checkboxes + an amber "active" warning) after the
controller section; `config_change` events render amber in the booth feed.
- **Tests:** `entry-presence-bypass.test.ts` (gate decisions under each combination),
`presence-bypass-route.test.ts` (RBAC, signing, no-op, validation), and
`entry-press-gate.test.ts` (camera bypass on the physical press).
- Fixing this surfaced a latent bug: `firstRelayByDirection` never attached `presenceInput`, so
the operator-issue radar check had always read "presence loop unavailable" (fixed 2026-07-02).
+6 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, booth, entry, threat-model, anpr, presence]
sources: []
updated: 2026-07-01
updated: 2026-07-04
status: settled
---
@@ -33,6 +33,11 @@ with phantom tickets, and (crucially) it guarantees the entry snapshot captures
what [[plate-reconciliation]] reads at exit. **No presence loop configured → the feature is
unavailable** at that site (we require both; no weaker camera-only fallback).
> **Bypass (2026-07-02):** when one of the two devices is FAULTY, the admin can drop that signal
> as a requirement via [[entry-presence-bypass]] (granular, signed `config_change`, persists till
> turned off). The gate then requires only the still-working signal; tickets minted under a bypass
> carry `presenceBypassed` on the signed entry, and refusal anomalies record it too.
**Enforced on BOTH sides.** The UI only enables the entry [[booth-console|BarrierLight]] as a clickable
issue-control when `radar.entry && lanes.entry` (both true) and the operator holds `session:create`.
The **server re-checks** current presence (`LaneStatus.snapshot().entry === true` AND the entry relay's
+48
View File
@@ -0,0 +1,48 @@
---
type: concept
tags: [parking, setup, devices, integrity, admin]
sources: []
updated: 2026-07-04
status: settled
---
# Setup relay test (signed, admin-only barrier pulse)
Commissioning a lane needs a way to prove **wiring**: does relay N on this controller actually
lift *this* barrier? Before this feature (built 2026-07-01) the only way was to fake a credential
or short an input — both of which pollute the flows they exercise. Now the Setup controller
section offers a per-relay **Test** button that pulses the relay directly.
## The design constraint: a test open must be SIGNED
The core anti-fraud rule ([[append-only-event-chain]]) is that **a physical barrier open with no
matching signed command is THE fraud signal**. An unsigned test pulse would therefore read as
fraud in any reconciliation of controller logs vs. ledger. So the test **signs a
`barrier_open_command` BEFORE the pulse fires** — same ordering invariant as every real open:
- `source: "manual"` (a deliberate human action, same as an operator barrier open),
`identity: relay-test:<controllerId>:<relay>`,
- payload: `reasonPayload("setup.relayTest", { operator, relay, controller })` + `relayTest: true`
so reconciliation and the feed can tell a test from an intervention.
- No `EventLog` available (boot ordering) → the endpoint refuses (503) rather than fire unsigned.
## Guardrails
- **Admin-only** (`site:update`) + CSRF — the operator (the [[threat-model]] adversary) cannot
pulse barriers from Setup.
- **Saved controllers only**, and only relays the saved config **declares** (unknown relay → 400,
unknown controller → 404, nothing signed on any refusal). No free-form "pulse anything" seam.
- **`radarAlert` (lamp) relays are excluded** in the UI — they are aux outputs, not barriers
([[button-light-indicator]]); the test drives `pulseOpen` only, so [[barrier-not-a-door]] holds
(intent-only; the barrier firmware owns the close).
- UI confirms before firing (`Test` → confirm dialog → pulse), per-relay buttons in the
controller assignment row.
## As-built
- Endpoint `POST /api/setup/test-relay` (`routes/setup.ts`) — validates, signs, then
`registry.create(...)` → `pulseOpen(relay)`. Feature-detects `pulseOpen` on the built device.
- UI: `RelayTester` in `SetupWizard.tsx` (access category only). Reason code `setup.relayTest`
in `@parking/shared` + both web catalogs ([[i18n]]).
- Tests: `setup-relay-test.test.ts` — RBAC 403, CSRF 403, signed command on success, 400/404
refusals sign nothing, bad relay value.
+63 -1
View File
@@ -2,7 +2,7 @@
type: entity
tags: [parking, hardware, readers, qr, dingtian]
sources: [dingtian-dt008]
updated: 2026-06-28
updated: 2026-07-04
status: open
---
@@ -120,6 +120,68 @@ host in the **vendor tool**; assign + enter its serial + bind it here.
- `output` is replied as `0` (Access). Confirm on hardware whether the reader needs `1`/`2` (WG26/34)
to drive its access line, vs. `0`.
## Channel tagging — output prefixes close the printed-card-clone hole (2026-07-04)
The push carries ONE opaque `cardid` whether the **optical** engine decoded a QR/barcode or the
**RF** engine read a card — the server couldn't tell. And `SubscriptionFlow.match` matched by
**value only** (the stored `rf`/`qr` kind was a label). Consequence: printing a card's UID (often
written on the card face, e.g. `86A158`) as a barcode and holding it up **cloned the RF card** —
the optical decode matched the RF credential and opened the barrier. In-threat-model and cheap.
**Fix (both sides):**
- **Reader (vendor tool, both units):** set `QRCode Output Prefix` = `Q:` and `Card Output
Prefix` = `K:` (env-overridable server-side: `READER_QR_PREFIX` / `READER_CARD_PREFIX`).
- **Server:** `routes/qr-reader.ts` strips the prefix and tags the read's confirmed channel
(`DeviceReadEvent.channel: "optical"|"rf"`; kind `qr`/`card`). Enrollment capture stores the
**bare** value. `SubscriptionFlow.match` then requires **channel agreement**: an optical read
may not claim an `rf` credential (and vice versa) — a mismatch is refused AND signs a
`sub.refused.channelMismatch` **anomaly** (a clone attempt is a fraud signal). An **unprefixed**
read keeps the legacy untagged shape and matches as before — enforcement only bites where
prefixes are deployed, so an unconfigured reader never breaks.
- Bonus: every `READ` log line now carries `ch=optical|rf`, which permanently attributes any
future phantom (see below) to its engine.
⚠️ **Two device-side settings are now part of the credential contract** (they live ON the reader,
not in our DB — re-apply after any factory reset/swap): the two **output prefixes** (must match
the server's expected `Q:`/`K:`), and **`Card Input format` (currently `6H`)** — it defines the
UID shape we enroll; changing it later silently orphans every enrolled card.
## ⚠️ Phantom optical decodes from sunlight patterns (park-buzi, 2026-07-04)
With the site EMPTY (pre-opening, verified live + by snapshot), the **exit reader
(`cjihao=H05MA5B0`) pushed spontaneous scans** at random afternoon times (observed 16:38–18:14,
low-western-sun hours): `cardid` values like `997492`, `389861`, `024358`, `192793` — and once a
lone **`C`**. Server logs (`READ serial=` lines) confirm the pushes carry the reader's own serial
and resolve to its assigned row, so this is **the physical reader decoding, not a network source**.
**Diagnosis:** the scan engine ships with many 1D symbologies enabled, some with weak/no checksums.
Six-digit all-numeric strings are the signature of **Interleaved 2-of-5** (even-length, digits-only,
no checksum — any high-contrast stripe pattern of the right proportions "decodes"); a lone `C` is a
**Code39/Codabar** artifact (Codabar start/stop chars are A–D). Low sun creates exactly such
patterns at a gate: the striped barrier arm, fence/railing shadows sweeping as the sun moves, glare
bands. RFID noise would instead give repeating UID-shaped values.
**Impact: noise, not risk.** Every phantom was REFUSED fail-closed; a phantom can never match a
ticket ([[ticket-encoding]] ids are 11-digit + Luhn, so a 6-digit read has nothing to match).
**Feed filter (2026-07-04 — supersedes the earlier "do not filter" position).** Initially each
phantom signed an `exit.refused.noSession` anomaly (#52–58 in the feed) and the position was to
keep it that way. The operator overruled it, correctly: red "who is trying to exit?" rows for
NOBODY train the operator to ignore the signed feed — alarm fatigue is the adversary's friend.
`read-dispatch.ts` now drops a no-match value that **cannot possibly be a credential we issue** to
UNSIGNED telemetry (`device_events`, `unrecognizedRead:true` — still auditable). "Possibly ours"
is deliberately WIDE and everything in it still signs the normal refusal anomaly: Luhn-valid
ticket shapes (a forged ticket is a real probe), `SUB-`/`SUBSESS-` prefixes, ANY read on a
confirmed-RF channel (a physical card is a real event, never sun noise), and plates (never
shape-filtered). The filter also works pre-prefix (legacy untagged reads). Still fix at the
source too:
**Fix (vendor tool, per reader — config lives ON THE DEVICE, not in our DB):** disable every
symbology except **QR + Code128** (all our credentials); if offered, set **minimum decode length
≥ 10** and require checksums. Apply to BOTH readers. ⚠️ A factory reset or a swapped unit silently
re-enables the phantom symbologies — re-apply after any reset/replacement. Physical fallback if any
noise survives: hood/visor the window, tilt it down, avoid facing the striped arm.
## ⚠️ Reply MUST set `Connection: close` (verified on hardware)
The reader sends `Connection: keep-alive` but **only acts on the verdict (beep/output) once the TCP
+4 -2
View File
@@ -80,8 +80,10 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback; suppressed press = telemetry.
- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF.
- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback (a real backstop since 2026-07-04); camera press-gate + post-hoc duplicate-plate anomaly (2026-07-04).
- [[entry-presence-bypass]] — admin drops a FAULTY presence signal (radar/camera, granular) until support fixes it; every flip is a signed config_change; tickets minted under bypass are stamped (2026-07-02).
- [[setup-relay-test]] — admin-only per-relay Test button in Setup; signs a barrier_open_command BEFORE the pulse so a test open never reads as fraud (2026-07-01).
- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF. The press gate enforces its SOLID state since 2026-07-04.
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
## Concepts — business domain
+65
View File
@@ -2207,3 +2207,68 @@ 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.
## [2026-07-04] update | Exit reader phantom scans traced to optical 1D decodes (sun patterns)
Pre-opening park-buzi, empty site: the exit [[dingtian-dt008-reader]] pushed spontaneous 6-digit
numeric scans (+ one lone "C") at low-sun afternoon hours; all refused fail-closed as
exit.refused.noSession. Server READ logs confirmed the reader's own serial (H05MA5B0) → the
physical device decoding, not a network source; a live snapshot confirmed nobody present.
Diagnosis: default-enabled weak-checksum 1D symbologies (I2of5 6-digit signature; "C" =
Code39/Codabar artifact) decoding sun-made stripe patterns (striped barrier arm, fence shadows,
glare). No fraud exposure (11-digit Luhn ids can't match a 6-digit read). Fix recorded on the
entity page: vendor-tool symbology cut to QR+Code128 + min length, BOTH readers, re-apply after
any factory reset (config lives on the device). Deliberately NOT filtering impossible codes
server-side — probe recording is the anomaly path's job.
## [2026-07-04] update | Backfilled missing concept pages: entry-presence-bypass + setup-relay-test
Two shipped features (2026-07-01/02) had no wiki pages — worse, six code files and
[[entry-double-press]] already linked [[entry-presence-bypass]] as if it existed. Written now:
[[entry-presence-bypass]] (admin drops a faulty radar/camera signal, granular by decision, every
flip a signed config_change, tickets stamped presenceBypassed, radar-bypass cooldown tradeoff,
"the admin is not the adversary" threat-model nuance) and [[setup-relay-test]] (admin-only
commissioning pulse, signed barrier_open_command BEFORE the fire so a test open never reads as the
out-of-band-open fraud signal, saved-controllers-only, radarAlert lamps excluded). Cross-linked
from [[operator-issued-entry]] (bypass note) and cataloged in index.md.
## [2026-07-04] update | Reader channel tagging: printed-card-clone hole closed
Investigating the phantom scans surfaced a real vulnerability: the DT-008 push is channel-blind
and SubscriptionFlow.match matched by value only, so printing an RF card's UID (written on the
card face) as a barcode cloned the card. Fixed with channel tagging: vendor-tool output prefixes
(Q:/K:) → routes/qr-reader.ts strips + tags DeviceReadEvent.channel (optical|rf) → match requires
channel agreement, refusing a mismatch + signing a sub.refused.channelMismatch anomaly (a clone
attempt is a fraud signal). Untagged (unprefixed) reads keep legacy behavior — enforcement only
bites where prefixes are deployed. Enrollment capture stores bare values. Recorded on
[[dingtian-dt008-reader]] incl. the two device-side settings now part of the credential contract
(prefixes + Card Input format 6H — re-apply after factory reset). 14 new tests; suite 272 green.
## [2026-07-04] update | Structural read filter: phantom scans out of the signed feed
Operator-requested reversal of the earlier "do not filter" position (recorded as superseded on
[[dingtian-dt008-reader]]): phantom decodes were signing exit.refused.noSession anomalies — red
rows for nobody, training the operator to ignore the feed. read-dispatch.ts now drops a no-match
reader value that cannot possibly be ours (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
confirmed-RF, not a plate) to unsigned device_events telemetry (unrecognizedRead:true). The
plausibility rule is deliberately wide so every real probe (forged ticket shape, unknown physical
card, unknown SUB- code) still signs the normal anomaly; enrolled credentials match before the
filter and can never be hidden by it. Works for legacy unprefixed reads too, so the feed cleans up
before the vendor-tool visit. 6 new tests; suite 278 green. Also this session: reader channel
tagging (clone defense) — see the prior entry.