Setup wizard: Test connection + Save & configure

Two-step device setup so the admin verifies before committing — and never touches
the device's own web UI.

- POST /api/setup/test (admin-only): healthCheck + checkPreconditions, no save and
  no device change. Returns device health + precondition issues.
- assign (Save) now also runs fixPreconditions (e.g. disables input_link_relay so
  a button press doesn't auto-fire its relay) before configuring the input push.
  Closes a gap where an assigned device could still auto-open. Fails the save with
  no DB row if device configuration fails (no orphan/half-configured rows).
- SetupWizard: wires config fields -> Test connection (health badge + precondition
  warnings) -> Save & configure; editing config resets prior test/save status.

Verified in-browser against the real device: Test -> ● ready + preconditions OK;
Save -> row persisted AND the device's Input Link URL written (push path matches
the saved device id). wiki/first-run-setup updated.
This commit is contained in:
2026-06-14 16:59:36 +02:00
parent 3294f188dd
commit 0375227a16
5 changed files with 212 additions and 36 deletions
+69 -27
View File
@@ -2,6 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, laneDevices, setupState, type Db } from "@parking/db";
import {
hasPreconditions,
hasPushConfig,
isDiscoverable,
registerBuiltinDrivers,
@@ -22,6 +23,11 @@ interface AssignBody {
config: Record<string, string | number | boolean>;
}
interface TestBody {
driverId: string;
config: Record<string, string | number | boolean>;
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
@@ -80,12 +86,36 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
// Assign a device to a lane. Validates the chosen driver + config against the
// registry before persisting; rejects unknown drivers / missing config.
// For push-capable devices (e.g. Dingtian), the backend generates a secret
// token, configures the device to HTTP-push input events to us (no manual URL
// entry by the admin), and stores the token so the push endpoint can verify
// it. See wiki/concepts/device-input-flow.md.
// Test a device config WITHOUT saving or changing the device: validate the
// config, probe reachability (healthCheck), and report preconditions
// (e.g. input_link_relay state). Lets the admin verify before committing.
app.post<{ Body: TestBody }>(
"/api/setup/test",
{ preHandler: adminGuard },
async (req, reply) => {
const { driverId, config } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
let device;
try {
device = registry.create(driverId, config);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
const health = await device.healthCheck();
const preconditions = hasPreconditions(device)
? await device.checkPreconditions()
: { ok: true, issues: [] };
return { health, preconditions };
},
);
// Assign a device to a lane. Validates the chosen driver + config, configures
// the device (fix preconditions + set up Digest-authenticated input push — no
// manual device-web-UI step by the admin), then persists. Fails the save if
// the device can't be configured. See wiki/concepts/device-input-flow.md.
app.post<{ Body: AssignBody }>(
"/api/setup/assign",
{ preHandler: adminGuard },
@@ -106,35 +136,47 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
return reply.code(400).send({ error: (err as Error).message });
}
// If the device supports input push, set it up now: generate Digest creds,
// configure the device to push to us, store the creds. Done before
// persisting so we don't store half-configured rows.
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
const backendIp = backendIpForDevice(host);
if (!backendIp) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). Set BACKEND_HOST_IP.`,
});
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first), and
// 2. set up input push (Digest creds + push URLs).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return reply.code(502).send({
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
});
}
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
try {
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
const backendIp = backendIpForDevice(host);
if (!backendIp) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). Set BACKEND_HOST_IP.`,
});
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: backendIp,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
} catch (err) {
return reply
.code(502)
.send({ error: `device push config failed: ${(err as Error).message}` });
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
}
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
} catch (err) {
return reply
.code(502)
.send({ error: `device configuration failed: ${(err as Error).message}` });
}
const row = {
+100 -2
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from "react";
import { useState, useEffect } from "react";
import {
assignDevice,
discoverDevices,
fetchCatalog,
testDevice,
type Catalog,
type CatalogEntry,
type DeviceCategory,
type DiscoveredDevice,
type TestResult,
} from "./api.js";
// First-run setup wizard (scaffold). The admin picks a device per category for a
@@ -54,6 +57,8 @@ export function SetupWizard() {
{CATEGORIES.map(({ key, title }) => (
<CategoryPicker
key={key}
lane={lane}
category={key}
title={title}
entries={catalog[key]}
discoverableIds={catalog.discoverable}
@@ -66,12 +71,16 @@ export function SetupWizard() {
}
function CategoryPicker({
lane,
category,
title,
entries,
discoverableIds,
selectedId,
onSelect,
}: {
lane: number;
category: DeviceCategory;
title: string;
entries: CatalogEntry[];
discoverableIds: string[];
@@ -83,6 +92,12 @@ function CategoryPicker({
// Config values (auto-filled by discovery, editable by hand).
const [config, setConfig] = useState<Record<string, string | number>>({});
const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
@@ -102,6 +117,53 @@ function CategoryPicker({
function applyDiscovered(d: DiscoveredDevice) {
setConfig((c) => ({ ...c, ...(d.config as Record<string, string | number>) }));
resetStatus();
}
// Config the user actually entered, merged over driver defaults.
function mergedConfig(): Record<string, string | number> {
const out: Record<string, string | number> = {};
for (const f of selected?.configFields ?? []) {
const v = config[f.key] ?? (f.default as string | number | undefined);
if (v !== undefined && v !== "") out[f.key] = v;
}
return out;
}
// Editing config invalidates a prior test/save.
function resetStatus() {
setTested(null);
setTestError(null);
setSaved(false);
setSaveError(null);
}
async function test() {
if (!selected) return;
setTesting(true);
setTestError(null);
setTested(null);
try {
setTested(await testDevice(selected.id, mergedConfig()));
} catch (e) {
setTestError((e as Error).message);
} finally {
setTesting(false);
}
}
async function save() {
if (!selected) return;
setSaving(true);
setSaveError(null);
try {
await assignDevice({ lane, category, driverId: selected.id, config: mergedConfig() });
setSaved(true);
} catch (e) {
setSaveError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
@@ -159,11 +221,47 @@ function CategoryPicker({
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
placeholder={f.help}
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
</label>
</div>
))}
{/* Test (no save/no device change) then Save (configures + persists). */}
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="button" onClick={test} disabled={testing}>
{testing ? "Testing…" : "Test connection"}
</button>
<button type="button" onClick={save} disabled={saving || saved}>
{saving ? "Saving…" : saved ? "Saved ✓" : "Save & configure"}
</button>
</div>
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
{tested && (
<div style={{ margin: "0.5rem 0 0" }}>
<div>
Device: <HealthBadge status={tested.health.status} />
{tested.health.detail && <span style={{ color: "#666" }}> — {tested.health.detail}</span>}
</div>
{tested.preconditions.ok ? (
<div style={{ color: "#16a34a" }}>● preconditions OK</div>
) : (
tested.preconditions.issues.map((i) => (
<div key={i.key} style={{ color: "#d97706" }}>
⚠ {i.message}
{i.fixable && <span style={{ color: "#666" }}> (auto-fixed on save)</span>}
</div>
))
)}
</div>
)}
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
{saved && <p style={{ color: "#16a34a", margin: "0.5rem 0 0" }}>Saved and configured ✓</p>}
</div>
)}
</fieldset>
+21 -2
View File
@@ -118,13 +118,32 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
return body.devices;
}
export type DeviceConfig = Record<string, string | number | boolean>;
export interface TestResult {
health: { status: string; detail?: string };
preconditions: {
ok: boolean;
issues: { key: string; message: string; fixable: boolean }[];
};
}
/** Test a device config (reachability + preconditions) without saving. */
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
return apiFetch<TestResult>("/api/setup/test", {
method: "POST",
body: JSON.stringify({ driverId, config }),
});
}
export interface AssignBody {
lane: number;
category: DeviceCategory;
driverId: string;
config: Record<string, string | number | boolean>;
config: DeviceConfig;
}
export function assignDevice(body: AssignBody): Promise<unknown> {
/** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise<{ id: string }> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
}