diff --git a/apps/server/.env.example b/apps/server/.env.example index a6c4e45..59ea906 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -29,3 +29,15 @@ EVENT_SIGNING_KEY= # Comma-separated extra origins allowed to open the booth WebSocket (/api/ws). # In dev, set the Vite SPA origin. Same-origin is always allowed without this. WS_ALLOWED_ORIGINS=http://localhost:5173 + +# Vision / ANPR (optional) ------------------------------------------------- +# OFF by default. The Node SERVER's view of the vision microservice (apps/vision), +# which runs as a separate process with its OWN apps/vision/.env. Both sides share the +# VISION_ prefix but are different processes — keep the two .env files separate. +# See wiki/entities/opencv-anpr-service.md "Configuration". +# VISION_ENABLED=1 # master switch — nothing runs without it +# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT +# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane +# VISION_POLL_MS=2000 # how often each anpr camera is polled +# VISION_DEDUPE_MS=15000 # suppress re-firing the same plate while a car sits in frame +# VISION_MIN_CONFIDENCE=0.5 # confidence floor; keep in sync with the service diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index 375b933..d6e10bd 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -60,7 +60,7 @@ export interface PrinterStatusEvent { export interface DeviceStatusEvent { readonly deviceId: string; // devices id readonly driverId: string; - readonly category: "access" | "reader" | "camera" | "printer"; + readonly category: "access" | "reader" | "camera" | "printer" | "vision"; /** * The device's ROLE descriptor for the footer label — NOT the vendor. A * direction-style token the client localises and pairs with the category, so the diff --git a/apps/server/src/device-monitor.ts b/apps/server/src/device-monitor.ts index ce7bc5c..46dbab4 100644 --- a/apps/server/src/device-monitor.ts +++ b/apps/server/src/device-monitor.ts @@ -3,6 +3,11 @@ import { devices, type Db, type DeviceRow } from "@parking/db"; import { isMonitorable, registry } from "@parking/devices"; import { deviceEvents, type DeviceStatusEvent } from "./device-events.js"; import { directionOf, relaysOf } from "./device-resolve.js"; +import type { VisionClient } from "./vision-client.js"; + +/** Synthetic device id for the vision service in the status footer (it's a service, + * not a device row, but shares the footer's traffic-light + WS plumbing). */ +const VISION_STATUS_ID = "vision-service"; // Unified live DEVICE monitor — the source for the booth's device-status footer. // Every enabled, configured device is probed on an interval, regardless of @@ -60,10 +65,15 @@ export class DeviceMonitor { #timer: ReturnType | null = null; #ticking = false; - constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) { + /** Optional: the vision service client. When present + enabled, the monitor probes + * its /health each tick and shows it as a "vision" chip in the footer. */ + readonly #vision: VisionClient | null; + + constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS, vision: VisionClient | null = null) { this.#db = db; this.#log = log; this.#pollMs = pollMs; + this.#vision = vision; } /** Begin polling. Idempotent. */ @@ -97,12 +107,16 @@ export class DeviceMonitor { const enabled = rows.filter((r) => r.enabled); const present = new Set(enabled.map((r) => r.id)); + // The vision service is a pseudo-device — keep it in the present set when enabled + // so the cleanup below doesn't evict it. + if (this.#vision?.enabled) present.add(VISION_STATUS_ID); + // Drop devices that are gone/disabled (so the footer doesn't show stale ones). for (const id of [...this.#latest.keys()]) { if (!present.has(id)) this.#latest.delete(id); } - await Promise.all(enabled.map((r) => this.#poll(r))); + await Promise.all([...enabled.map((r) => this.#poll(r)), this.#pollVision()]); } catch (err) { this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`); } finally { @@ -144,11 +158,33 @@ export class DeviceMonitor { } } - const prev = this.#latest.get(row.id); - this.#latest.set(row.id, next); + this.#publish(row.id, next); + } + + /** Probe the vision service /health and publish it as a "vision" footer chip. Skipped + * entirely when no client is wired or it's disabled (no chip then). */ + async #pollVision(): Promise { + if (!this.#vision?.enabled) return; + const h = await this.#vision.health(); + const state: DeviceStatusEvent["state"] = h.ok && h.ready ? "ready" : h.ready ? "degraded" : "offline"; + this.#publish(VISION_STATUS_ID, { + deviceId: VISION_STATUS_ID, + driverId: "vision", + category: "vision", + roleKind: null, + state, + detail: h.ready ? h.recognizer : (h.detail ?? "not ready"), + checkedAt: new Date().toISOString(), + }); + } + + /** Cache + emit a status, but only when it CHANGED (state or detail). */ + #publish(id: string, next: DeviceStatusEvent): void { + const prev = this.#latest.get(id); + this.#latest.set(id, next); if (!prev || prev.state !== next.state || prev.detail !== next.detail) { this.#log.info( - `device-monitor: ${next.category}/${next.roleKind ?? "—"} ${row.id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`, + `device-monitor: ${next.category}/${next.roleKind ?? "—"} ${id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`, ); deviceEvents.emitDeviceStatus(next); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7c816fc..73641b7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -112,11 +112,17 @@ 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) and feeds the booth's - // device-status footer over the WS. Read-only — never drives a relay. + // 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. // See wiki/concepts/device-status-monitoring.md. - const deviceMonitor = new DeviceMonitor(db, app.log); + const deviceMonitor = new DeviceMonitor(db, app.log, undefined, visionClient); await deviceStatusRoutes(app, deviceMonitor); app.addHook("onReady", async () => deviceMonitor.start()); app.addHook("onClose", async () => deviceMonitor.stop()); @@ -163,15 +169,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise 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"); - // Vision READER: polls opt-in (config.anpr) cameras, recognizes a plate via the // vision client, and emits a kind:"plate" read onto the SAME read bus a physical // reader uses → the dispatcher routes it to the subscription/exit flow unchanged. A diff --git a/apps/vision/.env.example b/apps/vision/.env.example new file mode 100644 index 0000000..f738ca8 --- /dev/null +++ b/apps/vision/.env.example @@ -0,0 +1,22 @@ +# apps/vision — the ANPR microservice's own env (copy to apps/vision/.env). +# This is the PYTHON SERVICE's config only. The Node server has its OWN VISION_* vars +# (in apps/server/.env) — keep the two .env files SEPARATE (they share the VISION_ +# prefix but are different processes). See wiki/entities/opencv-anpr-service.md "Configuration". + +# Recognizer: "stub" (no models, recognizes nothing — boots anywhere, for dev/CI) or +# "fast_alpr" (the real MIT YOLOv9+CCT/ONNX stack — needs `uv sync --extra alpr`). +VISION_RECOGNIZER=fast_alpr + +# Bind. On the appliance prefer 127.0.0.1 — the Node backend is the only caller, so the +# /analyze endpoint should NOT be reachable off-host. (0.0.0.0 only if you must.) +VISION_HOST=127.0.0.1 +VISION_PORT=8089 + +# fast-alpr models (only used when recognizer=fast_alpr). The defaults won the Albanian +# benchmark; change the OCR to european-plates-mobile-vit-v2-model only to re-test. +VISION_DETECTOR_MODEL=yolo-v9-t-384-license-plate-end2end +VISION_OCR_MODEL=cct-xs-v2-global-model + +# Confidence floor — a best plate below this is flagged low_confidence so the Node side +# treats it as advisory and falls back to the ticket path. Keep in sync with the server. +VISION_MIN_CONFIDENCE=0.5 diff --git a/apps/vision/README.md b/apps/vision/README.md index b727219..774c4ed 100644 --- a/apps/vision/README.md +++ b/apps/vision/README.md @@ -57,12 +57,21 @@ country European model to benchmark Albanian plates. For GPU/NPU, install `onnxr The Node side POSTs `Snapshot.bytes` directly (no multipart). `vehicle` is scaffolded but not yet populated — fast-alpr is plate-only; the vehicle stage (Job 2) is built later on the same runtime. -## Config (env, prefix `VISION_`) +## Config (env, prefix `VISION_`) — see `.env.example` + +This service's env only. The **Node server has its own `VISION_*`** (`apps/server/.env`: +`VISION_ENABLED`, `VISION_URL`, `VISION_POLL_MS`, …) — same prefix, **separate process, separate +`.env`**. Don't merge them. | Var | Default | Meaning | | --- | --- | --- | | `VISION_RECOGNIZER` | `stub` | `stub` (no models) or `fast_alpr` (real) | -| `VISION_PORT` | `8089` | listen port | +| `VISION_HOST` | `0.0.0.0` | bind address — prefer `127.0.0.1` on the appliance (Node is the only caller) | +| `VISION_PORT` | `8089` | listen port (must match the server's `VISION_URL`) | | `VISION_DETECTOR_MODEL` | `yolo-v9-t-384-license-plate-end2end` | fast-alpr detector | -| `VISION_OCR_MODEL` | `cct-xs-v2-global-model` | fast-alpr OCR | +| `VISION_OCR_MODEL` | `cct-xs-v2-global-model` | fast-alpr OCR (won the AL benchmark) | | `VISION_MIN_CONFIDENCE` | `0.5` | below this → `low_confidence=true` | + +To use it from the booth: set `VISION_ENABLED=1` on the **server**, run this service, then tick +**ANPR** on a camera in the SetupWizard (the camera must also be bound to a barrier). The booth footer +shows a **Vision** chip when enabled. Full config guide: `wiki/entities/opencv-anpr-service.md`. diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 8f2ad0e..a6d26b0 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -322,6 +322,10 @@ function DeviceForm({ const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id); const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); const isController = category === "access"; + const isCamera = category === "camera"; + // ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates + // (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md. + const [anpr, setAnpr] = useState(editCfg?.anpr === true); // Pre-fill scalar config fields from the existing assignment when editing. // (relays/controllerId/relay are model fields handled by their own state below.) @@ -429,6 +433,8 @@ function DeviceForm({ out.controllerId = controllerId; out.relay = boundRelay; } + // Camera ANPR opt-in (only persisted when on, to keep configs minimal). + if (isCamera && anpr) out.anpr = true; return out; } @@ -584,6 +590,22 @@ function DeviceForm({ /> )} + {/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */} + {isCamera && ( + + )} + {/* Test (no save/no device change) then Save (configures + persists). */}