diff --git a/apps/server/src/device-resolve.ts b/apps/server/src/device-resolve.ts index 243da14..13f270a 100644 --- a/apps/server/src/device-resolve.ts +++ b/apps/server/src/device-resolve.ts @@ -11,7 +11,7 @@ export type Direction = "entry" | "exit" | "both"; export type FlowDirection = "entry" | "exit"; /** One relay on an access controller: which barrier it opens, in which direction, - * and (optionally) the input terminal its entry button is wired to. */ + * and (optionally) the input terminals its entry button + presence loop are wired to. */ export interface RelaySpec { /** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */ readonly relay: number; @@ -19,6 +19,21 @@ export interface RelaySpec { /** 1-based input terminal of the entry button that fires this relay (transient * entry). Absent = no button at this barrier (subscriber/reader-driven only). */ readonly button?: number; + /** + * Anti-double-press for the transient entry button (one car must yield ONE ticket). + * Two modes, chosen by what barrier feedback exists at this lane: + * - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the + * 1-based input terminal of an induction loop / barrier presence signal on THIS + * controller. 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. This + * makes one-car-one-ticket physical. + * - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses + * on this relay for N seconds after a ticket prints. A pure timer — mitigation, + * not a guarantee. Used when `presenceInput` is unset (or as a secondary guard). + * Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md. + */ + readonly presenceInput?: number; + readonly entryCooldownSec?: number; } /** Access controller config (the `relays[]` map + connection fields). */ @@ -38,11 +53,17 @@ interface BoundConfig { readonly [k: string]: unknown; } -/** A resolved barrier: the controller row + the specific relay to pulse. */ +/** A resolved barrier: the controller row + the specific relay to pulse. Carries the + * transient-entry anti-double-press config (presence loop / cooldown) when resolved + * from a button press, so the entry flow can enforce one-car-one-ticket. */ export interface ResolvedRelay { readonly controller: DeviceRow; readonly relay: number; readonly direction: Direction; + /** 1-based presence-loop input gating this relay's entry (when wired). */ + readonly presenceInput?: number; + /** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */ + readonly entryCooldownSec?: number; } /** All enabled access controller rows. */ @@ -76,6 +97,31 @@ export function relayForButton(db: Db, controllerId: string, terminal: number): const spec = relaysOf(row).find((r) => r.button === terminal); if (!spec) return null; if (spec.direction !== "entry" && spec.direction !== "both") return null; + return { + controller: row, + relay: spec.relay, + direction: spec.direction, + presenceInput: spec.presenceInput, + entryCooldownSec: spec.entryCooldownSec, + }; +} + +/** + * Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with + * this deviceId, and the relay whose `presenceInput` terminal matches the fired input. + * Lets the entry flow track "a car is physically at this entry barrier" so it issues + * exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise. + */ +export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null { + const row = db + .select() + .from(devices) + .where(and(eq(devices.id, controllerId), eq(devices.category, "access"))) + .get(); + if (!row || !row.enabled) return null; + const spec = relaysOf(row).find((r) => r.presenceInput === terminal); + if (!spec) return null; + if (spec.direction !== "entry" && spec.direction !== "both") return null; return { controller: row, relay: spec.relay, direction: spec.direction }; } diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 9b88055..7e3ea36 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -1,5 +1,5 @@ -import { randomInt } from "node:crypto"; -import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db"; +import { randomInt, randomUUID } from "node:crypto"; +import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db"; import { NoPrinterAvailableError, printWithFailover, @@ -15,7 +15,7 @@ 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, type ResolvedRelay } from "./device-resolve.js"; +import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js"; import { snapshotAsync } from "./snapshot.js"; // The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry @@ -35,6 +35,31 @@ import { snapshotAsync } from "./snapshot.js"; // // 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; @@ -42,6 +67,8 @@ export class EntryFlow { readonly #logger: FastifyBaseLogger; /** Guard against double-fire from the same physical press (on edge only). */ readonly #inFlight = new Set(); + /** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */ + readonly #guard = new Map(); constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) { this.#db = db; @@ -49,10 +76,20 @@ export class EntryFlow { this.#logger = logger; } - /** Handle a device input edge. Acts only on the rising ("on") edge of an entry - * button — an input terminal mapped to an entry relay on its controller. */ + /** 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 { - if (e.edge !== "on") return; // release edge is just telemetry + // 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 @@ -60,6 +97,14 @@ export class EntryFlow { 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); @@ -72,6 +117,88 @@ export class EntryFlow { } } + /** 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 { // 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 @@ -80,14 +207,20 @@ export class EntryFlow { // 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; } @@ -103,6 +236,13 @@ export class EntryFlow { 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. @@ -113,6 +253,8 @@ export class EntryFlow { 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; } @@ -146,12 +288,7 @@ export class EntryFlow { // 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). - void snapshotAsync({ - db: this.#db, - direction: "entry", - identity: ticketId, - logger: this.#logger, - }).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`)); + 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). @@ -167,6 +304,15 @@ export class EntryFlow { } } + /** 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 }).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); diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 5003cde..5532d4c 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -99,6 +99,7 @@ export class ExitFlow { if (!view || !view.open) { const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession"); await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } }); + this.#fireExitSnapshot(id); this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`); return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason }; } @@ -112,6 +113,7 @@ export class ExitFlow { if (!freeGrace && (!paid || !withinGrace)) { const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid"); await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } }); + this.#fireExitSnapshot(id); this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`); return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason }; } @@ -274,6 +276,7 @@ export class ExitFlow { identity: e.value, payload: { ...rp, exitRefused: true }, }); + this.#fireExitSnapshot(e.value); this.#logger.warn(`exit refused: no open session for ${e.value}`); return { accepted: false, direction: "exit", reason: rp.reason }; } @@ -314,6 +317,7 @@ export class ExitFlow { identity: e.value, payload: { ...rp, exitRefused: true, sessionRef: e.value }, }); + this.#fireExitSnapshot(e.value); this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`); return { accepted: false, direction: "exit", reason: rp.reason }; } diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index 435834d..64df3c1 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -98,8 +98,11 @@ export class SubscriptionFlow { } async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise { + // The physical side the reader sits at — used to fire the right camera on a refusal + // that happens BEFORE we infer the entry/exit verb ("both" defers to entry). + const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry"; const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get(); - if (!sub) return { accepted: false, reason: await this.#reject(m, "sub.refused.notFound") }; + if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") }; // Validity: active + within the coverage window. const now = new Date().toISOString(); @@ -108,7 +111,7 @@ export class SubscriptionFlow { (sub.validFrom != null && now < sub.validFrom) || (sub.validTo != null && now > sub.validTo); if (invalid) { - const reason = await this.#reject(m, "sub.refused.outOfWindow", { status: sub.status }); + const reason = await this.#reject(m, lane, "sub.refused.outOfWindow", { status: sub.status }); return { accepted: false, reason }; } @@ -136,7 +139,7 @@ export class SubscriptionFlow { // subscription has NOTHING open, an exit read is a no-op anti-passback signal. const oldest = open[0]; if (!oldest) { - const reason = await this.#reject(m, "sub.refused.noSession"); + const reason = await this.#reject(m, "exit", "sub.refused.noSession"); return { accepted: false, direction: "exit", reason }; } const occurrenceId = oldest.identity; @@ -156,7 +159,7 @@ export class SubscriptionFlow { // ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a // fresh per-occurrence id so a fleet can have several open at once. if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) { - const reason = await this.#reject(m, "sub.refused.atCapacity", { + const reason = await this.#reject(m, "entry", "sub.refused.atCapacity", { inUse: open.length, max: sub.maxConcurrent, }); @@ -227,10 +230,14 @@ export class SubscriptionFlow { return open; } - /** Sign a refused-subscription anomaly with a localizable reason code, and return - * the rendered English reason for the caller's ReadOutcome. */ + /** Sign a refused-subscription anomaly with a localizable reason code, fire the + * directional evidence camera, and return the rendered English reason for the + * caller's ReadOutcome. `dir` is the lane the refusal happened at (entry/exit) so + * the right camera captures the turned-away subscriber. `via` records which + * credential was presented. */ async #reject( m: SubscriptionMatch, + dir: FlowDirection, code: ReasonCode, params?: Record, ): Promise { @@ -239,24 +246,28 @@ export class SubscriptionFlow { type: "anomaly", identity: m.carKey, // `permitId`/`permitRefused` are the on-chain field names (immutable). - payload: { ...rp, permitId: m.subscriptionId, permitRefused: true }, + payload: { ...rp, permitId: m.subscriptionId, permitRefused: true, via: m.via }, }); + this.#fireSnapshot(dir, m.carKey); this.#logger.warn(`subscription refused (${m.carKey}): ${rp.reason}`); return rp.reason; } + /** Fire the directional camera(s) for a refused-subscription event; never awaited + * (evidence, not a gate). The accepted entry/exit paths snapshot inside #open. */ + #fireSnapshot(dir: FlowDirection, identity: string): void { + void snapshotAsync({ db: this.#db, direction: dir, identity, logger: this.#logger }).catch((err) => + this.#logger.error(`subscription snapshot error: ${(err as Error).message}`), + ); + } + async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise { const access = this.#buildAccess(resolved.controller); if (access) await access.pulseOpen(resolved.relay); else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`); // SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate). - void snapshotAsync({ - db: this.#db, - direction: dir, - identity: carKey, - logger: this.#logger, - }).catch((err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`)); + this.#fireSnapshot(dir, carKey); } #closeCache(carKey: string): void { diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index 456b463..84ce6d4 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -91,6 +91,16 @@ function eventBadges(p: LedgerEvent["payload"]): string[] { return keys; } +/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity + * log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */ +function viaKey(p: LedgerEvent["payload"]): string | null { + if (!p) return null; + if (p.via === "qr") return "booth.viaQr"; + if (p.via === "card") return "booth.viaCard"; + if (p.via === "plate") return "booth.viaPlate"; + return null; +} + /** A short money summary for payment events (e.g. "350.00 ALL"). */ function paymentSummary(p: LedgerEvent["payload"]): string | null { if (!p || typeof p.amountMinor !== "number" || !p.currency) return null; @@ -115,8 +125,9 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v const reason = renderReason(p, t); const amount = paymentSummary(p); const badges = eventBadges(p); + const via = viaKey(p); const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null); - const showDetail = detail != null || badges.length > 0; + const showDetail = detail != null || badges.length > 0 || via != null; // The whole row is a button → opens the event-detail modal (full payload + the // session's entry/exit snapshots). A grid keeps the time/label/identity/index @@ -144,6 +155,11 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v {t(k)} ))} + {via && ( + + {t(via)} + + )} {detail && ( {detail} )} @@ -238,6 +254,11 @@ function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void )} {typeof p?.tender === "string" && {p.tender}} + {viaKey(p) && ( + + {t(viaKey(p)!)} + + )} {category && {category}} {plate && {plate}} {operator && {operator}} diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 310eb48..8f2ad0e 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -422,6 +422,8 @@ function DeviceForm({ relay: r.relay, direction: r.direction, ...(r.button ? { button: r.button } : {}), + ...(r.presenceInput ? { presenceInput: r.presenceInput } : {}), + ...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}), })); } else if (controllerId && boundRelay !== "") { out.controllerId = controllerId; @@ -698,6 +700,36 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: /> )} + {(r.direction === "entry" || r.direction === "both") && ( + + )} + {(r.direction === "entry" || r.direction === "both") && !r.presenceInput && ( + + )} {relays.length > 1 && (