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
+22 -1
View File
@@ -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)}
</span>
))}
{via && (
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
{t(via)}
</span>
)}
{detail && (
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
@@ -238,6 +254,11 @@ function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void
</DetailRow>
)}
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
{viaKey(p) && (
<DetailRow label={t("booth.edVia")}>
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
</DetailRow>
)}
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
+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)}>
✕
+5
View File
@@ -254,6 +254,11 @@ export interface RelaySpec {
direction: Direction;
/** Input terminal of the entry button that fires this relay (transient entry). */
button?: number;
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
presenceInput?: number;
entryCooldownSec?: number;
}
export interface TestResult {
+10
View File
@@ -160,6 +160,10 @@ export const en: Catalog = {
edRawPayload: "Raw signed payload",
edOccurrence: "Occurrence id",
subscriber: "Subscriber",
edVia: "Entry medium",
viaQr: "QR code",
viaCard: "RFID card/chip",
viaPlate: "Plate",
},
// Localized messages for the signed REASON_CODES (see @parking/shared). Keys MUST
// match the codes 1:1; {{param}} placeholders are filled from the event's
@@ -286,6 +290,12 @@ export const en: Catalog = {
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
relay: "Relay",
entryButtonTerminal: "Entry button on terminal",
presenceInput: "Presence loop (terminal)",
presenceInputHint:
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.",
entryCooldown: "Cooldown after ticket (s)",
entryCooldownHint:
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
addRelay: "+ Add relay",
whichBarrier: "Which barrier does this device serve?",
controller: "Controller",
+10
View File
@@ -164,6 +164,10 @@ export const sq = {
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
edOccurrence: "ID e hyrjes",
subscriber: "Abonent",
edVia: "Mënyra e hyrjes",
viaQr: "Kod QR",
viaCard: "Kartë/çip RFID",
viaPlate: "Targë",
},
// Mesazhet e përkthyera për REASON_CODES e nënshkruara (shih @parking/shared).
// Çelësat përputhen 1:1 me kodet; {{param}} mbushet nga reasonParams i eventit.
@@ -295,6 +299,12 @@ export const sq = {
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
relay: "Rele",
entryButtonTerminal: "Butoni i hyrjes në terminalin",
presenceInput: "Sensori i pranisë (terminali)",
presenceInputHint:
"Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.",
entryCooldown: "Pritje pas biletës (sek)",
entryCooldownHint:
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
addRelay: "+ Shto rele",
// Binding picker.
whichBarrier: "Cilën barrierë shërben kjo pajisje?",