Device-agnostic driver registry + first-run setup

Make the device-adapter pattern selectable so the admin chooses hardware at
install — per lane, from a catalog of supported drivers. Adding a device =
registering one more driver; no business-logic change.

packages/devices:
- interfaces.ts: AccessControlDevice / ReaderDevice / CameraDevice / PrinterDevice
  (adds CameraDevice for entry/exit snapshot-on-event; access relay stays
  intent-only per "a barrier is not a door").
- registry.ts: driver catalog with per-driver config fields + factory, config
  validation, and a catalog payload for the setup UI.
- drivers/: stub adapters — access (zkteco, esp32-relay), reader (wiegand,
  tcp-ip), camera (hikvision, dahua). Real vendor protocols TBD.

packages/db:
- lane_devices + setup_state tables (migration 0001); re-export query helpers.

apps/server:
- routes/setup.ts: GET /api/setup/catalog (public schema), and admin-only
  /assign, /state, /complete with registry validation before persisting.
- extract auth.ts (requireJwtSecret, requireRole, JWT type aug).

apps/web:
- SetupWizard scaffold + api client: pick a driver per category for a lane,
  render its config fields.

wiki: device-registry + first-run-setup concept pages; cross-link from
device-adapter-pattern; index + log updated.

Verified: full turbo build (5/5); catalog lists all drivers; admin assign
persists; missing-config and no-token requests are rejected.
This commit is contained in:
2026-06-14 07:59:46 +02:00
parent 7de5c74500
commit 72ba4099ea
24 changed files with 1138 additions and 71 deletions
+3 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { SetupWizard } from "./SetupWizard.js";
// Operator UI shell. Plain React (no admin framework) — the operator UI is
// simple enough that a framework's abstractions cost more than they save.
@@ -15,12 +16,12 @@ export function App() {
}, []);
return (
<main style={{ fontFamily: "system-ui", padding: "2rem" }}>
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
<h1>Parking System</h1>
<p>Operator console — scaffold.</p>
<p>
API health: <strong>{health}</strong>
</p>
<SetupWizard />
</main>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import {
fetchCatalog,
type Catalog,
type CatalogEntry,
type DeviceCategory,
} from "./api.js";
// First-run setup wizard (scaffold). The admin picks a device per category for
// a lane from the driver catalog and fills in its connection config. Persisting
// goes through POST /api/setup/assign (admin-only). The actual auth/token flow
// and a multi-lane stepper come later — this proves the device-agnostic
// selection end to end. See wiki/concepts/first-run-setup.md.
const CATEGORIES: { key: DeviceCategory; title: string }[] = [
{ key: "access", title: "Access controller" },
{ key: "reader", title: "Reader" },
{ key: "camera", title: "Camera (entry/exit snapshot)" },
{ key: "printer", title: "Printer" },
];
export function SetupWizard() {
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [lane, setLane] = useState(1);
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
}, []);
if (error) return <p style={{ color: "crimson" }}>Failed to load catalog: {error}</p>;
if (!catalog) return <p>Loading device catalog…</p>;
return (
<section>
<h2>First-run setup</h2>
<label>
Lane{" "}
<input
type="number"
min={1}
value={lane}
onChange={(e) => setLane(Number(e.target.value))}
style={{ width: "4rem" }}
/>
</label>
{CATEGORIES.map(({ key, title }) => (
<CategoryPicker
key={key}
title={title}
entries={catalog[key]}
selectedId={picked[key]}
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
/>
))}
</section>
);
}
function CategoryPicker({
title,
entries,
selectedId,
onSelect,
}: {
title: string;
entries: CatalogEntry[];
selectedId: string | undefined;
onSelect: (id: string) => void;
}) {
const selected = entries.find((e) => e.id === selectedId);
return (
<fieldset style={{ marginTop: "1rem" }}>
<legend>{title}</legend>
{entries.length === 0 ? (
<em>No drivers registered.</em>
) : (
<select value={selectedId ?? ""} onChange={(e) => onSelect(e.target.value)}>
<option value="" disabled>
Choose a device…
</option>
{entries.map((e) => (
<option key={e.id} value={e.id}>
{e.label} ({e.transports.join(", ")})
</option>
))}
</select>
)}
{selected && (
<div style={{ marginTop: "0.5rem" }}>
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p>
{selected.configFields.map((f) => (
<div key={f.key} style={{ margin: "0.25rem 0" }}>
<label>
{f.label}
{f.required ? " *" : ""}{" "}
<input
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
defaultValue={f.default as string | number | undefined}
placeholder={f.help}
/>
</label>
</div>
))}
</div>
)}
</fieldset>
);
}
+48
View File
@@ -0,0 +1,48 @@
// Thin API client for the operator/admin UI.
export interface ConfigField {
key: string;
label: string;
type: "string" | "number" | "boolean" | "host" | "port" | "secret" | "select";
required: boolean;
default?: string | number | boolean;
options?: { value: string; label: string }[];
help?: string;
}
export interface CatalogEntry {
id: string;
label: string;
description: string;
transports: string[];
configFields: ConfigField[];
}
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
export type Catalog = Record<DeviceCategory, CatalogEntry[]>;
export async function fetchCatalog(): Promise<Catalog> {
const res = await fetch("/api/setup/catalog");
if (!res.ok) throw new Error(`catalog: ${res.status}`);
return res.json() as Promise<Catalog>;
}
export interface AssignBody {
lane: number;
category: DeviceCategory;
driverId: string;
config: Record<string, string | number | boolean>;
}
export async function assignDevice(token: string, body: AssignBody): Promise<unknown> {
const res = await fetch("/api/setup/assign", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(msg.error ?? `assign: ${res.status}`);
}
return res.json();
}