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