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