From a0e0fd911820f7234efb45f1a4595377084de0f9 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 14 Jun 2026 08:21:27 +0200 Subject: [PATCH] Add device discovery (UHPPOTE LAN scan) to setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UHPPOTE controllers self-announce via UDP broadcast, but the frontend had no way to find them — the admin had to type the serial blind. Add a generic discovery capability and surface it in the setup wizard. packages/devices: - DiscoverableDriver capability + DiscoveredDevice type + isDiscoverable() guard on the registry (optional, so any driver can opt in). - uhppote driver implements discover() via uhppoted getDevices (UDP broadcast), mapping each controller's serial/IP/firmware into a DiscoveredDevice; extract shared buildCtx(). apps/server: - GET /api/setup/discover/:driverId (admin-only): runs discover() and health-checks each found device so reachability shows before assigning. - catalog now returns a `discoverable` driver-id list. apps/web: - SetupWizard "Scan for controllers" button for discoverable drivers; lists found devices with health badges; selecting one auto-fills serial + host. api client gains discoverDevices(). wiki: new device-discovery concept; cross-link from registry/setup/uhppote; note the broadcast-permission (EACCES) deployment caveat; index + log. Verified: catalog flags uhppote discoverable; discover runs and fails gracefully without hardware; non-discoverable driver -> 400; missing token -> 401. --- apps/server/src/routes/setup.ts | 40 +++++- apps/web/src/SetupWizard.tsx | 114 +++++++++++++++--- apps/web/src/api.ts | 29 ++++- .../devices/src/drivers/access-uhppote.ts | 62 +++++++--- packages/devices/src/drivers/uhppoted.d.ts | 17 +++ packages/devices/src/registry.ts | 27 +++++ wiki/concepts/device-discovery.md | 46 +++++++ wiki/concepts/device-registry.md | 2 + wiki/concepts/first-run-setup.md | 3 +- wiki/entities/uhppote-controller.md | 6 +- wiki/index.md | 1 + wiki/log.md | 12 ++ 12 files changed, 320 insertions(+), 39 deletions(-) create mode 100644 wiki/concepts/device-discovery.md diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index b497489..057c16c 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { eq, laneDevices, setupState, type Db } from "@parking/db"; import { + isDiscoverable, registerBuiltinDrivers, registry, setDeviceLogSink, @@ -24,7 +25,44 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { setDeviceLogSink((line) => app.log.info(line)); // Catalog of selectable drivers per category (no secrets — schema only). - app.get("/api/setup/catalog", async () => registry.catalog()); + // `discoverable` flags drivers that can scan the LAN (e.g. UHPPOTE). + app.get("/api/setup/catalog", async () => { + const catalog = registry.catalog(); + const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id); + return { ...catalog, discoverable }; + }); + + // Scan the LAN for devices a driver can discover (UHPPOTE UDP broadcast, etc). + // Each found device is health-checked so the admin sees reachability before + // assigning. Admin-only. See wiki/concepts/device-discovery.md. + app.get<{ Params: { driverId: string } }>( + "/api/setup/discover/:driverId", + { preHandler: requireRole("admin") }, + async (req, reply) => { + const driver = registry.get(req.params.driverId); + if (!driver) return reply.code(404).send({ error: `unknown driver: ${req.params.driverId}` }); + if (!isDiscoverable(driver)) { + return reply.code(400).send({ error: `driver ${driver.id} does not support discovery` }); + } + try { + const found = await driver.discover(); + const withHealth = await Promise.all( + found.map(async (d) => { + let health: { status: string; detail?: string }; + try { + health = await driver.create(d.config).healthCheck(); + } catch (err) { + health = { status: "offline", detail: (err as Error).message }; + } + return { ...d, health }; + }), + ); + return { driverId: driver.id, devices: withHealth }; + } catch (err) { + return reply.code(502).send({ error: `discovery failed: ${(err as Error).message}` }); + } + }, + ); // Current setup status + assignments. app.get( diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 9cb48c4..ea654c8 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -1,16 +1,21 @@ import { useEffect, useState } from "react"; import { + discoverDevices, fetchCatalog, type Catalog, type CatalogEntry, type DeviceCategory, + type DiscoveredDevice, } 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. +// 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. Drivers that +// support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found +// devices; selecting one auto-fills the config. See wiki/concepts/first-run-setup.md +// and device-discovery.md. +// +// NOTE: discovery + assign require an admin token. Wiring the real login flow is +// a follow-up; for now a token is read from a field so the scan can be exercised. const CATEGORIES: { key: DeviceCategory; title: string }[] = [ { key: "access", title: "Access controller" }, @@ -23,6 +28,7 @@ export function SetupWizard() { const [catalog, setCatalog] = useState(null); const [lane, setLane] = useState(1); const [picked, setPicked] = useState>>({}); + const [token, setToken] = useState(""); const [error, setError] = useState(null); useEffect(() => { @@ -35,22 +41,36 @@ export function SetupWizard() { return (

