import type { FastifyBaseLogger } from "fastify"; // Node-side client for the host vision service (apps/vision — the ANPR microservice). // Calls it over LOCALHOST HTTP with a camera snapshot and gets back a plate read. The // Python service is a separate process/failure domain; this client is the adapter the // rest of the server talks to, so the recognizer is swappable without business-logic // changes. See wiki/entities/opencv-anpr-service.md, decisions/vision-service*.md. // // ADVISORY, NEVER SOLE AUTHORITY. Per the vision decision + the fitness assessment, a // plate read is an *identity hint + evidence*, never the lone reason a paid/access // barrier opens. This client enforces two things at the boundary so callers can't // misuse it: // 1. It is FAIL-SOFT — any error (service down, timeout, decode fail) resolves to // `null`, never throws into the entry/exit path. A missing vision result must // degrade to the ticket/manual path, never strand or wrongly admit a car // (fail-state-safety). // 2. It applies the CONFIDENCE FLOOR — a read below the threshold is returned with // `lowConfidence: true` (mirroring the service's own flag) so the caller treats it // as advisory-only and falls back. // // NOT yet wired into the read bus — that (snapshot-before-decision on an opt-in camera → // emit DeviceReadEvent{kind:"plate"}) is a separate, deliberate step. This is the // transport + contract adapter only. /** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */ import { isVehicleClass, type VehicleClass } from "@parking/shared"; export interface PlateBBox { readonly x1: number; readonly y1: number; readonly x2: number; readonly y2: number; } /** One plate read from the vision service. `confidence` is the MIN of the model's * per-character confidences (a plate is only as trustworthy as its weakest char). */ export interface VisionPlate { readonly text: string; readonly confidence: number; readonly bbox?: PlateBBox | null; /** Predicted issuing region/country (advisory; the global model emits this). */ readonly region?: string | null; } /** The vehicle attributes stage of /analyze (advisory). `body_type` is one of the shared * VEHICLE_CLASSES vocabulary (the service's raw label is normalised there); a stub or a * plate-only recognizer sends null. */ export interface VisionVehicle { readonly bodyType: VehicleClass; readonly confidence: number; /** The vehicle's box in frame pixels, when the stage found one. */ readonly bbox?: PlateBBox | null; } /** The raw /analyze response shape (the Python contract). */ interface AnalyzeResponse { readonly plate: VisionPlate | null; readonly plates: VisionPlate[]; readonly vehicle: { body_type?: string | null; confidence?: number | null; bbox?: PlateBBox | null } | null; readonly low_confidence: boolean; readonly model_version: string; readonly took_ms: number; } /** What the rest of the server gets back from `analyze()`. Normalised + camelCased, * with the advisory gate already applied. Never thrown — `null` on any failure. */ export interface VisionResult { /** The best plate, or null if none read. */ readonly plate: VisionPlate | null; /** All plates found in the frame (a frame may hold several vehicles). */ readonly plates: VisionPlate[]; /** True when the best plate is below the confidence floor — treat as advisory only * and fall back to the ticket/manual path. */ readonly lowConfidence: boolean; /** The vehicle's body type, when the service ran that stage and named a known class. */ readonly vehicle: VisionVehicle | null; readonly modelVersion: string; readonly tookMs: number; } export interface VisionHealth { readonly ok: boolean; readonly recognizer: string; readonly ready: boolean; readonly modelVersion: string; readonly detail?: string | null; } export interface VisionClientOptions { /** Base URL of the vision service (localhost). */ readonly baseUrl?: string; /** Per-request timeout (ms) — a slow vision call must never hang the lane. */ readonly timeoutMs?: number; /** Confidence floor: a best-plate below this is flagged lowConfidence. Mirrors the * service's own VISION_MIN_CONFIDENCE; kept here too so the gate holds even if the * service is misconfigured. */ readonly minConfidence?: number; /** Master switch — when false, `analyze()` short-circuits to null (no call). Lets the * appliance run with no vision service configured. */ readonly enabled?: boolean; } export class VisionClient { readonly #baseUrl: string; readonly #timeoutMs: number; readonly #minConfidence: number; readonly #enabled: boolean; readonly #logger: FastifyBaseLogger; constructor(logger: FastifyBaseLogger, opts: VisionClientOptions = {}) { this.#logger = logger; this.#baseUrl = (opts.baseUrl ?? process.env.VISION_URL ?? "http://127.0.0.1:8089").replace(/\/$/, ""); this.#timeoutMs = opts.timeoutMs ?? Number(process.env.VISION_TIMEOUT_MS ?? 1500); this.#minConfidence = opts.minConfidence ?? Number(process.env.VISION_MIN_CONFIDENCE ?? 0.5); // Default OFF: vision is opt-in. Enable with VISION_ENABLED=1 (or pass enabled:true). this.#enabled = opts.enabled ?? ["1", "true", "yes"].includes((process.env.VISION_ENABLED ?? "").toLowerCase()); } get enabled(): boolean { return this.#enabled; } /** * Analyse snapshot bytes → a plate read, or `null`. NEVER throws and NEVER blocks the * caller's open path beyond `timeoutMs`: any failure (disabled, unreachable, timeout, * non-2xx, bad body) logs and resolves to null, so the caller falls back to the * ticket/manual path. The returned `lowConfidence` re-applies the floor on top of the * service's own flag. */ async analyze(imageBytes: Buffer, contentType = "application/octet-stream"): Promise { if (!this.#enabled) return null; try { const body = await this.#post("/analyze", imageBytes, contentType); if (!body) return null; const res = body as AnalyzeResponse; const best = res.plate ?? null; const lowConfidence = res.low_confidence || (best != null && best.confidence < this.#minConfidence); const v = res.vehicle; const vehicle: VisionVehicle | null = v && isVehicleClass(v.body_type) && typeof v.confidence === "number" ? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)), bbox: v.bbox ?? null } : null; return { plate: best, plates: Array.isArray(res.plates) ? res.plates : [], lowConfidence, vehicle, modelVersion: res.model_version ?? "unknown", tookMs: typeof res.took_ms === "number" ? res.took_ms : 0, }; } catch (err) { this.#logger.warn(`vision analyze failed (fallback to ticket path): ${(err as Error).message}`); return null; } } /** Liveness/readiness of the vision service. Returns ok:false (never throws) when * disabled or unreachable, so the device-status footer can show it. */ async health(): Promise { if (!this.#enabled) { return { ok: false, recognizer: "disabled", ready: false, modelVersion: "-", detail: "vision disabled" }; } try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.#timeoutMs); try { const r = await fetch(`${this.#baseUrl}/health`, { signal: controller.signal }); if (!r.ok) return { ok: false, recognizer: "?", ready: false, modelVersion: "-", detail: `HTTP ${r.status}` }; const h = (await r.json()) as { status?: string; recognizer?: string; ready?: boolean; model_version?: string; detail?: string | null; }; return { ok: h.status === "ok", recognizer: h.recognizer ?? "?", ready: Boolean(h.ready), modelVersion: h.model_version ?? "-", detail: h.detail ?? null, }; } finally { clearTimeout(timer); } } catch (err) { return { ok: false, recognizer: "?", ready: false, modelVersion: "-", detail: (err as Error).message }; } } /** POST raw bytes to a path, with timeout. Returns parsed JSON or throws (caught by * the caller, which fails soft). */ async #post(path: string, bytes: Buffer, contentType: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.#timeoutMs); try { const r = await fetch(`${this.#baseUrl}${path}`, { method: "POST", headers: { "content-type": contentType }, // Buffer is a valid BodyInit in Node's undici fetch. body: bytes, signal: controller.signal, }); if (!r.ok) throw new Error(`vision ${path} → HTTP ${r.status}`); return await r.json(); } finally { clearTimeout(timer); } } }