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:
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
|
isDiscoverable,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
@@ -24,7 +25,44 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
|
|
||||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
// 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.
|
// Current setup status + assignments.
|
||||||
app.get(
|
app.get(
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
discoverDevices,
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
type Catalog,
|
type Catalog,
|
||||||
type CatalogEntry,
|
type CatalogEntry,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
|
type DiscoveredDevice,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
// First-run setup wizard (scaffold). The admin picks a device per category for
|
// First-run setup wizard (scaffold). The admin picks a device per category for a
|
||||||
// a lane from the driver catalog and fills in its connection config. Persisting
|
// lane from the driver catalog and fills in its connection config. Drivers that
|
||||||
// goes through POST /api/setup/assign (admin-only). The actual auth/token flow
|
// support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found
|
||||||
// and a multi-lane stepper come later — this proves the device-agnostic
|
// devices; selecting one auto-fills the config. See wiki/concepts/first-run-setup.md
|
||||||
// selection end to end. 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 }[] = [
|
const CATEGORIES: { key: DeviceCategory; title: string }[] = [
|
||||||
{ key: "access", title: "Access controller" },
|
{ key: "access", title: "Access controller" },
|
||||||
@@ -23,6 +28,7 @@ export function SetupWizard() {
|
|||||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||||
const [lane, setLane] = useState(1);
|
const [lane, setLane] = useState(1);
|
||||||
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
||||||
|
const [token, setToken] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,6 +41,7 @@ export function SetupWizard() {
|
|||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2>First-run setup</h2>
|
<h2>First-run setup</h2>
|
||||||
|
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||||
<label>
|
<label>
|
||||||
Lane{" "}
|
Lane{" "}
|
||||||
<input
|
<input
|
||||||
@@ -45,12 +52,25 @@ export function SetupWizard() {
|
|||||||
style={{ width: "4rem" }}
|
style={{ width: "4rem" }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
|
Admin token{" "}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="needed to scan / assign"
|
||||||
|
style={{ width: "60%" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{CATEGORIES.map(({ key, title }) => (
|
{CATEGORIES.map(({ key, title }) => (
|
||||||
<CategoryPicker
|
<CategoryPicker
|
||||||
key={key}
|
key={key}
|
||||||
title={title}
|
title={title}
|
||||||
entries={catalog[key]}
|
entries={catalog[key]}
|
||||||
|
discoverableIds={catalog.discoverable}
|
||||||
|
token={token}
|
||||||
selectedId={picked[key]}
|
selectedId={picked[key]}
|
||||||
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
||||||
/>
|
/>
|
||||||
@@ -62,15 +82,44 @@ export function SetupWizard() {
|
|||||||
function CategoryPicker({
|
function CategoryPicker({
|
||||||
title,
|
title,
|
||||||
entries,
|
entries,
|
||||||
|
discoverableIds,
|
||||||
|
token,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
|
discoverableIds: string[];
|
||||||
|
token: string;
|
||||||
selectedId: string | undefined;
|
selectedId: string | undefined;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const selected = entries.find((e) => e.id === selectedId);
|
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<Record<string, string | number>>({});
|
||||||
|
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [scanError, setScanError] = useState<string | null>(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<string, string | number>) }));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset style={{ marginTop: "1rem" }}>
|
<fieldset style={{ marginTop: "1rem" }}>
|
||||||
<legend>{title}</legend>
|
<legend>{title}</legend>
|
||||||
@@ -88,9 +137,36 @@ function CategoryPicker({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected && (
|
{selected && (
|
||||||
<div style={{ marginTop: "0.5rem" }}>
|
<div style={{ marginTop: "0.5rem" }}>
|
||||||
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p>
|
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p>
|
||||||
|
|
||||||
|
{canDiscover && (
|
||||||
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||||
|
<button type="button" onClick={scan} disabled={scanning || !token}>
|
||||||
|
{scanning ? "Scanning…" : "Scan for controllers"}
|
||||||
|
</button>
|
||||||
|
{!token && <span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>}
|
||||||
|
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
||||||
|
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
||||||
|
{found && found.length > 0 && (
|
||||||
|
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1rem" }}>
|
||||||
|
{found.map((d) => (
|
||||||
|
<li key={d.id} style={{ margin: "0.25rem 0" }}>
|
||||||
|
<button type="button" onClick={() => applyDiscovered(d)}>
|
||||||
|
Use
|
||||||
|
</button>{" "}
|
||||||
|
<strong>{d.label}</strong>{" "}
|
||||||
|
<HealthBadge status={d.health.status} />
|
||||||
|
{d.info?.firmware && <span style={{ color: "#666" }}> · fw {d.info.firmware}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selected.configFields.map((f) => (
|
{selected.configFields.map((f) => (
|
||||||
<div key={f.key} style={{ margin: "0.25rem 0" }}>
|
<div key={f.key} style={{ margin: "0.25rem 0" }}>
|
||||||
<label>
|
<label>
|
||||||
@@ -98,8 +174,9 @@ function CategoryPicker({
|
|||||||
{f.required ? " *" : ""}{" "}
|
{f.required ? " *" : ""}{" "}
|
||||||
<input
|
<input
|
||||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||||
defaultValue={f.default as string | number | undefined}
|
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
||||||
placeholder={f.help}
|
placeholder={f.help}
|
||||||
|
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,3 +186,8 @@ function CategoryPicker({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HealthBadge({ status }: { status: string }) {
|
||||||
|
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
||||||
|
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
||||||
|
}
|
||||||
|
|||||||
+28
-1
@@ -19,7 +19,10 @@ export interface CatalogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
||||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]>;
|
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||||
|
/** Driver ids that support LAN discovery. */
|
||||||
|
discoverable: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchCatalog(): Promise<Catalog> {
|
export async function fetchCatalog(): Promise<Catalog> {
|
||||||
const res = await fetch("/api/setup/catalog");
|
const res = await fetch("/api/setup/catalog");
|
||||||
@@ -27,6 +30,30 @@ export async function fetchCatalog(): Promise<Catalog> {
|
|||||||
return res.json() as Promise<Catalog>;
|
return res.json() as Promise<Catalog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredDevice {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
config: Record<string, string | number | boolean>;
|
||||||
|
info?: Record<string, string>;
|
||||||
|
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<DiscoveredDevice[]> {
|
||||||
|
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 {
|
export interface AssignBody {
|
||||||
lane: number;
|
lane: number;
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import uhppoted, { type Controller, type Ctx } from "uhppoted";
|
import uhppoted, { type Controller, type Ctx } from "uhppoted";
|
||||||
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
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
|
// `uhppoted` is CommonJS — import the default and destructure (named ESM imports
|
||||||
// don't resolve off a CJS module under NodeNext).
|
// don't resolve off a CJS module under NodeNext).
|
||||||
const { Config, getStatus, openDoor } = uhppoted;
|
const { Config, getDevices, getStatus, openDoor } = uhppoted;
|
||||||
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
|
||||||
import { hostField, stubLog } from "./common.js";
|
|
||||||
|
|
||||||
// Real UHPPOTE access-control driver, backed by the official `uhppoted` lib.
|
// Real UHPPOTE access-control driver, backed by the official `uhppoted` lib.
|
||||||
// Implements AccessControlDevice (intent-only relay — "a barrier is not a door";
|
// 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.
|
// assumes the controller sits on an isolated VLAN reachable only by the host.
|
||||||
// See wiki/concepts/uhppote-udp-protocol.md and network-isolation.md.
|
// 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 {
|
class UhppoteAccessControl implements AccessControlDevice {
|
||||||
readonly driverId = "uhppote";
|
readonly driverId = "uhppote";
|
||||||
readonly #controller: Controller;
|
readonly #controller: Controller;
|
||||||
@@ -29,20 +49,7 @@ class UhppoteAccessControl implements AccessControlDevice {
|
|||||||
// Addressable descriptor when a host is given; otherwise rely on UDP
|
// Addressable descriptor when a host is given; otherwise rely on UDP
|
||||||
// broadcast discovery by serial.
|
// broadcast discovery by serial.
|
||||||
this.#controller = address ? { id: serial, address, protocol } : serial;
|
this.#controller = address ? { id: serial, address, protocol } : serial;
|
||||||
|
this.#ctx = buildCtx(config.timeoutMs ? Number(config.timeoutMs) : 5000);
|
||||||
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",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
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",
|
id: "uhppote",
|
||||||
category: "access",
|
category: "access",
|
||||||
label: "UHPPOTE controller",
|
label: "UHPPOTE controller",
|
||||||
description:
|
description:
|
||||||
"UHPPOTE Wiegand 26/34 network controller via the official uhppoted lib. Unauthenticated UDP — isolate the VLAN.",
|
"UHPPOTE Wiegand 26/34 network controller via the official uhppoted lib. Unauthenticated UDP — isolate the VLAN.",
|
||||||
transports: ["udp", "tcp"],
|
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: [
|
configFields: [
|
||||||
{
|
{
|
||||||
key: "serial",
|
key: "serial",
|
||||||
|
|||||||
+17
@@ -24,6 +24,22 @@ declare module "uhppoted" {
|
|||||||
locale?: string;
|
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(
|
export function openDoor(
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
controller: Controller,
|
controller: Controller,
|
||||||
@@ -56,6 +72,7 @@ declare module "uhppoted" {
|
|||||||
// CommonJS default export (module.exports = { ... }). Destructure from this.
|
// CommonJS default export (module.exports = { ... }). Destructure from this.
|
||||||
const uhppoted: {
|
const uhppoted: {
|
||||||
Config: typeof Config;
|
Config: typeof Config;
|
||||||
|
getDevices: typeof getDevices;
|
||||||
openDoor: typeof openDoor;
|
openDoor: typeof openDoor;
|
||||||
getStatus: typeof getStatus;
|
getStatus: typeof getStatus;
|
||||||
getEvent: typeof getEvent;
|
getEvent: typeof getEvent;
|
||||||
|
|||||||
@@ -51,6 +51,33 @@ export type ReaderDriver = DeviceDriver<ReaderDevice>;
|
|||||||
export type CameraDriver = DeviceDriver<CameraDevice>;
|
export type CameraDriver = DeviceDriver<CameraDevice>;
|
||||||
export type PrinterDriver = DeviceDriver<PrinterDevice>;
|
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 {
|
class DeviceRegistry {
|
||||||
readonly #drivers = new Map<string, DeviceDriver>();
|
readonly #drivers = new Map<string, DeviceDriver>();
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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]]).
|
(mirrors the "mixable per lane" principle — see [[trust-boundary]], [[entry-exit-readers]]).
|
||||||
- Config is **validated against the driver's declared fields** before persisting.
|
- Config is **validated against the driver's declared fields** before persisting.
|
||||||
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
|
- 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
|
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
|
stored and referenced from the signed event as an **independent record** — a fraud-control input
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ each device's connection config.
|
|||||||
## Flow
|
## Flow
|
||||||
|
|
||||||
1. **Read the catalog** — `GET /api/setup/catalog` returns supported drivers per category (no
|
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
|
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
|
[[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
|
before persisting to the `lane_devices` table; unknown drivers / missing required fields are
|
||||||
|
|||||||
@@ -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`,
|
> design needs: `openDoor`, `getStatus`, and the event-log set (`getEvent`, `getEventIndex`,
|
||||||
> `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen` for auto-push — see
|
> `setEventIndex`, `recordSpecialEvents`) plus `setListener`/`listen` for auto-push — see
|
||||||
> [[event-log-ingestion]]. Transport defaults to **UDP** (broadcast `…:60000`), with optional
|
> [[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`
|
> per-call TCP on newer firmware. The driver also implements **[[device-discovery]]**
|
||||||
> shim) and tends to use UDP broadcast, which needs socket broadcast permission on the host.
|
> (`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
|
## What it is
|
||||||
|
|
||||||
|
|||||||
@@ -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-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
||||||
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
|
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
|
||||||
- [[first-run-setup]] — admin assigns devices per lane from the catalog at install.
|
- [[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.
|
- [[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.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||||
|
|||||||
+12
@@ -40,3 +40,15 @@ access driver (pulseOpen→openDoor, healthCheck→getStatus), registered in the
|
|||||||
catalog. CJS interop: default-import + destructure. Verified it builds, appears
|
catalog. CJS interop: default-import + destructure. Verified it builds, appears
|
||||||
in the catalog, and degrades to "offline" gracefully without hardware. Real
|
in the catalog, and degrades to "offline" gracefully without hardware. Real
|
||||||
on-VLAN test still pending.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user