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 { 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<void> {
|
||||
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(
|
||||
|
||||
@@ -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<Catalog | null>(null);
|
||||
const [lane, setLane] = useState(1);
|
||||
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,22 +41,36 @@ export function SetupWizard() {
|
||||
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>
|
||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<label>
|
||||
Lane{" "}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={lane}
|
||||
onChange={(e) => setLane(Number(e.target.value))}
|
||||
style={{ width: "4rem" }}
|
||||
/>
|
||||
</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 }) => (
|
||||
<CategoryPicker
|
||||
key={key}
|
||||
title={title}
|
||||
entries={catalog[key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
token={token}
|
||||
selectedId={picked[key]}
|
||||
onSelect={(id) => 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<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 (
|
||||
<fieldset style={{ marginTop: "1rem" }}>
|
||||
<legend>{title}</legend>
|
||||
@@ -88,9 +137,36 @@ function CategoryPicker({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<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) => (
|
||||
<div key={f.key} style={{ margin: "0.25rem 0" }}>
|
||||
<label>
|
||||
@@ -98,8 +174,9 @@ function CategoryPicker({
|
||||
{f.required ? " *" : ""}{" "}
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -109,3 +186,8 @@ function CategoryPicker({
|
||||
</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 Catalog = Record<DeviceCategory, CatalogEntry[]>;
|
||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||
/** Driver ids that support LAN discovery. */
|
||||
discoverable: string[];
|
||||
};
|
||||
|
||||
export async function fetchCatalog(): Promise<Catalog> {
|
||||
const res = await fetch("/api/setup/catalog");
|
||||
@@ -27,6 +30,30 @@ export async function fetchCatalog(): 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 {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
|
||||
Reference in New Issue
Block a user