feat(setup): "Test ANPR" probe on ANPR-enabled cameras
Adds a bottom-of-modal "Test ANPR" button (shown only when a camera's Plate recognition opt-in is checked) that captures a live snapshot off the camera and runs it through the vision service, reporting the plate read + confidence + elapsed time, or which stage failed. - New POST /api/setup/test-anpr: builds the camera from the unsaved config (no DB write/device change, like /test), captures a snapshot, runs vision.analyze. Fail-soft like the runtime path (snapshot.ts): camera/vision failures are reported results, never a 500. - Thread the existing VisionClient into setupRoutes; add an isCamera() type guard to @parking/devices. - Web: testAnpr() client + AnprTestResult; button, hint, result line. - i18n keys in sq + en (Catalog parity). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -4,16 +4,19 @@ import { eq, devices, setupState, type Db } from "@parking/db";
|
||||
import {
|
||||
hasPreconditions,
|
||||
hasPushConfig,
|
||||
isCamera,
|
||||
isDiscoverable,
|
||||
isHardenable,
|
||||
registerBuiltinDrivers,
|
||||
registry,
|
||||
setDeviceLogSink,
|
||||
type CameraDevice,
|
||||
type DeviceCategory,
|
||||
type DeviceConfig,
|
||||
} from "@parking/devices";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||
import type { VisionClient } from "../vision-client.js";
|
||||
|
||||
// First-run setup API. The admin reads the driver catalog and assigns devices
|
||||
// per lane. See wiki/concepts/first-run-setup.md.
|
||||
@@ -172,7 +175,11 @@ async function configureDevice(
|
||||
return { config: fullConfig, warnings: hardenWarnings };
|
||||
}
|
||||
|
||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
export async function setupRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
vision?: VisionClient | null,
|
||||
): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
|
||||
@@ -261,6 +268,72 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// Test ANPR end-to-end on a camera config WITHOUT saving: capture a live snapshot
|
||||
// off the camera and run it through the vision (ANPR) service, reporting whether a
|
||||
// plate was extracted, the read, and how long it took. Lets the admin verify the
|
||||
// camera→vision pipeline before committing the camera's `anpr` opt-in. Advisory +
|
||||
// fail-soft, exactly like the runtime path (snapshot.ts): a vision failure is a
|
||||
// reported "no plate", never a 500. See wiki/entities/opencv-anpr-service.md.
|
||||
app.post<{ Body: TestBody }>(
|
||||
"/api/setup/test-anpr",
|
||||
{ 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}` });
|
||||
if (driver.category !== "camera") {
|
||||
return reply.code(400).send({ error: `driver ${driverId} is not a camera` });
|
||||
}
|
||||
if (!vision?.enabled) {
|
||||
// The vision service is off (VISION_ENABLED unset) — there's nothing to test
|
||||
// against. Report it cleanly so the UI can say "enable vision first".
|
||||
return reply.send({ ok: false, reason: "vision-disabled" });
|
||||
}
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(driverId, config);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
if (!isCamera(device)) {
|
||||
return reply.code(400).send({ error: `driver ${driverId} cannot capture snapshots` });
|
||||
}
|
||||
|
||||
// 1) Grab a frame off the camera. A camera/network failure here is the failure
|
||||
// we're testing for — report it, don't 500.
|
||||
const startedAt = Date.now();
|
||||
let shot: Awaited<ReturnType<CameraDevice["captureSnapshot"]>>;
|
||||
try {
|
||||
shot = await device.captureSnapshot({ direction: "entry" });
|
||||
} catch (err) {
|
||||
return reply.send({
|
||||
ok: false,
|
||||
reason: "snapshot-failed",
|
||||
detail: (err as Error).message,
|
||||
tookMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Run the same advisory analyze the runtime path uses. `analyze` is fail-soft
|
||||
// (null on any error/timeout) and applies the confidence floor.
|
||||
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||
const tookMs = Date.now() - startedAt;
|
||||
if (!result || !result.plate) {
|
||||
return reply.send({ ok: false, reason: "no-plate", tookMs });
|
||||
}
|
||||
return reply.send({
|
||||
ok: true,
|
||||
plate: result.plate.text.trim().toUpperCase(),
|
||||
confidence: result.plate.confidence,
|
||||
region: result.plate.region ?? null,
|
||||
lowConfidence: result.lowConfidence,
|
||||
modelVersion: result.modelVersion,
|
||||
tookMs,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Candidate backend IPs the device can push to, for a given device host. The
|
||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||
|
||||
@@ -93,11 +93,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
await userRoutes(app, db);
|
||||
await roleRoutes(app, db);
|
||||
|
||||
// Vision (ANPR) client — built early so the device monitor can include the vision
|
||||
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
|
||||
// snapshot→analyze probe on an ANPR-enabled camera. Opt-in (VISION_ENABLED) +
|
||||
// fail-soft; advisory only. See wiki/entities/opencv-anpr-service.md.
|
||||
const visionClient = new VisionClient(app.log);
|
||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||
|
||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||
await setupRoutes(app, db);
|
||||
await setupRoutes(app, db, visionClient);
|
||||
|
||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||
@@ -112,12 +119,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
app.addHook("onReady", async () => printerMonitor.start());
|
||||
app.addHook("onClose", async () => printerMonitor.stop());
|
||||
|
||||
// Vision (ANPR) client — built early so the device monitor can include the vision
|
||||
// service's health in the footer. Opt-in (VISION_ENABLED) + fail-soft; advisory only.
|
||||
// See wiki/entities/opencv-anpr-service.md.
|
||||
const visionClient = new VisionClient(app.log);
|
||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||
|
||||
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
||||
// cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
|
||||
// /health, and feeds the booth's device-status footer over the WS. Read-only.
|
||||
|
||||
Reference in New Issue
Block a user