Add device discovery (UHPPOTE LAN scan) to setup

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.
This commit is contained in:
2026-06-14 08:21:27 +02:00
parent 7438c0bdc2
commit a0e0fd9118
12 changed files with 320 additions and 39 deletions
+44 -18
View File
@@ -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<void> {
@@ -83,13 +90,32 @@ class UhppoteAccessControl implements AccessControlDevice {
}
}
export const uhppoteDriver: AccessDriver = {
export const uhppoteDriver: AccessDriver & {
discover(): Promise<DiscoveredDevice[]>;
} = {
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<DiscoveredDevice[]> {
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",
+17
View File
@@ -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<DiscoveredController[]>;
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;
+27
View File
@@ -51,6 +51,33 @@ export type ReaderDriver = DeviceDriver<ReaderDevice>;
export type CameraDriver = DeviceDriver<CameraDevice>;
export type PrinterDriver = DeviceDriver<PrinterDevice>;
/** 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<string, string>;
}
/**
* 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<DiscoveredDevice[]>;
}
/** Type guard: does this driver support discovery? */
export function isDiscoverable(
driver: DeviceDriver,
): driver is DeviceDriver & DiscoverableDriver {
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
}
class DeviceRegistry {
readonly #drivers = new Map<string, DeviceDriver>();