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 {
|
import {
|
||||||
hasPreconditions,
|
hasPreconditions,
|
||||||
hasPushConfig,
|
hasPushConfig,
|
||||||
|
isCamera,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
isHardenable,
|
isHardenable,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
|
type CameraDevice,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
type DeviceConfig,
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.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
|
// First-run setup API. The admin reads the driver catalog and assigns devices
|
||||||
// per lane. See wiki/concepts/first-run-setup.md.
|
// per lane. See wiki/concepts/first-run-setup.md.
|
||||||
@@ -172,7 +175,11 @@ async function configureDevice(
|
|||||||
return { config: fullConfig, warnings: hardenWarnings };
|
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();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
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
|
// 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
|
// 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.
|
// 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 userRoutes(app, db);
|
||||||
await roleRoutes(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
|
// 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
|
// 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.
|
// 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.
|
// 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),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// 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("onReady", async () => printerMonitor.start());
|
||||||
app.addHook("onClose", async () => printerMonitor.stop());
|
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/
|
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
||||||
// cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
|
// 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.
|
// /health, and feeds the booth's device-status footer over the WS. Read-only.
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
fetchBackendIps,
|
fetchBackendIps,
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
fetchState,
|
fetchState,
|
||||||
|
testAnpr,
|
||||||
testDevice,
|
testDevice,
|
||||||
unassignDevice,
|
unassignDevice,
|
||||||
|
type AnprTestResult,
|
||||||
type Assignment,
|
type Assignment,
|
||||||
type BackendIpCandidate,
|
type BackendIpCandidate,
|
||||||
type Catalog,
|
type Catalog,
|
||||||
@@ -352,6 +354,10 @@ function DeviceForm({
|
|||||||
const [tested, setTested] = useState<TestResult | null>(null);
|
const [tested, setTested] = useState<TestResult | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
||||||
|
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
||||||
|
const [anprTesting, setAnprTesting] = useState(false);
|
||||||
|
const [anprError, setAnprError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||||||
@@ -442,6 +448,8 @@ function DeviceForm({
|
|||||||
setTested(null);
|
setTested(null);
|
||||||
setTestError(null);
|
setTestError(null);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
setAnprResult(null);
|
||||||
|
setAnprError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function test() {
|
async function test() {
|
||||||
@@ -458,6 +466,23 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// End-to-end ANPR probe: capture a frame off this camera and run the vision service
|
||||||
|
// on it, reporting plate + time (or the failure stage). Only meaningful for an
|
||||||
|
// ANPR-enabled camera; never blocks save.
|
||||||
|
async function testAnprNow() {
|
||||||
|
if (!selected) return;
|
||||||
|
setAnprTesting(true);
|
||||||
|
setAnprError(null);
|
||||||
|
setAnprResult(null);
|
||||||
|
try {
|
||||||
|
setAnprResult(await testAnpr(selected.id, mergedScalarConfig()));
|
||||||
|
} catch (e) {
|
||||||
|
setAnprError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setAnprTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
// Bound devices must point at a controller relay (binding is optional in the
|
// Bound devices must point at a controller relay (binding is optional in the
|
||||||
@@ -641,6 +666,40 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* CAMERA + ANPR on: a bottom-of-modal end-to-end probe — capture a frame and
|
||||||
|
run the vision service on it, reporting the plate read + how long it took. */}
|
||||||
|
{isCamera && anpr && (
|
||||||
|
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={testAnprNow} disabled={anprTesting}>
|
||||||
|
{anprTesting ? t("setup.anprTesting") : t("setup.testAnpr")}
|
||||||
|
</button>
|
||||||
|
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
|
||||||
|
|
||||||
|
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
||||||
|
{anprResult &&
|
||||||
|
(anprResult.ok ? (
|
||||||
|
<div className="mt-2 text-[12px] text-term-green">
|
||||||
|
{t("setup.anprOk", {
|
||||||
|
plate: anprResult.plate,
|
||||||
|
confidence: Math.round(anprResult.confidence * 100),
|
||||||
|
ms: anprResult.tookMs,
|
||||||
|
})}
|
||||||
|
{anprResult.lowConfidence && (
|
||||||
|
<span className="ml-1 text-term-amber">{t("setup.anprLowConfidence")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-[12px] text-term-amber">
|
||||||
|
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
|
||||||
|
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
|
||||||
|
{anprResult.tookMs != null && (
|
||||||
|
<span className="text-term-muted"> ({t("setup.anprTookMs", { ms: anprResult.tookMs })})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{backendIps && backendIps.length > 0 && (
|
{backendIps && backendIps.length > 0 && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="field max-w-md">
|
<div className="field max-w-md">
|
||||||
|
|||||||
@@ -280,6 +280,34 @@ export function testDevice(driverId: string, config: DeviceConfig): Promise<Test
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of an end-to-end ANPR probe on a camera: snapshot → vision analyze. */
|
||||||
|
export type AnprTestResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
plate: string;
|
||||||
|
confidence: number;
|
||||||
|
region: string | null;
|
||||||
|
lowConfidence: boolean;
|
||||||
|
modelVersion: string;
|
||||||
|
tookMs: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
/** vision-disabled | snapshot-failed | no-plate */
|
||||||
|
reason: string;
|
||||||
|
detail?: string;
|
||||||
|
tookMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Take a live snapshot off the camera and run ANPR on it — without saving. Reports
|
||||||
|
* whether a plate was extracted, the read, and how long it took. */
|
||||||
|
export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTestResult> {
|
||||||
|
return apiFetch<AnprTestResult>("/api/setup/test-anpr", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ driverId, config }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackendIpCandidate {
|
export interface BackendIpCandidate {
|
||||||
ip: string;
|
ip: string;
|
||||||
iface: string;
|
iface: string;
|
||||||
|
|||||||
@@ -344,6 +344,16 @@ export const en: Catalog = {
|
|||||||
anpr: "Plate recognition (ANPR)",
|
anpr: "Plate recognition (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.",
|
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.",
|
||||||
|
testAnpr: "Test ANPR",
|
||||||
|
anprTesting: "Testing ANPR…",
|
||||||
|
testAnprHint:
|
||||||
|
"Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.",
|
||||||
|
anprOk: "✓ Read plate {{plate}} — {{confidence}}% confidence, {{ms}} ms",
|
||||||
|
anprLowConfidence: "(low confidence — advisory only)",
|
||||||
|
anprTookMs: "{{ms}} ms",
|
||||||
|
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
||||||
|
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
||||||
|
"anprFail.no-plate": "No plate found in the snapshot.",
|
||||||
whichBarrier: "Which barrier does this device serve?",
|
whichBarrier: "Which barrier does this device serve?",
|
||||||
controller: "Controller",
|
controller: "Controller",
|
||||||
choose: "Choose…",
|
choose: "Choose…",
|
||||||
|
|||||||
@@ -354,6 +354,16 @@ export const sq = {
|
|||||||
anpr: "Njohja e targave (ANPR)",
|
anpr: "Njohja e targave (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.",
|
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.",
|
||||||
|
testAnpr: "Testo ANPR",
|
||||||
|
anprTesting: "Duke testuar ANPR…",
|
||||||
|
testAnprHint:
|
||||||
|
"Merr një pamje të drejtpërdrejtë nga kjo kamerë dhe përpiqet të lexojë një targë, duke raportuar rezultatin dhe kohën e nevojshme. Vendos një targë para kamerës më parë.",
|
||||||
|
anprOk: "✓ Targa u lexua {{plate}} — {{confidence}}% besueshmëri, {{ms}} ms",
|
||||||
|
anprLowConfidence: "(besueshmëri e ulët — vetëm këshillues)",
|
||||||
|
anprTookMs: "{{ms}} ms",
|
||||||
|
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
||||||
|
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
||||||
|
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
||||||
// Binding picker.
|
// Binding picker.
|
||||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||||
controller: "Kontrolluesi",
|
controller: "Kontrolluesi",
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ export interface CameraDevice extends Device {
|
|||||||
captureSnapshot(ctx: SnapshotContext): Promise<Snapshot>;
|
captureSnapshot(ctx: SnapshotContext): Promise<Snapshot>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCamera(device: Device): device is Device & CameraDevice {
|
||||||
|
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnapshotContext {
|
export interface SnapshotContext {
|
||||||
readonly direction: "entry" | "exit";
|
readonly direction: "entry" | "exit";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user