feat(vision): add VisionClient Node adapter (advisory, fail-soft, opt-in)

Node-side adapter to the apps/vision ANPR microservice (localhost HTTP: POST /analyze
with snapshot bytes, GET /health), returning a normalised VisionResult or null. Enforces
"advisory, never sole authority" at the boundary: opt-in (VISION_ENABLED, default off),
fail-soft (any error/timeout/unreachable → null, never throws into the lane → ticket
fallback), and re-applies the confidence floor (VISION_MIN_CONFIDENCE) on top of the
service's own low_confidence flag. Per-request AbortController timeout so a slow call
can't hang the barrier. Constructed in server.ts.

Verified: fail-soft (disabled/unreachable → null, no throw) and live end-to-end (Node
client → running fast_alpr service → AA558EE 0.999, region=Albania). NOT yet wired into
the read bus — the opt-in snapshot→DeviceReadEvent{kind:"plate"} trigger is the next
step. Build + lint green. Updates opencv-anpr-service (adapter gap marked done).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 15:58:43 +02:00
parent 17fdf3d482
commit 236cbfecab
4 changed files with 220 additions and 9 deletions
+10
View File
@@ -19,6 +19,7 @@ import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js";
import { logRoutes } from "./routes/logs.js";
import { VisionClient } from "./vision-client.js";
import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js";
import { roleRoutes } from "./routes/roles.js";
@@ -161,6 +162,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
});
app.addHook("onClose", async () => unsubscribeRead());
// Vision (ANPR) client: the adapter to the host vision microservice (apps/vision),
// talking localhost HTTP. ADVISORY ONLY + opt-in (VISION_ENABLED) + fail-soft — a
// plate read is an identity hint/evidence, never the sole authority to open a paid
// barrier. Constructed here and available for the (separate, not-yet-wired) read
// trigger that snapshots an opt-in camera and emits a plate read. See
// wiki/entities/opencv-anpr-service.md "Fitness for the entry/exit flows".
const visionClient = new VisionClient(app.log);
if (visionClient.enabled) app.log.info("vision client enabled");
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
// CHOSEN reader to populate a subscription credential, without blocking the other
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
+193
View File
@@ -0,0 +1,193 @@
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. */
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 raw /analyze response shape (the Python contract). `vehicle` is reserved for
* Job 2 (vehicle verification) — not yet produced. */
interface AnalyzeResponse {
readonly plate: VisionPlate | null;
readonly plates: VisionPlate[];
readonly vehicle: unknown | 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;
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<VisionResult | null> {
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);
return {
plate: best,
plates: Array.isArray(res.plates) ? res.plates : [],
lowConfidence,
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<VisionHealth> {
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<unknown> {
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);
}
}
}