diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 16eb960..9a44b21 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -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 { +export async function setupRoutes( + app: FastifyInstance, + db: Db, + vision?: VisionClient | null, +): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); @@ -261,6 +268,72 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { }, ); + // 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>; + 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. diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7ae3c65..a44df05 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -93,11 +93,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise 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. diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 5f95f0c..a2537ce 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -7,8 +7,10 @@ import { fetchBackendIps, fetchCatalog, fetchState, + testAnpr, testDevice, unassignDevice, + type AnprTestResult, type Assignment, type BackendIpCandidate, type Catalog, @@ -352,6 +354,10 @@ function DeviceForm({ const [tested, setTested] = useState(null); const [testing, setTesting] = useState(false); const [testError, setTestError] = useState(null); + // ANPR probe (camera + anpr on): snapshot → vision analyze, reported below. + const [anprResult, setAnprResult] = useState(null); + const [anprTesting, setAnprTesting] = useState(false); + const [anprError, setAnprError] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [found, setFound] = useState(null); @@ -442,6 +448,8 @@ function DeviceForm({ setTested(null); setTestError(null); setSaveError(null); + setAnprResult(null); + setAnprError(null); } 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() { if (!selected) return; // Bound devices must point at a controller relay (binding is optional in the @@ -641,6 +666,40 @@ function DeviceForm({ )} + {/* 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 && ( +
+ +

{t("setup.testAnprHint")}

+ + {anprError &&

{t("setup.testFailed", { error: anprError })}

} + {anprResult && + (anprResult.ok ? ( +
+ {t("setup.anprOk", { + plate: anprResult.plate, + confidence: Math.round(anprResult.confidence * 100), + ms: anprResult.tookMs, + })} + {anprResult.lowConfidence && ( + {t("setup.anprLowConfidence")} + )} +
+ ) : ( +
+ ⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })} + {anprResult.detail && — {anprResult.detail}} + {anprResult.tookMs != null && ( + ({t("setup.anprTookMs", { ms: anprResult.tookMs })}) + )} +
+ ))} +
+ )} + {backendIps && backendIps.length > 0 && (
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index c4ef2b3..fedb1d5 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -280,6 +280,34 @@ export function testDevice(driverId: string, config: DeviceConfig): Promise { + return apiFetch("/api/setup/test-anpr", { + method: "POST", + body: JSON.stringify({ driverId, config }), + }); +} + export interface BackendIpCandidate { ip: string; iface: string; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 199e75c..21fea61 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -344,6 +344,16 @@ export const en: Catalog = { anpr: "Plate recognition (ANPR)", 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.", + 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?", controller: "Controller", choose: "Choose…", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 9fe39c0..08b9729 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -354,6 +354,16 @@ export const sq = { anpr: "Njohja e targave (ANPR)", 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.", + 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. whichBarrier: "Cilën barrierë shërben kjo pajisje?", controller: "Kontrolluesi", diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index e0cbbd2..56a65a5 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -173,6 +173,10 @@ export interface CameraDevice extends Device { captureSnapshot(ctx: SnapshotContext): Promise; } +export function isCamera(device: Device): device is Device & CameraDevice { + return typeof (device as Partial).captureSnapshot === "function"; +} + export interface SnapshotContext { readonly direction: "entry" | "exit"; }