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
@@ -0,0 +1,14 @@
CREATE TABLE `lane_devices` (
`id` text PRIMARY KEY NOT NULL,
`lane` integer NOT NULL,
`category` text NOT NULL,
`driver_id` text NOT NULL,
`config` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `setup_state` (
`id` integer PRIMARY KEY NOT NULL,
`completed_at` text
);
+245
View File
@@ -0,0 +1,245 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1073123c-0df9-4109-84bf-7f23b95ec5bd",
"prevId": "721bbb8f-b929-4018-9420-0ae75b03ff93",
"tables": {
"events": {
"name": "events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"events_index_unique": {
"name": "events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lane_devices": {
"name": "lane_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1781389618205,
"tag": "0000_absent_rocket_raccoon",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781416636098,
"tag": "0001_cuddly_maria_hill",
"breakpoints": true
}
]
}
+3
View File
@@ -3,6 +3,9 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "./schema.js";
export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package).
export { eq, and, desc, sql } from "drizzle-orm";
/**
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+29
View File
@@ -37,5 +37,34 @@ export const events = sqliteTable("events", {
signature: text("signature").notNull(),
});
// Per-lane device assignments chosen by the admin during first-run setup.
// One row per (lane, category, instance). `driverId` references a driver in the
// @parking/devices registry; `config` is that driver's JSON config (host, port,
// credentials…). Lets the system stay device-agnostic and admin-configurable.
// See wiki/concepts/device-registry.md and first-run-setup.md.
export const laneDevices = sqliteTable("lane_devices", {
id: text("id").primaryKey(),
lane: integer("lane").notNull(),
category: text("category", {
enum: ["access", "reader", "camera", "printer"],
}).notNull(),
driverId: text("driver_id").notNull(),
// Driver-specific connection config as JSON (validated against the driver's
// declared config fields before persisting). Secrets live here — protect at rest.
config: text("config", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// Tracks whether first-run setup has been completed (single-row marker).
export const setupState = sqliteTable("setup_state", {
id: integer("id").primaryKey(), // always 1
completedAt: text("completed_at"),
});
export type UserRow = typeof users.$inferSelect;
export type EventRow = typeof events.$inferSelect;
export type LaneDeviceRow = typeof laneDevices.$inferSelect;
export type SetupStateRow = typeof setupState.$inferSelect;
+49
View File
@@ -0,0 +1,49 @@
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
import type { AccessDriver, DeviceConfig } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
// Access-control drivers. Each implements AccessControlDevice (intent-only relay
// — "a barrier is not a door"). STUBS: connect/log only, no real protocol yet.
class StubAccessControl implements AccessControlDevice {
constructor(
readonly driverId: string,
protected readonly config: DeviceConfig,
) {}
async connect(): Promise<void> {
stubLog(this.driverId, `connect ${this.config.host}:${this.config.port}`);
}
async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect");
}
async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub" };
}
async pulseOpen(doorId: number): Promise<void> {
// Intent only — never times/forces a close against a vehicle.
stubLog(this.driverId, `pulseOpen door=${doorId}`);
}
async getDoorStatus(): Promise<"open" | "closed"> {
return "closed";
}
}
export const zktecoDriver: AccessDriver = {
id: "zkteco",
category: "access",
label: "ZKTeco controller",
description: "ZKTeco network access controller (TCP/IP). Reader + relay.",
transports: ["tcp-ip"],
configFields: [hostField, portField(4370), { key: "doors", label: "Door count", type: "number", required: true, default: 4 }],
create: (c) => new StubAccessControl("zkteco", c),
};
export const esp32RelayDriver: AccessDriver = {
id: "esp32-relay",
category: "access",
label: "ESP32 relay controller",
description: "Simple ESP32-based relay controller over the network.",
transports: ["tcp-ip"],
configFields: [hostField, portField(80), { key: "doors", label: "Relay channels", type: "number", required: true, default: 1 }],
create: (c) => new StubAccessControl("esp32-relay", c),
};
+57
View File
@@ -0,0 +1,57 @@
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js";
import type { CameraDriver, DeviceConfig } from "../registry.js";
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
// Camera drivers — entry/exit snapshot-on-event. The image is stored and
// referenced from the signed event as an independent fraud-control record.
// Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL. STUBS only.
class StubCamera implements CameraDevice {
constructor(
readonly driverId: string,
protected readonly config: DeviceConfig,
protected readonly snapshotPath: string,
) {}
async connect(): Promise<void> {
stubLog(this.driverId, `connect ${this.config.host} (${this.snapshotPath})`);
}
async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect");
}
async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub" };
}
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
// Real driver: GET http(s)://host{snapshotPath}, store bytes, return ref.
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction}`);
return {
imageRef: `stub://${this.driverId}/lane${ctx.lane}/${ctx.direction}/${Date.now()}`,
contentType: "image/jpeg",
capturedAt: new Date().toISOString(),
};
}
}
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, { key: "channel", label: "Channel", type: "number" as const, required: false, default: 1 }];
export const hikvisionDriver: CameraDriver = {
id: "hikvision",
category: "camera",
label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI.",
transports: ["tcp-ip"],
configFields: cameraConfigFields,
// /ISAPI/Streaming/channels/<id>/picture
create: (c) => new StubCamera("hikvision", c, "/ISAPI/Streaming/channels/101/picture"),
};
export const dahuaDriver: CameraDriver = {
id: "dahua",
category: "camera",
label: "Dahua camera",
description: "Dahua snapshot via CGI.",
transports: ["tcp-ip"],
configFields: cameraConfigFields,
// /cgi-bin/snapshot.cgi?channel=<n>
create: (c) => new StubCamera("dahua", c, "/cgi-bin/snapshot.cgi"),
};
+46
View File
@@ -0,0 +1,46 @@
import type { ConfigField } from "../registry.js";
// Shared config-field presets so drivers stay terse and consistent.
export const hostField: ConfigField = {
key: "host",
label: "IP address / host",
type: "host",
required: true,
help: "On the isolated device VLAN. See wiki/concepts/network-isolation.md.",
};
export function portField(def: number): ConfigField {
return { key: "port", label: "Port", type: "port", required: true, default: def };
}
export const usernameField: ConfigField = {
key: "username",
label: "Username",
type: "string",
required: false,
};
export const passwordField: ConfigField = {
key: "password",
label: "Password",
type: "secret",
required: false,
};
/**
* Sink for stub/diagnostic device messages. Defaults to a no-op so the package
* has no host/runtime dependency; the server sets this to its Fastify logger.
*/
export type DeviceLogSink = (line: string) => void;
let sink: DeviceLogSink = () => {};
export function setDeviceLogSink(fn: DeviceLogSink): void {
sink = fn;
}
/** Stubs log instead of performing real I/O. Replaced with real protocols later. */
export function stubLog(driverId: string, msg: string): void {
sink(`[device:${driverId}] ${msg}`);
}
+30
View File
@@ -0,0 +1,30 @@
// Register all bundled drivers into the singleton registry. Importing this
// module wires the catalog. Add a new device by registering it here.
import { registry } from "../registry.js";
import { esp32RelayDriver, zktecoDriver } from "./access.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
let registered = false;
/** Idempotently register the built-in drivers. Called once at server startup. */
export function registerBuiltinDrivers(): void {
if (registered) return;
registered = true;
registry.register(zktecoDriver);
registry.register(esp32RelayDriver);
registry.register(wiegandReaderDriver);
registry.register(tcpipReaderDriver);
registry.register(hikvisionDriver);
registry.register(dahuaDriver);
}
export {
zktecoDriver,
esp32RelayDriver,
wiegandReaderDriver,
tcpipReaderDriver,
hikvisionDriver,
dahuaDriver,
};
+60
View File
@@ -0,0 +1,60 @@
import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js";
import type { DeviceConfig, ReaderDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
// access controller directly (autonomous); TCP-IP readers are seen host-side.
// See wiki/concepts/entry-exit-readers.md. STUBS only.
class StubReader implements ReaderDevice {
#cb: ((r: ReaderEvent) => void) | null = null;
constructor(
readonly driverId: string,
protected readonly config: DeviceConfig,
) {}
async connect(): Promise<void> {
stubLog(this.driverId, "connect");
}
async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect");
}
async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub" };
}
onRead(cb: (r: ReaderEvent) => void): void {
this.#cb = cb;
stubLog(this.driverId, "onRead handler registered");
}
/** Test hook for stubs — real drivers emit from hardware events. */
protected emit(r: ReaderEvent): void {
this.#cb?.(r);
}
}
export const wiegandReaderDriver: ReaderDriver = {
id: "wiegand-reader",
category: "reader",
label: "Wiegand reader (into controller)",
description:
"RF/optical reader wired Wiegand 26/34 into the access controller's reader port. Autonomous offline decisions.",
transports: ["wiegand"],
configFields: [
{ key: "door", label: "Controller reader port / door", type: "number", required: true, default: 1 },
{ key: "format", label: "Wiegand format", type: "select", required: true, default: "26", options: [
{ value: "26", label: "Wiegand 26" },
{ value: "34", label: "Wiegand 34" },
] },
],
create: (c) => new StubReader("wiegand-reader", c),
};
export const tcpipReaderDriver: ReaderDriver = {
id: "tcpip-reader",
category: "reader",
label: "TCP/IP reader (host-side)",
description:
"Network RF/optical reader seen only by the host; host decides and commands the relay.",
transports: ["tcp-ip"],
configFields: [hostField, portField(9000)],
create: (c) => new StubReader("tcpip-reader", c),
};
+7 -31
View File
@@ -1,33 +1,9 @@
// Device-agnostic adapter interfaces.
// @parking/devices — device-agnostic adapters + the driver registry that lets
// an admin select between supported devices at first-run setup.
//
// Business logic talks ONLY to these interfaces, never to a device SDK. Swapping
// hardware means writing a new adapter that implements one of these — nothing
// else changes. See wiki/concepts/device-adapter-pattern.md.
//
// SAFETY: a barrier is NOT a door. The relay interface expresses INTENT only
// (`pulseOpen`); it never times or forces a close against a vehicle. Physical
// safety (induction loops, anti-crush, auto-reverse) lives in the barrier
// operator's own firmware. See wiki/concepts/barrier-not-a-door.md.
// See wiki/concepts/device-adapter-pattern.md and device-registry.md.
export interface CardReaderDevice {
connect(): Promise<void>;
onCardRead(cb: (cardNumber: string, door: number) => void): void;
disconnect(): Promise<void>;
}
export interface TicketData {
readonly ticketId: string;
readonly lane: number;
readonly issuedAt: string; // ISO-8601
}
export interface PrinterDevice {
printTicket(data: TicketData): Promise<void>;
checkStatus(): Promise<"ready" | "offline" | "paper_out">;
}
export interface RelayDevice {
/** Express intent to open. NEVER timed/forced closed against a vehicle. */
pulseOpen(doorId: number): Promise<void>;
getDoorStatus(doorId: number): Promise<"open" | "closed">;
}
export * from "./interfaces.js";
export * from "./registry.js";
export { registerBuiltinDrivers } from "./drivers/index.js";
export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js";
+80
View File
@@ -0,0 +1,80 @@
// Device-agnostic adapter interfaces.
//
// Business logic talks ONLY to these interfaces, never to a device SDK. Swapping
// hardware means writing a new adapter that implements one of these — nothing
// else changes. See wiki/concepts/device-adapter-pattern.md.
//
// SAFETY: a barrier is NOT a door. The relay interface expresses INTENT only
// (`pulseOpen`); it never times or forces a close against a vehicle. Physical
// safety (induction loops, anti-crush, auto-reverse) lives in the barrier
// operator's own firmware. See wiki/concepts/barrier-not-a-door.md.
/** The four device categories an admin configures per lane. */
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
/** Lifecycle shared by every device adapter. */
export interface Device {
/** Stable id of the driver that produced this instance (e.g. "zkteco"). */
readonly driverId: string;
connect(): Promise<void>;
disconnect(): Promise<void>;
/** Liveness/health probe used by setup ("Test connection") and monitoring. */
healthCheck(): Promise<DeviceHealth>;
}
export interface DeviceHealth {
readonly status: "ready" | "offline" | "degraded";
readonly detail?: string;
}
// --- Access control (barrier relay) --------------------------------------
// ZKTeco, an ESP32 relay controller, UHPPOTE, etc. all implement this.
export interface AccessControlDevice extends Device {
/** Express intent to open. NEVER timed/forced closed against a vehicle. */
pulseOpen(doorId: number): Promise<void>;
getDoorStatus(doorId: number): Promise<"open" | "closed">;
}
// --- Readers (RF / optical; TCP-IP or Wiegand) ---------------------------
export interface ReaderDevice extends Device {
/** Emits when a credential is read (card number, plate, QR payload, …). */
onRead(cb: (read: ReaderEvent) => void): void;
}
export interface ReaderEvent {
readonly value: string;
readonly kind: "card" | "plate" | "qr" | "ticket";
readonly door: number;
readonly at: string; // ISO-8601
}
// --- Cameras (entry/exit snapshot) ---------------------------------------
// Hikvision / Dahua implement this. Snapshot-on-event: the host asks for an
// image at entry/exit; the image is stored and referenced from the signed event
// as an independent record (anti-fraud). See wiki/concepts/append-only-event-chain.
export interface CameraDevice extends Device {
captureSnapshot(ctx: SnapshotContext): Promise<Snapshot>;
}
export interface SnapshotContext {
readonly lane: number;
readonly direction: "entry" | "exit";
}
export interface Snapshot {
/** Storage reference for the captured image (file path / blob id). */
readonly imageRef: string;
readonly contentType: string;
readonly capturedAt: string; // ISO-8601
}
// --- Printers (ticket dispenser / booth printer) -------------------------
export interface TicketData {
readonly ticketId: string;
readonly lane: number;
readonly issuedAt: string; // ISO-8601
}
export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>;
}
+116
View File
@@ -0,0 +1,116 @@
// Driver registry — the catalog of selectable device drivers.
//
// This is what makes the system admin-configurable: each category (access /
// reader / camera / printer) has multiple drivers, and the first-run setup UI
// reads this catalog to let the admin pick one per lane and fill in its config.
// Adding support for a new device = registering one more driver here; no
// business-logic changes. See wiki/concepts/device-registry.md.
import type {
AccessControlDevice,
CameraDevice,
Device,
DeviceCategory,
PrinterDevice,
ReaderDevice,
} from "./interfaces.js";
/** A single configurable connection field shown in the setup wizard. */
export interface ConfigField {
readonly key: string;
readonly label: string;
readonly type: "string" | "number" | "boolean" | "host" | "port" | "secret" | "select";
readonly required: boolean;
readonly default?: string | number | boolean;
/** For type "select". */
readonly options?: readonly { value: string; label: string }[];
readonly help?: string;
}
/** Opaque per-instance config the admin fills in (host, port, credentials…). */
export type DeviceConfig = Record<string, string | number | boolean>;
/**
* A driver: metadata describing a supported device model/family, the config
* fields the admin must supply, and a factory that builds a live adapter.
*/
export interface DeviceDriver<T extends Device = Device> {
readonly id: string; // stable, e.g. "zkteco", "esp32-relay", "hikvision"
readonly category: DeviceCategory;
readonly label: string; // human name for the picker, e.g. "ZKTeco controller"
readonly description: string;
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
readonly transports: readonly string[];
readonly configFields: readonly ConfigField[];
/** Build a live adapter instance from validated config. */
create(config: DeviceConfig): T;
}
export type AccessDriver = DeviceDriver<AccessControlDevice>;
export type ReaderDriver = DeviceDriver<ReaderDevice>;
export type CameraDriver = DeviceDriver<CameraDevice>;
export type PrinterDriver = DeviceDriver<PrinterDevice>;
class DeviceRegistry {
readonly #drivers = new Map<string, DeviceDriver>();
register(driver: DeviceDriver): void {
if (this.#drivers.has(driver.id)) {
throw new Error(`duplicate driver id: ${driver.id}`);
}
this.#drivers.set(driver.id, driver);
}
/** All drivers, optionally filtered by category (used by the setup catalog). */
list(category?: DeviceCategory): DeviceDriver[] {
const all = [...this.#drivers.values()];
return category ? all.filter((d) => d.category === category) : all;
}
get(id: string): DeviceDriver | undefined {
return this.#drivers.get(id);
}
/** Validate config against a driver's declared fields and build the adapter. */
create(id: string, config: DeviceConfig): Device {
const driver = this.#drivers.get(id);
if (!driver) throw new Error(`unknown driver: ${id}`);
for (const field of driver.configFields) {
if (field.required && config[field.key] === undefined) {
throw new Error(`driver ${id}: missing required config "${field.key}"`);
}
}
return driver.create(config);
}
/** Catalog payload for the setup UI — drivers grouped by category, no secrets. */
catalog() {
const byCategory: Record<DeviceCategory, CatalogEntry[]> = {
access: [],
reader: [],
camera: [],
printer: [],
};
for (const d of this.#drivers.values()) {
byCategory[d.category].push({
id: d.id,
label: d.label,
description: d.description,
transports: d.transports,
configFields: d.configFields,
});
}
return byCategory;
}
}
export interface CatalogEntry {
readonly id: string;
readonly label: string;
readonly description: string;
readonly transports: readonly string[];
readonly configFields: readonly ConfigField[];
}
/** Singleton registry. Drivers self-register on import (see ./drivers). */
export const registry = new DeviceRegistry();