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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user