feat(vision): configurability — SetupWizard ANPR toggle, footer health chip, env docs
Make the vision service genuinely configurable (was env-only). - SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted only when on; sq+en) — opt-in is no longer raw JSON. - DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when up, 0 chips when disabled. - apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example (Node side) + a Configuration section in opencv-anpr-service.md covering all four layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE .env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr camera recognizes but every read is refused. Build + lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -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<typeof setInterval> | 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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user