Files
parking_solution/apps/server/src/entry-flow.ts
T
julian ecaaefd899 refactor(vision): ANPR rides the entry/exit snapshot, drop polling reader
Rework the ANPR trigger to the real design: when a transient presses the button or a
subscriber passes QR/RFID, the entry/exit fires and takes its evidence snapshot — that
is the moment to recognize. snapshotAsync now takes the VisionClient and, after storing
each snapshot from an opt-in (config.anpr) camera, runs ANPR on the SAME image and
records the plate against the SAME session identity (device_events kind:"read" with
plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both
evidence and plate extraction; recognition fires only on a real entry/exit — no polling.

The entry/exit/subscription flows take an optional VisionClient and pass it through;
server.ts wires it. Removed the polling VisionReader and VISION_POLL_MS/VISION_DEDUPE_MS.

Advisory + fire-and-forget: a low-confidence/no-plate result records nothing, a vision
failure never delays or changes the open, and the plate does not feed the access
decision. Verified e2e: a simulated entry snapshot on an anpr camera (live fast_alpr)
stored the snapshot for the session and recorded {identity, plate:AA558EE, 0.999,
region:Albania, snapshotId}. Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:53:43 +02:00

427 lines
19 KiB
TypeScript

import { randomInt, randomUUID } from "node:crypto";
import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
import {
NoPrinterAvailableError,
printWithFailover,
registry,
type AccessControlDevice,
type PrinterDevice,
type PrinterInstance,
type TicketData,
type TicketHeader,
} from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
// → open the barrier. The button is wired into an access controller's input; the
// admin maps that input terminal to a relay (config.relays[].button), so a press
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
//
// Two invariants from the threat model + safety analysis:
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
// BEFORE pulseOpen fires; an open with no matching signed event is the fraud
// signal (wiki/concepts/append-only-event-chain.md).
// 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if
// all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket
// unprinted) and leave the barrier closed; the operator handles the held car.
// Crucially, NO vehicle_entry is written in that case — we never record an
// "entered" event for a car that didn't get in (decision 2026-06-15).
//
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
// (fail) sign anomaly, stop.
//
// ONE CAR = ONE TICKET (anti-double-press). The entry button can be physically held
// or mashed; without a guard each press mints a fresh ticket + signed vehicle_entry
// (corrupting occupancy and letting a transient shop the cheapest ticket at exit). The
// guard is per-relay and CONFIGURED on the relay spec (config.relays[]), chosen by what
// barrier feedback exists at the lane:
// - PRESENCE loop (preferred): `presenceInput` ties ticketing to a real vehicle. A
// press prints only while a car is present, and NO second ticket issues until the
// loop CLEARS (car drove in) and a new car re-occupies it. We observe the loop's
// 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.
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
// See wiki/concepts/entry-double-press.md.
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
interface RelayGuardState {
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
lastTicketAt: number;
/** PRESENCE mode: is a vehicle currently on the loop? (from loop input edges) */
present: boolean;
/** PRESENCE mode: ready to issue a ticket for a NEW car. Set false after a ticket
* prints; re-armed when the loop CLEARS (the car drove through). */
armed: boolean;
}
export class EntryFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
/** Guard against double-fire from the same physical press (on edge only). */
readonly #inFlight = new Set<string>();
/** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */
readonly #guard = new Map<string, RelayGuardState>();
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#logger = logger;
this.#vision = vision;
}
/** Handle a device input edge. Two kinds of edge matter to this flow:
* (1) an ENTRY BUTTON press (rising edge) → run entry, subject to the per-relay
* anti-double-press guard; (2) a PRESENCE LOOP edge (either direction) → update
* presence state so the guard knows when a car arrives/leaves. The same physical
* input is never both, so we resolve each independently. */
async onInput(e: DeviceInputEvent): Promise<void> {
// Presence-loop edge (both directions matter): keep the per-relay state current.
const presence = relayForPresence(this.#db, e.deviceId, e.input);
if (presence) {
this.#onPresenceEdge(presence, e.edge);
return; // a loop input is not a button — nothing else to do
}
if (e.edge !== "on") return; // for buttons, the release edge is just telemetry
// The firing device must be an access controller, and the pressed input terminal
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
const resolved = relayForButton(this.#db, e.deviceId, e.input);
if (!resolved) return;
// ANTI-DOUBLE-PRESS: is this press allowed to issue a ticket? (presence/cooldown)
const suppressed = this.#suppressReason(resolved);
if (suppressed) {
this.#recordSuppressedPress(e, resolved, suppressed);
this.#logger.info(`entry press suppressed (${this.#relayKey(resolved)}): ${suppressed}`);
return;
}
const key = `${e.deviceId}:${e.input}`;
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
this.#inFlight.add(key);
try {
await this.#runEntry(resolved);
} catch (err) {
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
} finally {
this.#inFlight.delete(key);
}
}
/** Stable per-relay key for the guard map. */
#relayKey(r: ResolvedRelay): string {
return `${r.controller.id}:${r.relay}`;
}
/** Lazily get (or create) the guard state for a relay. New relays start ARMED and
* with no car present, so the first press on a fresh lane works immediately. */
#guardState(r: ResolvedRelay): RelayGuardState {
const key = this.#relayKey(r);
let s = this.#guard.get(key);
if (!s) {
s = { lastTicketAt: 0, present: false, armed: true };
this.#guard.set(key, s);
}
return s;
}
/** Apply a presence-loop edge to a relay's state. The car ARRIVING re-arms ticketing;
* the car LEAVING the loop (after its entry) re-arms for the NEXT car. */
#onPresenceEdge(r: ResolvedRelay, edge: "on" | "off"): void {
const s = this.#guardState(r);
if (edge === "on") {
s.present = true; // a vehicle is at the barrier
} else {
// Loop cleared: the car drove through (or backed off). Re-arm for the next car —
// this is the gate that makes a *new* car necessary before another ticket.
s.present = false;
s.armed = true;
}
}
/** 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. */
#suppressReason(r: ResolvedRelay): string | null {
const s = this.#guardState(r);
if (typeof r.presenceInput === "number") {
// 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;
}
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
const elapsed = Date.now() - s.lastTicketAt;
if (elapsed < r.entryCooldownSec * 1000) {
const remain = Math.ceil((r.entryCooldownSec * 1000 - elapsed) / 1000);
return `within ${r.entryCooldownSec}s entry cooldown (${remain}s left)`;
}
}
return null;
}
/** 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 {
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: e.deviceId,
category: "access",
kind: "input",
detail: {
driverId: e.driverId,
input: e.input,
edge: e.edge,
entrySuppressed: true,
relay: r.relay,
reason,
},
occurredAt: e.at,
})
.run();
} catch (err) {
this.#logger.error(`suppressed-press telemetry insert failed: ${(err as Error).message}`);
}
}
async #runEntry(resolved: ResolvedRelay): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
// no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
// they aren't locked out. "Full" is a soft policy seam for valet over-
// capacity later. See wiki/concepts/capacity-occupancy.md.
const occ = getOccupancy(this.#db);
if (occ.full) {
// No ticket id exists for a refused entry, so mint a synthetic ref to key the
// anomaly + its evidence snapshot together. The operator wants the photo of WHO
// was turned away (a fraud/dispute signal), so we still fire the entry camera.
const refusedRef = `REFUSED-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
await this.#log.append({
type: "anomaly",
identity: refusedRef,
payload: {
...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }),
entryRefused: true,
full: true,
},
});
this.#fireSnapshot("entry", refusedRef);
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
return;
}
const ticketId = newTicketId();
const issuedAt = new Date().toISOString();
const printers = this.#loadPrinters();
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket),
);
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
// ONE CAR = ONE TICKET: a ticket is now out for the car at this barrier. Disarm +
// stamp the cooldown so a repeat press (held button / mashing) issues no second
// ticket. PRESENCE mode re-arms when the loop clears (car drove in); COOLDOWN mode
// re-allows after entryCooldownSec. Done on the print success, NOT the open.
const guard = this.#guardState(resolved);
guard.lastTicketAt = Date.now();
guard.armed = false;
} catch (err) {
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
// failed attempt is in the tamper-evident record for the operator.
const reason =
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
await this.#log.append({
type: "anomaly",
identity: ticketId,
payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false },
});
// Capture who is held at the barrier (evidence for the operator handling the car).
this.#fireSnapshot("entry", ticketId);
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
return;
}
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
// `category` is FROZEN here (in the signed payload) so the tariff prices and
// later reprices the same way at exit. Today every transient takes the SITE
// default category (operator policy, site_config.default_vehicle_category;
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
// is the future seam — source it from `resolved` then. A V1/no-category tariff
// ignores it; only V2 category cards consult it.
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const category =
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
? cfg.defaultVehicleCategory
: DEFAULT_VEHICLE_CATEGORY;
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: "ticket",
identity: ticketId,
payload: { sessionRef: ticketId, ticketPrinted: true, category },
occurredAt: issuedAt,
});
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
// a camera failure must not delay or block the already-open barrier).
this.#fireSnapshot("entry", ticketId);
// 4. Update the session projection cache (rebuildable from the ledger; this is
// just a fast read-model, never the source of truth).
try {
this.#db
.insert(sessions)
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
.run();
} catch (err) {
// Cache miss is non-fatal — the ledger is authoritative and the projection
// can be rebuilt. Log it; don't fail the (already-open) entry.
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
}
}
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
* 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(
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
);
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
/** Build live ENTRY printer instances (for failover selection). */
#loadPrinters(): PrinterInstance[] {
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** Park identity for the ticket header, from site_config (all fields optional;
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
#ticketHeader(): TicketHeader | undefined {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
}
/**
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
*
* Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
* — the anti-fraud property the wiki settles.
*
* Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the
* Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN
* ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while
* being two digits (≈2 barcode modules) narrower than the old 13. Collisions are
* negligible at lot scale; the unique constraints on ledger_events.index / sessions.id
* are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.)
* The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of
* failing as "session not found".
*/
function newTicketId(): string {
let body = "";
for (let i = 0; i < 10; i += 1) body += String(randomInt(10));
return body + luhnCheckDigit(body);
}
/** The Luhn (mod-10) check digit for an all-digit string. */
function luhnCheckDigit(digits: string): string {
let sum = 0;
// Walk right-to-left; the check digit sits at position 0 from the right, so the
// last body digit is an "even" position that gets doubled.
let double = true;
for (let i = digits.length - 1; i >= 0; i -= 1) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return String((10 - (sum % 10)) % 10);
}
/**
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
* Lets a manual-entry path (operator types the code off the ticket when readers are
* down) reject a typo up front. A scanned/looked-up id that predates this format
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
* never reject an id that already exists in the ledger. See ticket-encoding.md.
*/
export function validateTicketCode(code: string): boolean {
// Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest.
// Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation
// (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps
// a stray short/long string from being mistaken for a ticket. See ticket-encoding.md.
if (!/^\d{10,14}$/.test(code)) return false;
const body = code.slice(0, -1);
return luhnCheckDigit(body) === code[code.length - 1];
}