feat(setup): operator-tested relay pulse, signed into the ledger

Add a per-relay "Test" control on each saved controller in /setup so an admin
can prove barrier wiring without a vehicle. POST /api/setup/test-relay pulses a
barrier relay — but because a physical open with no matching signed command is
the fraud signal, the route SIGNS a barrier_open_command (reason setup.relayTest,
source manual, attributed to the acting admin) BEFORE it fires. Reconciliation
then reads the open as explained, not an anomaly, and there's an audit trail.

- Admin-only (site:update), CSRF-guarded; fires only against a SAVED controller
  (real id → clean attribution; also stops a redirected/unsaved config from
  opening an arbitrary host's barrier). Sign-before-fire; a pulse failure is
  reported, not a 500. radarAlert relays (lamps) are excluded from the UI.
- New reason code setup.relayTest in @parking/shared (+ EN template); sq/en keys.
- EventLog constructed before setupRoutes so the route can sign.
- Integration test (stub controller, no hardware): RBAC 403, CSRF 403, signed
  barrier_open_command on success, 400 unknown relay w/ no ledger row, 404
  unknown controller, 400 bad relay value.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-04 13:40:58 +02:00
parent 61b9955160
commit 306d136a08
8 changed files with 310 additions and 12 deletions
+55
View File
@@ -10,6 +10,7 @@ import {
testAnpr,
testDevice,
testPrint,
testRelay,
unassignDevice,
type AnprTestResult,
type PrintTestResult,
@@ -305,6 +306,7 @@ function AssignmentRow({
<strong className="text-term-text">{assignment.driverId}</strong>
{host && <span className="tabular-nums text-term-muted">{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} />
{assignment.category === "access" && <RelayTester assignment={assignment} />}
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
<span className="flex-1" />
{error && <span className="text-term-red">{error}</span>}
@@ -318,6 +320,59 @@ function AssignmentRow({
);
}
/** Per-relay "Test" control on a SAVED controller row. Pulses a BARRIER relay to prove
* the wiring — this physically opens the barrier, so it confirms first, and the server
* signs the deliberate open into the ledger (reason setup.relayTest). radarAlert relays
* are lamps, not barriers — excluded (pulsing one is meaningless/wrong). */
function RelayTester({ assignment }: { assignment: Assignment }) {
const { t } = useTranslation();
const cfg = assignment.config as Record<string, unknown>;
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
const barriers = relays.filter((r) => r.direction !== "radarAlert");
const [busyRelay, setBusyRelay] = useState<number | null>(null);
const [result, setResult] = useState<{ relay: number; ok: boolean; detail?: string } | null>(null);
if (barriers.length === 0) return null;
async function testRelayNow(relay: number) {
if (!confirm(t("setup.confirmRelayTest", { relay }))) return;
setBusyRelay(relay);
setResult(null);
try {
const res = await testRelay(assignment.id, relay);
setResult({ relay, ok: res.ok, detail: res.ok ? undefined : res.detail ?? res.reason });
} catch (e) {
setResult({ relay, ok: false, detail: (e as Error).message });
} finally {
setBusyRelay(null);
}
}
return (
<span className="flex flex-wrap items-center gap-1">
{barriers.map((r) => (
<button
key={r.relay}
type="button"
className="btn btn-ghost btn-sm"
onClick={() => testRelayNow(r.relay)}
disabled={busyRelay != null}
title={t("setup.testRelayTitle", { relay: r.relay })}
>
{busyRelay === r.relay ? t("setup.relayTesting") : t("setup.testRelay", { relay: r.relay })}
</button>
))}
{result && (
<span className={result.ok ? "text-term-green" : "text-term-red"}>
{result.ok
? t("setup.relayTestOk", { relay: result.relay })
: t("setup.relayTestFailed", { relay: result.relay, detail: result.detail ?? "" })}
</span>
)}
</span>
);
}
/** Inline summary of an assignment's direction/binding for the list. */
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
const { t } = useTranslation();
+15
View File
@@ -460,6 +460,21 @@ export function testPrint(
});
}
export type RelayTestResult =
| { ok: true; firedAt: string; tookMs: number }
| { ok: false; reason: string; detail?: string; tookMs?: number };
/** Pulse a SAVED controller's barrier relay to test the wiring — physically opens the
* barrier. The server signs a `barrier_open_command` (reason setup.relayTest) before
* firing, so the open is explained, not a reconciliation anomaly. Saved controller only
* (needs a persisted id for attribution). */
export function testRelay(id: string, relay: number): Promise<RelayTestResult> {
return apiFetch<RelayTestResult>("/api/setup/test-relay", {
method: "POST",
body: JSON.stringify({ id, relay }),
});
}
// --- Admin reports -------------------------------------------------------
export type ReportBucket = "hour" | "day" | "month";
+7
View File
@@ -470,6 +470,13 @@ export const en: Catalog = {
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
// Relay wiring test — physically opens the barrier (the open is signed into the ledger).
testRelay: "Test R{{relay}}",
relayTesting: "Opening…",
testRelayTitle: "Pulse relay {{relay}} — opens the barrier",
confirmRelayTest: "Pulse relay {{relay}} now? This physically opens the barrier and is recorded in the ledger as a test.",
relayTestOk: "✓ R{{relay}} pulsed — barrier opened",
relayTestFailed: "✗ R{{relay}} failed: {{detail}}",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Show password",
hideSecret: "Hide password",
+7
View File
@@ -480,6 +480,13 @@ export const sq = {
"Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.",
printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.",
"printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).",
// Provë e releut — hap fizikisht barrierën (hapja regjistrohet në ledger).
testRelay: "Provo R{{relay}}",
relayTesting: "Duke hapur…",
testRelayTitle: "Puls releu {{relay}} — hap barrierën",
confirmRelayTest: "Ky veprim hap fizikisht barrierën dhe regjistrohet në ledger si provë.",
relayTestOk: "✓ R{{relay}} u pulsua — barriera u hap",
relayTestFailed: "✗ R{{relay}} dështoi: {{detail}}",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Shfaq fjalëkalimin",
hideSecret: "Fshih fjalëkalimin",