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