devices: pool-of-spaces model — drop lane, per-relay direction
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.
Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)
Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
(v1 events won't verify under v2 — intentional, gated per-event by keyId)
Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]
Web:
- wizard: no lane selector; add controllers (relay map + entry-button
terminal) first, then bind readers/cameras/printers to a controller relay
Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
This commit is contained in:
+301
-68
@@ -12,28 +12,41 @@ import {
|
||||
type Catalog,
|
||||
type CatalogEntry,
|
||||
type DeviceCategory,
|
||||
type DeviceConfig,
|
||||
type Direction,
|
||||
type DiscoveredDevice,
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
} from "./api.js";
|
||||
|
||||
// First-run setup wizard (scaffold). The admin assigns devices per lane from the
|
||||
// driver catalog. The data model is multi-instance — one lane_devices row per
|
||||
// instance — so EVERY category supports more than one device: each section lists
|
||||
// the already-assigned instances (with Remove) and an "Add" form. Drivers that
|
||||
// support LAN discovery get a "Scan" button. Auth is via the admin's session
|
||||
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md.
|
||||
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
|
||||
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
|
||||
// declares its relays = entry/exit/both + which input terminal the entry button is
|
||||
// on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
|
||||
// Direction is a property of the relay, inherited by bound devices. The data model
|
||||
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
|
||||
|
||||
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||
{ key: "access", title: "Access controllers", noun: "access controller" },
|
||||
{ key: "reader", title: "Readers", noun: "reader" },
|
||||
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" },
|
||||
{ key: "printer", title: "Printers", noun: "printer" },
|
||||
const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = {
|
||||
key: "access",
|
||||
title: "Controllers (barriers + entry button)",
|
||||
noun: "controller",
|
||||
};
|
||||
// Categories that BIND to a controller relay (direction inherited from the relay).
|
||||
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" },
|
||||
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" },
|
||||
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" },
|
||||
];
|
||||
|
||||
const DIRECTION_LABELS: Record<Direction, string> = {
|
||||
entry: "Entry",
|
||||
exit: "Exit",
|
||||
both: "Both (entry + exit)",
|
||||
};
|
||||
|
||||
export function SetupWizard() {
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
||||
const [lane, setLane] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reloadState = useCallback(() => {
|
||||
@@ -50,36 +63,41 @@ export function SetupWizard() {
|
||||
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
||||
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
||||
|
||||
// Controllers are needed before binding readers/cameras (they pick a controller relay).
|
||||
const controllers = assignments.filter((a) => a.category === "access");
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>First-run setup</h2>
|
||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<label>
|
||||
Lane{" "}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={lane}
|
||||
onChange={(e) => setLane(Number(e.target.value))}
|
||||
style={{ width: "4rem" }}
|
||||
/>
|
||||
</label>
|
||||
<span style={{ color: "#666", fontSize: "0.85em" }}>
|
||||
Devices are added per lane. Switch lanes to configure another.
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ color: "#666", fontSize: "0.9em" }}>
|
||||
Add your barrier controllers first — set which relay is entry/exit and which
|
||||
terminal the entry button is wired to. Then add readers, cameras and printers
|
||||
and point each at the barrier it serves.
|
||||
</p>
|
||||
|
||||
{CATEGORIES.map(({ key, title, noun }) => (
|
||||
<CategorySection
|
||||
category={CONTROLLER.key}
|
||||
title={CONTROLLER.title}
|
||||
noun={CONTROLLER.noun}
|
||||
entries={catalog[CONTROLLER.key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
pushCapableIds={catalog.pushCapable}
|
||||
controllers={controllers}
|
||||
assignments={controllers}
|
||||
onChanged={reloadState}
|
||||
/>
|
||||
|
||||
{BOUND.map(({ key, title, noun }) => (
|
||||
<CategorySection
|
||||
key={key}
|
||||
lane={lane}
|
||||
category={key}
|
||||
title={title}
|
||||
noun={noun}
|
||||
entries={catalog[key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
pushCapableIds={catalog.pushCapable}
|
||||
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
||||
controllers={controllers}
|
||||
assignments={assignments.filter((a) => a.category === key)}
|
||||
onChanged={reloadState}
|
||||
/>
|
||||
))}
|
||||
@@ -88,39 +106,37 @@ export function SetupWizard() {
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
lane,
|
||||
category,
|
||||
title,
|
||||
noun,
|
||||
entries,
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
controllers,
|
||||
assignments,
|
||||
onChanged,
|
||||
}: {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
title: string;
|
||||
noun: string;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
controllers: Assignment[];
|
||||
assignments: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
}) {
|
||||
// Show the add-form automatically when nothing is assigned yet; otherwise it's
|
||||
// collapsed behind "Add another" so the list stays the focus.
|
||||
const [adding, setAdding] = useState(false);
|
||||
// Warnings from the most recent save (e.g. "string protocol could not be
|
||||
// disabled — finish in the device web UI"). Persist after the form closes.
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const showForm = adding || assignments.length === 0;
|
||||
|
||||
// Binding categories need a controller to point at first.
|
||||
const isBound = category !== "access";
|
||||
const blockedNoController = isBound && controllers.length === 0;
|
||||
|
||||
return (
|
||||
<fieldset style={{ marginTop: "1rem" }}>
|
||||
<legend>
|
||||
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
|
||||
</legend>
|
||||
<legend>{title}</legend>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div
|
||||
@@ -147,18 +163,20 @@ function CategorySection({
|
||||
{assignments.length > 0 && (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||
{assignments.map((a) => (
|
||||
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} />
|
||||
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{showForm ? (
|
||||
{blockedNoController ? (
|
||||
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||
) : showForm ? (
|
||||
<DeviceForm
|
||||
lane={lane}
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
@@ -177,17 +195,17 @@ function CategorySection({
|
||||
|
||||
function AssignmentRow({
|
||||
assignment,
|
||||
controllers,
|
||||
onChanged,
|
||||
}: {
|
||||
assignment: Assignment;
|
||||
controllers: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
}) {
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// A short, human summary of the instance: role (if any) + host.
|
||||
const cfg = assignment.config;
|
||||
const role = typeof cfg.role === "string" ? cfg.role : null;
|
||||
const cfg = assignment.config as Record<string, unknown>;
|
||||
const host = typeof cfg.host === "string" ? cfg.host : null;
|
||||
|
||||
async function remove() {
|
||||
@@ -214,8 +232,8 @@ function AssignmentRow({
|
||||
}}
|
||||
>
|
||||
<strong>{assignment.driverId}</strong>
|
||||
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
|
||||
{host && <span style={{ color: "#666" }}>{host}</span>}
|
||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||
@@ -226,33 +244,66 @@ function AssignmentRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline summary of an assignment's direction/binding for the list. */
|
||||
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
||||
const cfg = assignment.config as Record<string, unknown>;
|
||||
if (assignment.category === "access") {
|
||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>;
|
||||
return (
|
||||
<span style={{ display: "flex", gap: "0.35rem" }}>
|
||||
{relays.map((r) => (
|
||||
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Bound device: show controller + relay it points at, with inherited direction.
|
||||
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||
if (!controllerId || relay == null) return <em style={{ color: "#b45309" }}>unbound</em>;
|
||||
const controller = controllers.find((c) => c.id === controllerId);
|
||||
const spec = controller
|
||||
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
|
||||
: undefined;
|
||||
return (
|
||||
<DirectionBadge
|
||||
direction={spec?.direction ?? "both"}
|
||||
label={`${controller ? controller.driverId : "?"} · R${relay}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceForm({
|
||||
lane,
|
||||
category,
|
||||
entries,
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
controllers,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
controllers: Assignment[];
|
||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const selected = entries.find((e) => e.id === selectedId);
|
||||
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
||||
// Only push-capable drivers (e.g. the Dingtian relay) call back to the
|
||||
// backend and need a backend IP. Pull-only devices (cameras, commanded relays)
|
||||
// must NOT show the field. See wiki/concepts/device-input-flow.md.
|
||||
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||
const isController = category === "access";
|
||||
|
||||
// Config values (auto-filled by discovery, editable by hand).
|
||||
const [config, setConfig] = useState<Record<string, string | number>>({});
|
||||
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
||||
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]);
|
||||
// Bound devices: which controller + relay this device sits at.
|
||||
const [controllerId, setControllerId] = useState<string>("");
|
||||
const [boundRelay, setBoundRelay] = useState<number | "">("");
|
||||
|
||||
const [tested, setTested] = useState<TestResult | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
@@ -262,17 +313,10 @@ function DeviceForm({
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
|
||||
// Backend push IP: which of OUR addresses the device should call back on. We
|
||||
// auto-pick the NIC on the device's subnet, but surface it editable here so a
|
||||
// multi-NIC host can be corrected (the chosen IP is baked into the device on
|
||||
// save). Only relevant for drivers that push back to us (pushesToBackend).
|
||||
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||
const [backendIp, setBackendIp] = useState<string>("");
|
||||
|
||||
// (Re)load backend-IP candidates whenever the device host changes after a
|
||||
// successful test (the test confirms the host is real + reachable) — but only
|
||||
// for push-capable drivers; a pull-only device never calls back.
|
||||
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
|
||||
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
||||
useEffect(() => {
|
||||
if (!testedHost || !pushesToBackend) {
|
||||
setBackendIps(null);
|
||||
@@ -319,8 +363,8 @@ function DeviceForm({
|
||||
resetStatus();
|
||||
}
|
||||
|
||||
// Config the user actually entered, merged over driver defaults.
|
||||
function mergedConfig(): Record<string, string | number> {
|
||||
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
||||
function mergedScalarConfig(): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const f of selected?.configFields ?? []) {
|
||||
const v = config[f.key] ?? (f.default as string | number | undefined);
|
||||
@@ -329,7 +373,22 @@ function DeviceForm({
|
||||
return out;
|
||||
}
|
||||
|
||||
// Editing config invalidates a prior test.
|
||||
/** Full config to persist: scalars + the model's direction/binding fields. */
|
||||
function mergedConfig(): DeviceConfig {
|
||||
const out: DeviceConfig = { ...mergedScalarConfig() };
|
||||
if (isController) {
|
||||
out.relays = relays.map((r) => ({
|
||||
relay: r.relay,
|
||||
direction: r.direction,
|
||||
...(r.button ? { button: r.button } : {}),
|
||||
}));
|
||||
} else if (controllerId && boundRelay !== "") {
|
||||
out.controllerId = controllerId;
|
||||
out.relay = boundRelay;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resetStatus() {
|
||||
setTested(null);
|
||||
setTestError(null);
|
||||
@@ -342,7 +401,7 @@ function DeviceForm({
|
||||
setTestError(null);
|
||||
setTested(null);
|
||||
try {
|
||||
setTested(await testDevice(selected.id, mergedConfig()));
|
||||
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
||||
} catch (e) {
|
||||
setTestError((e as Error).message);
|
||||
} finally {
|
||||
@@ -352,17 +411,21 @@ function DeviceForm({
|
||||
|
||||
async function save() {
|
||||
if (!selected) return;
|
||||
// Bound devices must point at a controller relay (binding is optional in the
|
||||
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
||||
if (!isController && (!controllerId || boundRelay === "")) {
|
||||
setSaveError("Pick the controller and relay this device sits at.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await assignDevice({
|
||||
lane,
|
||||
category,
|
||||
driverId: selected.id,
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
});
|
||||
// Hand warnings to the parent so they persist after this form unmounts.
|
||||
await onSaved(result.warnings ?? []);
|
||||
} catch (e) {
|
||||
setSaveError((e as Error).message);
|
||||
@@ -452,6 +515,23 @@ function DeviceForm({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
||||
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
||||
|
||||
{/* BOUND device: which controller + relay it sits at. */}
|
||||
{!isController && (
|
||||
<BindingPicker
|
||||
controllers={controllers}
|
||||
controllerId={controllerId}
|
||||
relay={boundRelay}
|
||||
onControllerChange={(id) => {
|
||||
setControllerId(id);
|
||||
setBoundRelay("");
|
||||
}}
|
||||
onRelayChange={setBoundRelay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<button type="button" onClick={test} disabled={testing}>
|
||||
@@ -487,8 +567,6 @@ function DeviceForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backend push IP — only for push-capable devices (candidates present).
|
||||
Pre-filled with the auto-pick; editable for multi-NIC hosts. */}
|
||||
{backendIps && backendIps.length > 0 && (
|
||||
<div style={{ margin: "0.5rem 0 0" }}>
|
||||
<label>
|
||||
@@ -523,6 +601,161 @@ function DeviceForm({
|
||||
);
|
||||
}
|
||||
|
||||
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
||||
* the input terminal its entry button is wired to. */
|
||||
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
||||
function update(i: number, patch: Partial<RelaySpec>) {
|
||||
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
}
|
||||
function add() {
|
||||
const nextRelay = (relays.reduce((m, r) => Math.max(m, r.relay), 0) || 0) + 1;
|
||||
onChange([...relays, { relay: nextRelay, direction: "both" }]);
|
||||
}
|
||||
function remove(i: number) {
|
||||
onChange(relays.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong>
|
||||
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}>
|
||||
Each relay opens one barrier. Set its direction; for transient entry, set which input
|
||||
terminal the entry button is wired to.
|
||||
</p>
|
||||
{relays.map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}>
|
||||
<label>
|
||||
Relay{" "}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.relay}
|
||||
style={{ width: "3.5rem" }}
|
||||
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{DIRECTION_LABELS[d]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(r.direction === "entry" || r.direction === "both") && (
|
||||
<label>
|
||||
Entry button on terminal{" "}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.button ?? ""}
|
||||
placeholder="—"
|
||||
style={{ width: "3.5rem" }}
|
||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{relays.length > 1 && (
|
||||
<button type="button" onClick={() => remove(i)}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}>
|
||||
+ Add relay
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Binding picker for readers/cameras/printers: choose the controller + relay this
|
||||
* device sits at. Direction is inherited from the chosen relay (shown). */
|
||||
function BindingPicker({
|
||||
controllers,
|
||||
controllerId,
|
||||
relay,
|
||||
onControllerChange,
|
||||
onRelayChange,
|
||||
}: {
|
||||
controllers: Assignment[];
|
||||
controllerId: string;
|
||||
relay: number | "";
|
||||
onControllerChange: (id: string) => void;
|
||||
onRelayChange: (relay: number) => void;
|
||||
}) {
|
||||
const controller = controllers.find((c) => c.id === controllerId);
|
||||
const relays: RelaySpec[] = controller
|
||||
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
|
||||
: [];
|
||||
const chosen = relays.find((r) => r.relay === relay);
|
||||
|
||||
return (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong>
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}>
|
||||
<label>
|
||||
Controller{" "}
|
||||
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
Choose…
|
||||
</option>
|
||||
{controllers.map((c) => {
|
||||
const host = (c.config as Record<string, unknown>).host;
|
||||
return (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.driverId}
|
||||
{typeof host === "string" ? ` (${host})` : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Relay{" "}
|
||||
<select
|
||||
value={relay === "" ? "" : String(relay)}
|
||||
disabled={!controller}
|
||||
onChange={(e) => onRelayChange(Number(e.target.value))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Choose…
|
||||
</option>
|
||||
{relays.map((r) => (
|
||||
<option key={r.relay} value={r.relay}>
|
||||
Relay {r.relay} ({DIRECTION_LABELS[r.direction]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />}
|
||||
</div>
|
||||
{controller && relays.length === 0 && (
|
||||
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}>
|
||||
This controller has no relays configured.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
||||
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
color,
|
||||
border: `1px solid ${color}`,
|
||||
borderRadius: 4,
|
||||
padding: "0 0.35rem",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{label ?? direction}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthBadge({ status }: { status: string }) {
|
||||
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
||||
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
||||
|
||||
Reference in New Issue
Block a user