First-run setup

- +
+ + +
{CATEGORIES.map(({ key, title }) => ( setPicked((p) => ({ ...p, [key]: id }))} /> @@ -62,15 +82,44 @@ export function SetupWizard() { function CategoryPicker({ title, entries, + discoverableIds, + token, selectedId, onSelect, }: { title: string; entries: CatalogEntry[]; + discoverableIds: string[]; + token: string; selectedId: string | undefined; onSelect: (id: string) => void; }) { const selected = entries.find((e) => e.id === selectedId); + const canDiscover = selected != null && discoverableIds.includes(selected.id); + + // Config values (auto-filled by discovery, editable by hand). + const [config, setConfig] = useState>({}); + const [found, setFound] = useState(null); + const [scanning, setScanning] = useState(false); + const [scanError, setScanError] = useState(null); + + async function scan() { + if (!selected) return; + setScanning(true); + setScanError(null); + try { + setFound(await discoverDevices(token, selected.id)); + } catch (e) { + setScanError((e as Error).message); + } finally { + setScanning(false); + } + } + + function applyDiscovered(d: DiscoveredDevice) { + setConfig((c) => ({ ...c, ...(d.config as Record) })); + } + return (
{title} @@ -88,9 +137,36 @@ function CategoryPicker({ ))} )} + {selected && (

{selected.description}

+ + {canDiscover && ( +
+ + {!token && enter an admin token to scan} + {scanError && {scanError}} + {found && found.length === 0 &&

No controllers found on the LAN.

} + {found && found.length > 0 && ( +
    + {found.map((d) => ( +
  • + {" "} + {d.label}{" "} + + {d.info?.firmware && · fw {d.info.firmware}} +
  • + ))} +
+ )} +
+ )} + {selected.configFields.map((f) => (
@@ -109,3 +186,8 @@ function CategoryPicker({
); } + +function HealthBadge({ status }: { status: string }) { + const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626"; + return ● {status}; +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 5419ec3..4cf46e3 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -19,7 +19,10 @@ export interface CatalogEntry { } export type DeviceCategory = "access" | "reader" | "camera" | "printer"; -export type Catalog = Record; +export type Catalog = Record & { + /** Driver ids that support LAN discovery. */ + discoverable: string[]; +}; export async function fetchCatalog(): Promise { const res = await fetch("/api/setup/catalog"); @@ -27,6 +30,30 @@ export async function fetchCatalog(): Promise { return res.json() as Promise; } +export interface DiscoveredDevice { + id: string; + label: string; + config: Record; + info?: Record; + health: { status: string; detail?: string }; +} + +/** Scan the LAN for devices a driver can discover (e.g. UHPPOTE). Admin-only. */ +export async function discoverDevices( + token: string, + driverId: string, +): Promise { + const res = await fetch(`/api/setup/discover/${driverId}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const msg = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(msg.error ?? `discover: ${res.status}`); + } + const body = (await res.json()) as { devices: DiscoveredDevice[] }; + return body.devices; +} + export interface AssignBody { lane: number; category: DeviceCategory; diff --git a/packages/devices/src/drivers/access-uhppote.ts b/packages/devices/src/drivers/access-uhppote.ts index e29d1a0..847a8d0 100644 --- a/packages/devices/src/drivers/access-uhppote.ts +++ b/packages/devices/src/drivers/access-uhppote.ts @@ -1,11 +1,15 @@ import uhppoted, { type Controller, type Ctx } from "uhppoted"; import type { AccessControlDevice, DeviceHealth } from "../interfaces.js"; +import type { + AccessDriver, + DeviceConfig, + DiscoveredDevice, +} from "../registry.js"; +import { hostField, stubLog } from "./common.js"; // `uhppoted` is CommonJS — import the default and destructure (named ESM imports // don't resolve off a CJS module under NodeNext). -const { Config, getStatus, openDoor } = uhppoted; -import type { AccessDriver, DeviceConfig } from "../registry.js"; -import { hostField, stubLog } from "./common.js"; +const { Config, getDevices, getStatus, openDoor } = uhppoted; // Real UHPPOTE access-control driver, backed by the official `uhppoted` lib. // Implements AccessControlDevice (intent-only relay — "a barrier is not a door"; @@ -16,6 +20,22 @@ import { hostField, stubLog } from "./common.js"; // assumes the controller sits on an isolated VLAN reachable only by the host. // See wiki/concepts/uhppote-udp-protocol.md and network-isolation.md. +/** Shared uhppoted context (UDP broadcast on :60000, listener on :60001). */ +function buildCtx(timeoutMs = 5000): Ctx { + return { + config: new Config( + "parking", + "0.0.0.0", + "255.255.255.255:60000", + "0.0.0.0:60001", + timeoutMs, + [], + false, + ), + locale: "en-US", + }; +} + class UhppoteAccessControl implements AccessControlDevice { readonly driverId = "uhppote"; readonly #controller: Controller; @@ -29,20 +49,7 @@ class UhppoteAccessControl implements AccessControlDevice { // Addressable descriptor when a host is given; otherwise rely on UDP // broadcast discovery by serial. this.#controller = address ? { id: serial, address, protocol } : serial; - - const timeout = config.timeoutMs ? Number(config.timeoutMs) : 5000; - this.#ctx = { - config: new Config( - "parking", - "0.0.0.0", - "255.255.255.255:60000", - "0.0.0.0:60001", - timeout, - [], - false, - ), - locale: "en-US", - }; + this.#ctx = buildCtx(config.timeoutMs ? Number(config.timeoutMs) : 5000); } async connect(): Promise { @@ -83,13 +90,32 @@ class UhppoteAccessControl implements AccessControlDevice { } } -export const uhppoteDriver: AccessDriver = { +export const uhppoteDriver: AccessDriver & { + discover(): Promise; +} = { id: "uhppote", category: "access", label: "UHPPOTE controller", description: "UHPPOTE Wiegand 26/34 network controller via the official uhppoted lib. Unauthenticated UDP — isolate the VLAN.", transports: ["udp", "tcp"], + // UDP broadcast discovery (get-devices): every controller on the LAN answers + // with its serial, IP, and firmware. See wiki/concepts/device-discovery.md. + async discover(): Promise { + const found = await getDevices(buildCtx(3000)); + return found.map((d) => ({ + id: String(d.device.serialNumber), + label: `UHPPOTE ${d.device.serialNumber} @ ${d.device.address}`, + config: { serial: d.device.serialNumber, host: d.device.address, protocol: "udp" }, + info: { + address: d.device.address, + netmask: d.device.netmask, + gateway: d.device.gateway, + MAC: d.device.MAC, + firmware: d.device.version, + }, + })); + }, configFields: [ { key: "serial", diff --git a/packages/devices/src/drivers/uhppoted.d.ts b/packages/devices/src/drivers/uhppoted.d.ts index c15e808..4b29145 100644 --- a/packages/devices/src/drivers/uhppoted.d.ts +++ b/packages/devices/src/drivers/uhppoted.d.ts @@ -24,6 +24,22 @@ declare module "uhppoted" { locale?: string; } + export interface DiscoveredController { + deviceId: number; + device: { + serialNumber: number; + address: string; + netmask: string; + gateway: string; + MAC: string; + version: string; + date: string; + }; + } + + /** UDP broadcast discovery — returns every controller answering on the LAN. */ + export function getDevices(ctx: Ctx): Promise; + export function openDoor( ctx: Ctx, controller: Controller, @@ -56,6 +72,7 @@ declare module "uhppoted" { // CommonJS default export (module.exports = { ... }). Destructure from this. const uhppoted: { Config: typeof Config; + getDevices: typeof getDevices; openDoor: typeof openDoor; getStatus: typeof getStatus; getEvent: typeof getEvent; diff --git a/packages/devices/src/registry.ts b/packages/devices/src/registry.ts index c29d5ba..9cf22dc 100644 --- a/packages/devices/src/registry.ts +++ b/packages/devices/src/registry.ts @@ -51,6 +51,33 @@ export type ReaderDriver = DeviceDriver; export type CameraDriver = DeviceDriver; export type PrinterDriver = DeviceDriver; +/** A device found on the LAN by a driver's discovery scan. */ +export interface DiscoveredDevice { + /** Identifier to pre-fill (e.g. UHPPOTE serial number). */ + readonly id: string; + readonly label: string; + /** Config values to auto-fill into the setup form (host, serial, …). */ + readonly config: DeviceConfig; + /** Extra info to show the admin (firmware, MAC, netmask, …). */ + readonly info?: Record; +} + +/** + * Optional capability: a driver that can find devices on the LAN. UHPPOTE + * implements this via the protocol's UDP broadcast discovery (get-devices); + * cameras (ONVIF) and others may add it later. See wiki/concepts/device-discovery.md. + */ +export interface DiscoverableDriver { + discover(): Promise; +} + +/** Type guard: does this driver support discovery? */ +export function isDiscoverable( + driver: DeviceDriver, +): driver is DeviceDriver & DiscoverableDriver { + return typeof (driver as Partial).discover === "function"; +} + class DeviceRegistry { readonly #drivers = new Map(); diff --git a/wiki/concepts/device-discovery.md b/wiki/concepts/device-discovery.md new file mode 100644 index 0000000..39ce32e --- /dev/null +++ b/wiki/concepts/device-discovery.md @@ -0,0 +1,46 @@ +--- +type: concept +tags: [parking, architecture, devices, setup] +sources: [parking-system-architecture] +updated: 2026-06-15 +--- + +# Device Discovery + +An optional driver capability: **find devices on the LAN** so the admin doesn't have to type +connection details by hand during [[first-run-setup]]. Modeled generically so any driver can +opt in. + +> Implementation-derived (from `@parking/devices` + setup API/UI), not the source doc. + +## The capability + +A driver may implement `DiscoverableDriver` — `discover() => DiscoveredDevice[]`. Each found +device carries an `id`, a `label`, a `config` blob to **auto-fill** the setup form, and `info` +(firmware, MAC, …). The [[device-registry]]'s `isDiscoverable()` guard lets the system treat it +as optional; the setup catalog returns a `discoverable` list of driver ids. + +## UHPPOTE discovery + +The [[uhppote-controller]] supports discovery natively: a **UDP broadcast** (`get-devices` on +`255.255.255.255:60000`) that **every controller on the LAN answers** with its serial, IP, +netmask, gateway, MAC, firmware version, and date. The official `uhppoted` lib exposes this as +`getDevices(ctx)`; the `uhppote` driver maps each result into a `DiscoveredDevice` (serial → id, +IP → host). + +## Flow + +1. The setup catalog flags `uhppote` as discoverable. +2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only). +3. The server runs `discover()` and **health-checks each found device** so the admin sees + reachability before assigning. +4. Selecting a result **auto-fills serial + host**; the admin then assigns it to a lane. + +## Deployment notes + +- UHPPOTE discovery is a **broadcast** — the host socket needs broadcast permission (a raw + `send EACCES …:60000` means the OS blocked it). Works on the isolated device VLAN + ([[network-isolation]]) where the controller and host share an L2 segment. +- Discovery shares the same unauthenticated UDP exposure as everything else UHPPOTE — another + reason the controllers live on an isolated VLAN ([[uhppote-udp-protocol]]). +- Cameras (Hikvision/Dahua via ONVIF/WS-Discovery) could implement the same interface later. diff --git a/wiki/concepts/device-registry.md b/wiki/concepts/device-registry.md index 99ba696..d4f22ad 100644 --- a/wiki/concepts/device-registry.md +++ b/wiki/concepts/device-registry.md @@ -37,6 +37,8 @@ driver; **no business-logic change** — this is the [[device-adapter-pattern]] (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. +- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan + the LAN instead of typing connection details — UHPPOTE does this today. 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 diff --git a/wiki/concepts/first-run-setup.md b/wiki/concepts/first-run-setup.md index 414a6c4..a013179 100644 --- a/wiki/concepts/first-run-setup.md +++ b/wiki/concepts/first-run-setup.md @@ -16,7 +16,8 @@ each device's connection config. ## 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. + secrets, just schema) plus a `discoverable` list. The web `SetupWizard` renders a picker + the + driver's config fields, and a **Scan** button for discoverable drivers ([[device-discovery]]). 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 diff --git a/wiki/entities/uhppote-controller.md b/wiki/entities/uhppote-controller.md index 0f6f009..86115dc 100644 --- a/wiki/entities/uhppote-controller.md +++ b/wiki/entities/uhppote-controller.md @@ -17,8 +17,10 @@ is UHPPOTE now → ZKTeco later (see [[bom]]). (See [[parking-system-architectur > design needs: `openDoor`, `getStatus`, and the event-log set (`getEvent`, `getEventIndex`, > `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen` for auto-push — see > [[event-log-ingestion]]. Transport defaults to **UDP** (broadcast `…:60000`), with optional -> per-call TCP on newer firmware. Note: the lib pulls one trivial extra dep (the npm `os` -> shim) and tends to use UDP broadcast, which needs socket broadcast permission on the host. +> per-call TCP on newer firmware. The driver also implements **[[device-discovery]]** +> (`getDevices` broadcast) so the setup wizard can scan for controllers. Note: the lib pulls one +> trivial extra dep (the npm `os` shim) and uses UDP broadcast, which needs socket broadcast +> permission on the host. ## What it is diff --git a/wiki/index.md b/wiki/index.md index 509837d..79570d8 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -52,6 +52,7 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records. - [[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. +- [[device-discovery]] — optional driver capability to scan the LAN (UHPPOTE UDP broadcast). - [[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. diff --git a/wiki/log.md b/wiki/log.md index f68d3ae..08ccdcd 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -40,3 +40,15 @@ access driver (pulseOpen→openDoor, healthCheck→getStatus), registered in the catalog. CJS interop: default-import + destructure. Verified it builds, appears in the catalog, and degrades to "offline" gracefully without hardware. Real on-VLAN test still pending. + +## [2026-06-15] feature | Device discovery (UHPPOTE scan in setup) +The frontend had no way to find a UHPPOTE — but the controllers self-announce via +UDP broadcast. Added a generic [[device-discovery]] capability: optional +`DiscoverableDriver.discover()` on the registry, implemented by the `uhppote` +driver via `getDevices`. New admin-only `GET /api/setup/discover/:driverId` +(health-checks each found device); catalog now returns a `discoverable` list. +SetupWizard gains a "Scan for controllers" button that lists found devices with +health badges and auto-fills serial + host on selection. Verified: catalog flags +uhppote; discover runs and fails gracefully without hardware (broadcast EACCES); +non-discoverable driver → 400; no token → 401. Modeled generically so cameras +(ONVIF) can add discovery later.