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,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),
|
||||
};
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
Reference in New Issue
Block a user