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 = {