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:
@@ -0,0 +1,115 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// POST /api/setup/test-relay pulses a SAVED controller's barrier relay to prove the
|
||||||
|
// wiring — it physically opens the barrier. Because "a physical open with no matching
|
||||||
|
// signed command is the fraud signal" (append-only-event-chain / reconciliation), the
|
||||||
|
// route must SIGN a barrier_open_command (reason setup.relayTest) BEFORE it fires, and it
|
||||||
|
// must be admin-only. These tests use the `stub-access` controller (pulseOpen only logs —
|
||||||
|
// no real hardware) so they exercise the validate → sign → pulse path safely.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
const CTL = "ctl-stub";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: { relays: [{ relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function pulse(
|
||||||
|
body: unknown,
|
||||||
|
auth?: { cookie: string; csrf: string },
|
||||||
|
) {
|
||||||
|
return app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/setup/test-relay",
|
||||||
|
headers: auth ? { cookie: auth.cookie, "x-csrf-token": auth.csrf } : {},
|
||||||
|
payload: body as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/setup/test-relay", () => {
|
||||||
|
it("is admin-only: a non-site:update user is 403", async () => {
|
||||||
|
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||||
|
const auth = await login(app, "op", "pw");
|
||||||
|
const res = await pulse({ id: CTL, relay: 1 }, auth);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires CSRF on the mutation", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const { cookie } = await login(app, "admin", "pw");
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/setup/test-relay",
|
||||||
|
headers: { cookie }, // no x-csrf-token
|
||||||
|
payload: { id: CTL, relay: 1 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs a barrier_open_command (reason setup.relayTest) BEFORE firing, then reports ok", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
|
||||||
|
const res = await pulse({ id: CTL, relay: 2 }, auth);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
// The deliberate open is EXPLAINED in the signed ledger — not an anomaly.
|
||||||
|
const rows = db.select().from(ledgerEvents).all();
|
||||||
|
const testOpen = rows.find((r) => r.type === "barrier_open_command");
|
||||||
|
expect(testOpen, "a barrier_open_command must be signed").toBeTruthy();
|
||||||
|
expect(testOpen!.source).toBe("manual"); // deliberate human action
|
||||||
|
expect(testOpen!.signature.length).toBeGreaterThan(0);
|
||||||
|
const payload = testOpen!.payload as Record<string, unknown>;
|
||||||
|
expect(payload.relayTest).toBe(true);
|
||||||
|
expect(payload.reasonCode).toBe("setup.relayTest");
|
||||||
|
expect(payload.relay).toBe(2);
|
||||||
|
expect(payload.controllerId).toBe(CTL);
|
||||||
|
expect(payload.operator).toBe("admin"); // attributed to the acting admin
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a relay the controller does not declare (400, no ledger row)", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
const res = await pulse({ id: CTL, relay: 9 }, auth);
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // nothing signed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s an unknown controller id", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
const res = await pulse({ id: "nope", relay: 1 }, auth);
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bad relay value (non-positive-integer)", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
expect((await pulse({ id: CTL, relay: 0 }, auth)).statusCode).toBe(400);
|
||||||
|
expect((await pulse({ id: CTL, relay: -1 }, auth)).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
type DeviceConfig,
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
|
import { reasonPayload } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
import type { VisionClient } from "../vision-client.js";
|
import type { VisionClient } from "../vision-client.js";
|
||||||
|
|
||||||
@@ -60,6 +62,12 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Feature-detect the barrier-pulse capability on a built device adapter (the Setup
|
||||||
|
* relay test needs it; a stub/reader/camera won't have it). */
|
||||||
|
function hasPulseOpen(d: unknown): d is { pulseOpen(doorId: number): Promise<void> } {
|
||||||
|
return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function";
|
||||||
|
}
|
||||||
|
|
||||||
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
||||||
// secret may only be re-merged when these match the stored row — otherwise an admin
|
// secret may only be re-merged when these match the stored row — otherwise an admin
|
||||||
// could point a test at an attacker host while keeping a real device id and have the
|
// could point a test at an attacker host while keeping a real device id and have the
|
||||||
@@ -220,6 +228,7 @@ export async function setupRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
vision?: VisionClient | null,
|
vision?: VisionClient | null,
|
||||||
|
eventLog?: EventLog | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
@@ -456,6 +465,89 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// PULSE a controller's barrier relay from Setup, to test the wiring — WITHOUT any
|
||||||
|
// vehicle/session. This physically opens the barrier, so unlike the other tests it
|
||||||
|
// runs only against a SAVED controller (real id → clean attribution) and it SIGNS a
|
||||||
|
// `barrier_open_command` into the ledger FIRST, with reason `setup.relayTest` + the
|
||||||
|
// admin's identity. That is the whole point of doing it this way: a physical open with
|
||||||
|
// no matching signed command is the fraud signal ([[append-only-event-chain]],
|
||||||
|
// [[reconciliation]]) — a deliberate test must therefore be an EXPLAINED open, not a
|
||||||
|
// silent one. Sign-before-fire mirrors exit-flow's manual re-open: the intervention is
|
||||||
|
// recorded whether or not the physical pulse then succeeds. Admin-only (site:update).
|
||||||
|
app.post<{ Body: { id: string; relay: number } }>(
|
||||||
|
"/api/setup/test-relay",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id, relay } = req.body;
|
||||||
|
if (typeof id !== "string" || !id) return reply.code(400).send({ error: "missing controller id" });
|
||||||
|
if (!Number.isInteger(relay) || relay < 1) {
|
||||||
|
return reply.code(400).send({ error: "relay must be a 1-based channel number" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A relay test fires REAL hardware, so it must target a persisted controller — no
|
||||||
|
// firing an unsaved/redirected config (that would let a probe open an arbitrary host's
|
||||||
|
// barrier). Load the saved row and build straight from its stored config (relayPassword
|
||||||
|
// included — it's on the row, never in the request).
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
if (!row) return reply.code(404).send({ error: "controller not found" });
|
||||||
|
if (row.category !== "access") {
|
||||||
|
return reply.code(400).send({ error: `device ${id} is not a controller` });
|
||||||
|
}
|
||||||
|
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||||
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as { relay?: number }[]) : [];
|
||||||
|
if (!relays.some((r) => r.relay === relay)) {
|
||||||
|
return reply.code(400).send({ error: `controller ${id} has no relay ${relay}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(row.driverId, cfg as Record<string, string | number | boolean>);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!hasPulseOpen(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${row.driverId} cannot pulse a relay` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the deliberate open FIRST — recorded whether or not the physical pulse then
|
||||||
|
// succeeds. Skip only if no ledger is wired (test/degraded), in which case we still
|
||||||
|
// refuse rather than fire an unrecorded open.
|
||||||
|
const operator = req.user?.username ?? "unknown";
|
||||||
|
if (!eventLog) {
|
||||||
|
return reply.code(503).send({ error: "ledger unavailable — refusing an unrecorded relay open" });
|
||||||
|
}
|
||||||
|
await eventLog.append({
|
||||||
|
type: "barrier_open_command",
|
||||||
|
// A deliberate human action from the admin console → "manual" (the top-level
|
||||||
|
// IdentitySource). The relayTest marker + reason distinguish it in the payload.
|
||||||
|
source: "manual",
|
||||||
|
identity: `relay-test:${id}:${relay}`,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("setup.relayTest", { operator, relay, controller: row.driverId }),
|
||||||
|
relayTest: true,
|
||||||
|
controllerId: id,
|
||||||
|
relay,
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await device.pulseOpen(relay);
|
||||||
|
} catch (err) {
|
||||||
|
// The failure we're testing for (relay unreachable, wrong password). The open is
|
||||||
|
// already signed; report the pulse failure, don't 500.
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "pulse-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true, firedAt: new Date().toISOString(), tookMs: Date.now() - startedAt });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Candidate backend IPs the device can push to, for a given device host. The
|
// Candidate backend IPs the device can push to, for a given device host. The
|
||||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||||
|
|||||||
+15
-12
@@ -115,11 +115,24 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const visionClient = new VisionClient(app.log);
|
const visionClient = new VisionClient(app.log);
|
||||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||||
|
|
||||||
|
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||||
|
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||||
|
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||||
|
// in device_events. The entry flow turns an input into a signed vehicle_entry once
|
||||||
|
// a ticket prints + the barrier is commanded. See event-streams-split.md.
|
||||||
|
// Constructed HERE (before setupRoutes) so the Setup relay-test can sign its
|
||||||
|
// deliberate barrier open into the ledger; the read routes are wired further down.
|
||||||
|
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||||
|
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||||
|
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||||
|
deviceEvents.emitLedger(row),
|
||||||
|
);
|
||||||
|
|
||||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||||
await setupRoutes(app, db, visionClient);
|
await setupRoutes(app, db, visionClient, eventLog);
|
||||||
|
|
||||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
@@ -158,17 +171,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => deviceMonitor.start());
|
app.addHook("onReady", async () => deviceMonitor.start());
|
||||||
app.addHook("onClose", async () => deviceMonitor.stop());
|
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||||
|
|
||||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
// Read routes for the signed ledger (constructed above, before setupRoutes).
|
||||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
|
||||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
|
||||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
|
||||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
|
||||||
// See wiki/decisions/event-streams-split.md.
|
|
||||||
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
|
||||||
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
|
||||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
|
||||||
deviceEvents.emitLedger(row),
|
|
||||||
);
|
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
testAnpr,
|
testAnpr,
|
||||||
testDevice,
|
testDevice,
|
||||||
testPrint,
|
testPrint,
|
||||||
|
testRelay,
|
||||||
unassignDevice,
|
unassignDevice,
|
||||||
type AnprTestResult,
|
type AnprTestResult,
|
||||||
type PrintTestResult,
|
type PrintTestResult,
|
||||||
@@ -305,6 +306,7 @@ function AssignmentRow({
|
|||||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||||
|
{assignment.category === "access" && <RelayTester assignment={assignment} />}
|
||||||
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
|
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
{error && <span className="text-term-red">{error}</span>}
|
{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. */
|
/** Inline summary of an assignment's direction/binding for the list. */
|
||||||
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
||||||
const { t } = useTranslation();
|
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 -------------------------------------------------------
|
// --- Admin reports -------------------------------------------------------
|
||||||
export type ReportBucket = "hour" | "day" | "month";
|
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.",
|
"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.",
|
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).",
|
"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).
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
revealSecret: "Show password",
|
revealSecret: "Show password",
|
||||||
hideSecret: "Hide 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.",
|
"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.",
|
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).",
|
"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).
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
revealSecret: "Shfaq fjalëkalimin",
|
revealSecret: "Shfaq fjalëkalimin",
|
||||||
hideSecret: "Fshih fjalëkalimin",
|
hideSecret: "Fshih fjalëkalimin",
|
||||||
|
|||||||
@@ -395,6 +395,9 @@ export const REASON_CODES = [
|
|||||||
"sub.refused.unpaidWindow",
|
"sub.refused.unpaidWindow",
|
||||||
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
|
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
|
||||||
"void.ticketCancelled",
|
"void.ticketCancelled",
|
||||||
|
// an admin fired a barrier relay from Setup to test the wiring. The physical open is
|
||||||
|
// DELIBERATE — signing it keeps reconciliation from reading it as an out-of-band open.
|
||||||
|
"setup.relayTest",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ReasonCode = (typeof REASON_CODES)[number];
|
export type ReasonCode = (typeof REASON_CODES)[number];
|
||||||
@@ -428,6 +431,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
|||||||
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
||||||
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
||||||
"void.ticketCancelled": "ticket cancelled — {reason}",
|
"void.ticketCancelled": "ticket cancelled — {reason}",
|
||||||
|
"setup.relayTest": "relay test — admin {operator} pulsed relay {relay} on controller {controller} from Setup",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user