feat(booth): refusal snapshots, subscriber access medium, one-car-one-ticket entry

Three booth-integrity improvements that share the entry/exit flows and activity log.

Refusal snapshots: previously only an accepted open captured a camera image; now
every refusal/hold anomaly fires the directional camera too (a turned-away car is
exactly the evidence wanted) — entry refused-full/held, exit refused
closed/no-session/unpaid/grace-expired (booth + reader paths), refused subscription.
A refused entry has no ticket id, so a synthetic REFUSED- ref keys the anomaly + photo
together. Same fire-and-forget contract; failed captures still show as tiles.

Subscriber access medium: the subscription flow already signed `via`
(qr|card|plate) into entry/exit payloads; surface it as a typed LedgerPayload.via, a
cyan chip in the ticker, and an "Entry medium" modal row (sq+en). Display-only.

One car = one ticket: the entry button could be mashed to mint many tickets per car
(corrupting occupancy + enabling ticket-shopping at exit) — the old #inFlight guard
only blocked overlapping presses. Add a per-relay guard configured on the relay spec:
PRESENCE mode (presenceInput ties ticketing to a vehicle loop on a Dingtian input —
one ticket per car, re-armed when the loop clears) or COOLDOWN fallback
(entryCooldownSec) when there's no barrier feedback. A suppressed press is unsigned
device_events telemetry, not a signed anomaly. SetupWizard exposes both fields.
Fail-closed entry and barrier-is-not-a-door invariants untouched; guard state is
in-memory/rebuildable, starts armed after restart.

Wiki: new entry-double-press; updated entry-exit-points, booth-console, index.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 12:54:54 +02:00
parent bfb6ab0b36
commit 30e7fe85de
14 changed files with 436 additions and 28 deletions
+48 -2
View File
@@ -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 };
}