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,40 @@
|
||||
import type { Role } from "@parking/shared";
|
||||
|
||||
// Local JWT auth helpers — fully local, no external identity provider
|
||||
// (offline-first). See wiki/entities/local-jwt-auth.md.
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: { sub: string; username: string; role: Role };
|
||||
user: { sub: string; username: string; role: Role };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||
* There is deliberately no fallback default — a missing, short, or placeholder
|
||||
* secret throws so the server never runs with forgeable tokens.
|
||||
*/
|
||||
export function requireJwtSecret(): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret || secret.length < 32 || /change.?me|insecure|dev-only/i.test(secret)) {
|
||||
throw new Error(
|
||||
"JWT_SECRET must be set to a strong random value (>=32 chars). " +
|
||||
"Generate one with: openssl rand -hex 32",
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler role guard. Authorization is a simple per-route role check — no
|
||||
* Casbin/RBAC engine needed at this scale. See wiki/entities/local-jwt-auth.md.
|
||||
*/
|
||||
export function requireRole(...allowed: Role[]) {
|
||||
return async (req: { jwtVerify: () => Promise<void>; user?: { role: Role } }) => {
|
||||
await req.jwtVerify();
|
||||
if (!req.user || !allowed.includes(req.user.role)) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
||||
import {
|
||||
registerBuiltinDrivers,
|
||||
registry,
|
||||
setDeviceLogSink,
|
||||
type DeviceCategory,
|
||||
} from "@parking/devices";
|
||||
import { requireRole } from "../auth.js";
|
||||
|
||||
// First-run setup API. The admin reads the driver catalog and assigns devices
|
||||
// per lane. See wiki/concepts/first-run-setup.md.
|
||||
|
||||
interface AssignBody {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
driverId: string;
|
||||
config: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
|
||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||
app.get("/api/setup/catalog", async () => registry.catalog());
|
||||
|
||||
// Current setup status + assignments.
|
||||
app.get(
|
||||
"/api/setup/state",
|
||||
{ preHandler: requireRole("admin") },
|
||||
async () => {
|
||||
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
||||
const assignments = await db.select().from(laneDevices).all();
|
||||
return { completedAt: state?.completedAt ?? null, assignments };
|
||||
},
|
||||
);
|
||||
|
||||
// Assign a device to a lane. Validates the chosen driver + config against the
|
||||
// registry before persisting; rejects unknown drivers / missing config.
|
||||
app.post<{ Body: AssignBody }>(
|
||||
"/api/setup/assign",
|
||||
{ preHandler: requireRole("admin") },
|
||||
async (req, reply) => {
|
||||
const { lane, category, driverId, config } = req.body;
|
||||
const driver = registry.get(driverId);
|
||||
if (!driver || driver.category !== category) {
|
||||
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
||||
}
|
||||
try {
|
||||
registry.create(driverId, config); // validates required fields
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
const row = {
|
||||
id: randomUUID(),
|
||||
lane,
|
||||
category,
|
||||
driverId,
|
||||
config,
|
||||
enabled: true,
|
||||
};
|
||||
await db.insert(laneDevices).values(row);
|
||||
return reply.code(201).send(row);
|
||||
},
|
||||
);
|
||||
|
||||
// Mark first-run setup complete.
|
||||
app.post(
|
||||
"/api/setup/complete",
|
||||
{ preHandler: requireRole("admin") },
|
||||
async () => {
|
||||
const completedAt = new Date().toISOString();
|
||||
await db
|
||||
.insert(setupState)
|
||||
.values({ id: 1, completedAt })
|
||||
.onConflictDoUpdate({ target: setupState.id, set: { completedAt } });
|
||||
return { completedAt };
|
||||
},
|
||||
);
|
||||
}
|
||||
+13
-38
@@ -1,39 +1,24 @@
|
||||
import jwt from "@fastify/jwt";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import type { Role } from "@parking/shared";
|
||||
import { createDb, type Db } from "@parking/db";
|
||||
import { requireJwtSecret } from "./auth.js";
|
||||
import { setupRoutes } from "./routes/setup.js";
|
||||
|
||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||
// plugins emitting onto a shared internal event bus; auth is fully local
|
||||
// (offline-first). See wiki/entities/fastify.md and local-jwt-auth.md.
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: { sub: string; username: string; role: Role };
|
||||
user: { sub: string; username: string; role: Role };
|
||||
}
|
||||
export interface BuildOptions {
|
||||
db?: Db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||
* There is deliberately no fallback default — a missing, short, or placeholder
|
||||
* secret throws so the server never runs with forgeable tokens.
|
||||
*/
|
||||
function requireJwtSecret(): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret || secret.length < 32 || /change.?me|insecure|dev-only/i.test(secret)) {
|
||||
throw new Error(
|
||||
"JWT_SECRET must be set to a strong random value (>=32 chars). " +
|
||||
"Generate one with: openssl rand -hex 32",
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
export async function buildServer(): Promise<FastifyInstance> {
|
||||
export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> {
|
||||
const app = Fastify({
|
||||
logger: { level: process.env.LOG_LEVEL ?? "info" },
|
||||
});
|
||||
|
||||
const db = opts.db ?? createDb();
|
||||
|
||||
// Local JWT signing with a local secret — no external identity provider.
|
||||
// Fail fast rather than fall back to a known default: a booth machine started
|
||||
// without a real secret would sign tokens anyone could forge (incl. an admin
|
||||
@@ -45,21 +30,11 @@ export async function buildServer(): Promise<FastifyInstance> {
|
||||
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
|
||||
// TODO: register device-driver plugins (packages/devices adapters),
|
||||
// the append-only event-log routes, and the role-guarded admin API.
|
||||
// Device-agnostic setup: the admin selects devices per lane from the driver
|
||||
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
||||
await setupRoutes(app, db);
|
||||
|
||||
// TODO: device-driver runtime plugins, append-only event-log routes, login.
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler role guard. Authorization is a simple per-route role check — no
|
||||
* Casbin/RBAC engine needed at this scale. See wiki/entities/local-jwt-auth.md.
|
||||
*/
|
||||
export function requireRole(...allowed: Role[]) {
|
||||
return async (req: { jwtVerify: () => Promise<void>; user?: { role: Role } }) => {
|
||||
await req.jwtVerify();
|
||||
if (!req.user || !allowed.includes(req.user.role)) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -30,3 +30,9 @@ interface RelayDevice {
|
||||
|
||||
Note the `RelayDevice` expresses **intent only** — see the [[barrier-not-a-door]] safety
|
||||
principle. The choice of *which* adapter to trust is the [[trust-boundary]] decision.
|
||||
|
||||
> **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the
|
||||
> supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras),
|
||||
> and the admin assigns one per lane during [[first-run-setup]]. Adding hardware support = one
|
||||
> more registered driver, no business-logic change. (The implemented interfaces add a
|
||||
> `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, architecture, devices, configurable]
|
||||
sources: [parking-system-architecture]
|
||||
updated: 2026-06-15
|
||||
---
|
||||
|
||||
# Device Registry (selectable drivers)
|
||||
|
||||
How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to
|
||||
"**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported
|
||||
**drivers**, grouped by category; the [[first-run-setup]] UI reads it so an
|
||||
operator can choose a device per lane and fill in its connection config.
|
||||
|
||||
> Implementation-derived (from `packages/devices`), not the source doc.
|
||||
|
||||
## The four categories
|
||||
|
||||
| Category | Examples (drivers) | Interface |
|
||||
| --- | --- | --- |
|
||||
| **access** | ZKTeco, ESP32 relay (also UHPPOTE) | `AccessControlDevice` — intent-only relay ([[barrier-not-a-door]]) |
|
||||
| **reader** | Wiegand-into-controller, TCP/IP reader | `ReaderDevice` — RF/optical; two paths ([[entry-exit-readers]]) |
|
||||
| **camera** | Hikvision, Dahua | `CameraDevice` — entry/exit snapshot-on-event |
|
||||
| **printer** | (ticket dispenser / booth printer) | `PrinterDevice` |
|
||||
|
||||
## How a driver is described
|
||||
|
||||
Each driver declares: a stable `id`, its `category`, a human `label`/`description`, the
|
||||
`transports` it uses (`tcp-ip`, `wiegand`, …), a list of **`configFields`** (host, port,
|
||||
credentials, selects — what the setup UI renders), and a `create(config)` **factory** that
|
||||
validates config and returns a live adapter. Adding hardware support = registering one more
|
||||
driver; **no business-logic change** — this is the [[device-adapter-pattern]] made selectable.
|
||||
|
||||
## Why a registry (not hard-coded wiring)
|
||||
|
||||
- The admin chooses between **multiple devices per category** at install time, per lane
|
||||
(mirrors the "mixable per lane" principle — see [[trust-boundary]], [[entry-exit-readers]]).
|
||||
- Config is **validated against the driver's declared fields** before persisting.
|
||||
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
|
||||
|
||||
Cameras are modelled as **snapshot-on-event**: the host requests an image at entry/exit; it's
|
||||
stored and referenced from the signed event as an **independent record** — a fraud-control input
|
||||
to the [[append-only-event-chain]] (cf. [[lpr-camera]] as the recognition-based identity source).
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, architecture, devices, admin]
|
||||
sources: [parking-system-architecture]
|
||||
updated: 2026-06-15
|
||||
---
|
||||
|
||||
# First-Run Setup (device selection)
|
||||
|
||||
The admin install flow that makes the system **device-agnostic in practice**: on first run, an
|
||||
admin assigns devices **per lane** by choosing from the [[device-registry]] catalog and entering
|
||||
each device's connection config.
|
||||
|
||||
> Implementation-derived (from `apps/server` + `apps/web`), not the source doc.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Read the catalog** — `GET /api/setup/catalog` returns supported drivers per category (no
|
||||
secrets, just schema). The web `SetupWizard` renders a picker + the driver's config fields.
|
||||
2. **Assign per lane** — `POST /api/setup/assign` (admin-only, role-guarded; see
|
||||
[[local-jwt-auth]]). The server validates the chosen driver + config against the registry
|
||||
before persisting to the `lane_devices` table; unknown drivers / missing required fields are
|
||||
rejected.
|
||||
3. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`.
|
||||
|
||||
## Config granularity
|
||||
|
||||
Organized **per lane** — each lane gets an access controller, reader(s), and camera(s), each with
|
||||
its own connection settings. Matches the architecture's "mixable per lane" reality (a lane can
|
||||
serve permit holders via [[wiegand]] and casual via host-side reads on one relay — see
|
||||
[[entry-exit-readers]]).
|
||||
|
||||
## Security notes
|
||||
|
||||
- The assign/state/complete endpoints require the **admin** role ([[local-jwt-auth]]).
|
||||
- Device **credentials are stored in `lane_devices.config`** — protect at rest
|
||||
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
|
||||
@@ -50,6 +50,8 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
|
||||
|
||||
## Concepts — device architecture & safety
|
||||
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
||||
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
|
||||
- [[first-run-setup]] — admin assigns devices per lane from the catalog at install.
|
||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||
|
||||
+10
@@ -18,3 +18,13 @@ property). Marked [[esp32-custom-controller]] `status: deferred` per decision no
|
||||
implement device-level auth for now (access control stays on UHPPOTE + network
|
||||
isolation); noted in [[open-questions]] #6. Updated [[local-jwt-auth]] (hardened secret
|
||||
handling + 8h expiry, asymmetric-key pointer) and the index.
|
||||
|
||||
## [2026-06-15] decision | Device-agnostic registry + first-run setup
|
||||
From app work. Made the [[device-adapter-pattern]] selectable: added a
|
||||
[[device-registry]] (catalog of drivers per category) and a [[first-run-setup]]
|
||||
flow so the admin picks a device per lane at install. Categories: access
|
||||
(ZKTeco / ESP32 relay), reader (Wiegand / TCP-IP), camera (Hikvision / Dahua,
|
||||
snapshot-on-event), printer. Added a `CameraDevice` interface; new `lane_devices`
|
||||
+ `setup_state` tables (migration 0001); admin-only setup endpoints. Stub drivers
|
||||
for now (no real vendor protocols yet). Verified catalog + assign + validation +
|
||||
auth end to end.
|
||||
|
||||
Reference in New Issue
Block a user