b4f1418858
Field report (park-buzi): a BLINKING entry button still printed — the lamp encoded blink-vs-solid (radar-only vs radar+camera) but #suppressReason only checked the radar, so a radar false-positive (rain, pedestrian) minted a real signed ticket. Three layered fixes: 1. CAMERA gate on the physical press: with an entry camera configured, a press is live only in the lamp's SOLID state (LaneStatus.entry busy, mirrored into EntryFlow via onLaneStatus). Suppress-only — the camera stays advisory (never opens, never traps). Camera-less sites keep the radar-only gate; a faulty camera is dropped via the existing bypassPresenceCamera admin toggle. 2. Cooldown as a REAL backstop behind presence: the presence branch returned early, so entryCooldownSec was dead wherever a loop was wired. Now it bounds the stationary-car double-ticket (a motion radar drops a motionless car → spurious loop-clear re-arms one-car-one-ticket → same car reprints). 3. Post-hoc duplicate-plate anomaly (entry-side twin of plateSwapSuspected): when entry ANPR recognizes a plate already OPEN under another session entered within ENTRY_DUP_PLATE_WINDOW_MIN (default 15 min), sign ONE entry.duplicatePlate anomaly naming both tickets for the operator to void. ANPR stays non-blocking (rides the post-open snapshot as before). REJECTED: camera-vetoed re-arm (defer re-arm until the lane flips free). The camera has no leave events — "free" is a ~30s silence timeout that never lapses inside a queue, so every queued car after the first would be suppressed until an operator intervened. Blocking legit entry at peak beats nothing; the proper preventive fix is a pass-through sensor (passedInput) — recorded as open in wiki/concepts/entry-double-press.md. Also: setup.relayTest reason was missing from both web catalogs (parity is only enforced sq<->en, so the build passed) — added. Tests: entry-press-gate.test.ts (blink suppresses / solid prints / camera-less unaffected / bypass honored / cooldown catches the dropout re-press / residual risk documented / still-present re-press stays suppressed) + entry-duplicate-plate.test.ts (flags open dup, ignores closed/stale/self/other plates). Suite 258 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
614 lines
29 KiB
TypeScript
614 lines
29 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, 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";
|
|
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.
|
|
// 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.
|
|
|
|
/** A presence signal the entry gate can require (or, when a device is faulty, the admin
|
|
* can bypass): the radar/loop presence input, or the camera vehicle-detection. */
|
|
export type PresenceSignal = "radar" | "camera";
|
|
|
|
/** 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;
|
|
/** 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;
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** 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}`;
|
|
}
|
|
|
|
/** 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.
|
|
* 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.
|
|
// 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";
|
|
// 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) {
|
|
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;
|
|
}
|
|
|
|
/** 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 {
|
|
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;
|
|
}
|
|
|
|
await this.#issueTicket(resolved, { source: "ticket" });
|
|
}
|
|
|
|
/**
|
|
* The shared "issue a transient ticket" sequence used by BOTH the physical button
|
|
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
|
|
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
|
|
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
|
|
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
|
|
* signed entry AND append a companion `anomaly` (the operator-adversary path always
|
|
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
|
|
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
|
|
*/
|
|
async #issueTicket(
|
|
resolved: ResolvedRelay,
|
|
opts: {
|
|
source: "ticket" | "manual";
|
|
operator?: string;
|
|
overCapacity?: { count: number; capacity: number | null };
|
|
/** Presence signals that were BYPASSED (admin dropped them due to faulty hardware).
|
|
* Recorded on the signed entry so a ticket issued under a weakened gate is auditable. */
|
|
presenceBypassed?: PresenceSignal[];
|
|
},
|
|
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
|
|
const ticketId = newTicketId();
|
|
const issuedAt = new Date().toISOString();
|
|
const printers = this.#loadPrinters();
|
|
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
|
|
// + operatorInitiated:true in the payload. The button path is source "ticket".
|
|
const operatorInitiated = opts.source === "manual";
|
|
|
|
// 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 { ok: false, reason };
|
|
}
|
|
|
|
// 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).
|
|
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: opts.source,
|
|
identity: ticketId,
|
|
payload: {
|
|
sessionRef: ticketId,
|
|
ticketPrinted: true,
|
|
category,
|
|
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
|
|
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
|
|
...(opts.presenceBypassed && opts.presenceBypassed.length > 0
|
|
? { presenceBypassed: opts.presenceBypassed }
|
|
: {}),
|
|
},
|
|
occurredAt: issuedAt,
|
|
});
|
|
|
|
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
|
|
// always leaves a red-flag row in the tamper-evident record for reconciliation.
|
|
if (operatorInitiated) {
|
|
await this.#log.append({
|
|
type: "anomaly",
|
|
identity: ticketId,
|
|
payload: {
|
|
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
|
|
source: "booth",
|
|
operatorInitiated: true,
|
|
...(opts.operator ? { operator: opts.operator } : {}),
|
|
...(opts.overCapacity ? { lotFull: true } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
|
const access = this.#buildAccess(resolved.controller);
|
|
let opened = false;
|
|
if (access) {
|
|
await access.pulseOpen(resolved.relay);
|
|
opened = true;
|
|
} 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 is ALSO
|
|
// what records the plate that plate-reconciliation reads at exit.
|
|
this.#fireSnapshot("entry", ticketId);
|
|
|
|
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
|
try {
|
|
this.#db
|
|
.insert(sessions)
|
|
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
|
|
.run();
|
|
} catch (err) {
|
|
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
|
}
|
|
return { ok: true, ticketId, opened };
|
|
}
|
|
|
|
/**
|
|
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
|
|
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
|
|
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
|
|
* presence is this flow's own per-relay guard state. If a site has no presence loop the
|
|
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
|
|
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
|
|
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
|
|
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
|
|
* See wiki/concepts/operator-issued-entry.md.
|
|
*/
|
|
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
|
|
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
|
|
> {
|
|
const resolved = firstRelayByDirection(this.#db, "entry");
|
|
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
|
|
|
|
// PRESENCE GATE — normally require BOTH radar/loop presence AND camera detection. An
|
|
// admin may BYPASS a signal when its device is faulty (site_config, signed config_change);
|
|
// the bypassed signal is dropped as a requirement and RECORDED on the issued ticket.
|
|
const bypass = this.#presenceBypass();
|
|
const bypassed: PresenceSignal[] = [];
|
|
|
|
// Radar/loop side. A configured loop is only mandatory while radar is still REQUIRED;
|
|
// if radar is bypassed we skip the loop entirely (a dead loop is exactly why they bypass).
|
|
const radarRequired = !bypass.radar;
|
|
let radarPresent: boolean | null = null;
|
|
if (radarRequired) {
|
|
if (typeof resolved.presenceInput !== "number") {
|
|
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable (or bypass radar)" };
|
|
}
|
|
radarPresent = this.#guardState(resolved).present;
|
|
} else {
|
|
bypassed.push("radar");
|
|
}
|
|
|
|
// Camera side.
|
|
const cameraRequired = !bypass.camera;
|
|
if (!cameraRequired) bypassed.push("camera");
|
|
|
|
// Refuse only when a STILL-REQUIRED signal fails to confirm a vehicle.
|
|
const radarOk = !radarRequired || radarPresent === true;
|
|
const cameraOk = !cameraRequired || cameraBusy;
|
|
if (!radarOk || !cameraOk) {
|
|
await this.#log.append({
|
|
type: "anomaly",
|
|
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
|
payload: {
|
|
...reasonPayload("entry.issue.noPresence", { operator }),
|
|
source: "booth",
|
|
operator,
|
|
radarPresent,
|
|
cameraBusy,
|
|
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
|
},
|
|
});
|
|
this.#logger.warn(
|
|
`operator entry refused by ${operator}: no vehicle present (radar=${radarPresent}, camera=${cameraBusy}, bypassed=[${bypassed.join(",")}])`,
|
|
);
|
|
return { ok: false, reason: "no vehicle detected at the entry" };
|
|
}
|
|
|
|
const key = `operator-issue:${this.#relayKey(resolved)}`;
|
|
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
|
|
this.#inFlight.add(key);
|
|
try {
|
|
const occ = getOccupancy(this.#db);
|
|
const res = await this.#issueTicket(resolved, {
|
|
source: "manual",
|
|
operator,
|
|
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
|
|
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
|
});
|
|
if (!res.ok) return res;
|
|
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
|
|
} finally {
|
|
this.#inFlight.delete(key);
|
|
}
|
|
}
|
|
|
|
/** 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 {
|
|
// `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}`),
|
|
);
|
|
}
|
|
|
|
/** Current admin presence-gate bypass (site_config), read LIVE so a toggle takes effect
|
|
* with no restart. Default: nothing bypassed (the normal both-required gate). */
|
|
#presenceBypass(): { radar: boolean; camera: boolean } {
|
|
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
return { radar: cfg?.bypassPresenceRadar ?? false, camera: cfg?.bypassPresenceCamera ?? false };
|
|
}
|
|
|
|
/** 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];
|
|
}
|