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