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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user