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
+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>;
}