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
+32
View File
@@ -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:
/>
</label>
)}
{(r.direction === "entry" || r.direction === "both") && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
{t("setup.presenceInput")}
<input
type="number"
min={1}
value={r.presenceInput ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
)}
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
<input
type="number"
min={0}
value={r.entryCooldownSec ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
)}
{relays.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