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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user