feat(carwash): entry-stream sampling for the review outbox; park-2 wired to the collector
Build & push images / images (push) Successful in 4m22s
Build & push images / images (push) Successful in 4m22s
The wash stream is small; the entry camera photographs every car in exactly the view the classifier is trained on. The booth can now queue entry vehicle reads as pure training material — crop + the camera's class, no order, no operator, no category. - Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N) and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose. - Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated without the operator fields, review screen shows an entry sample as such, export has a kind column, operator agreement computed from wash items only. Setup line shows "1 in N entries sampled"; status carries entrySample. - komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2, the shared per-booth secret, every entry sampled) — the collector is up on the overlay. - Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed assessment), log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { PrinterStatus } from "@parking/devices";
|
||||
import type { LedgerEventRow } from "@parking/db";
|
||||
import type { VehicleRead } from "@parking/shared";
|
||||
|
||||
// Internal event bus for device-originated events (button presses, etc.).
|
||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||
@@ -105,6 +106,17 @@ export interface PlateRecognizedEvent {
|
||||
readonly direction: "entry" | "exit";
|
||||
}
|
||||
|
||||
/** Emitted when vision classified the vehicle in an entry/exit frame (advisory; stored on
|
||||
* the read row like the plate). A module may sample these — the Car Wash review outbox
|
||||
* queues one in N ENTRY reads for the remote reviewer, in the gate view the classifier
|
||||
* will be trained on (wiki/concepts/vision-review-outbox.md). The core emits; it never
|
||||
* knows who listens. */
|
||||
export interface VehicleReadEvent {
|
||||
readonly identity: string;
|
||||
readonly direction: "entry" | "exit";
|
||||
readonly read: VehicleRead;
|
||||
}
|
||||
|
||||
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||
@@ -197,6 +209,13 @@ class DeviceEventBus extends EventEmitter {
|
||||
this.on("plate-recognized", cb);
|
||||
return () => this.off("plate-recognized", cb);
|
||||
}
|
||||
emitVehicleRead(event: VehicleReadEvent): void {
|
||||
this.emit("vehicle-read", event);
|
||||
}
|
||||
onVehicleRead(cb: (event: VehicleReadEvent) => void): () => void {
|
||||
this.on("vehicle-read", cb);
|
||||
return () => this.off("vehicle-read", cb);
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide device event bus. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { deviceEvents } from "../../device-events.js";
|
||||
import type { ServerModule } from "../index.js";
|
||||
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||
import { carwashRoutes } from "./routes.js";
|
||||
@@ -18,6 +19,13 @@ export const carwashModule: ServerModule = {
|
||||
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
|
||||
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
|
||||
outbox.start();
|
||||
// Entry-stream sampling: one in N entry vehicle reads goes to the reviewer as pure
|
||||
// training material (the gate view, no order attached). The core announces the read;
|
||||
// the module decides. Off unless CARWASH_REVIEW_ENTRY_SAMPLE is set.
|
||||
const offVehicleRead = deviceEvents.onVehicleRead((e) => {
|
||||
if (e.direction === "entry" && outbox.sampleEntry()) void outbox.enqueueEntry(e.read);
|
||||
});
|
||||
app.addHook("onClose", async () => offVehicleRead());
|
||||
app.addHook("onClose", async () => outbox.stop());
|
||||
const service = new CarwashService(deps, app.log, outbox);
|
||||
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||
|
||||
@@ -3,6 +3,7 @@ import sharp from "sharp";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { deviceEvents as deviceEventBus } from "../../device-events.js";
|
||||
import { buildServer } from "../../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
|
||||
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||
@@ -81,7 +82,7 @@ describe("queue + drain", () => {
|
||||
});
|
||||
afterEach(() => close());
|
||||
|
||||
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60 };
|
||||
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60, entrySample: 0 };
|
||||
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
|
||||
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false };
|
||||
|
||||
@@ -105,7 +106,7 @@ describe("queue + drain", () => {
|
||||
const row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row.status).toBe("queued");
|
||||
expect(row.image!.length).toBeGreaterThan(500);
|
||||
expect(row.payload).toMatchObject({ v: 1, booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
|
||||
expect(row.payload).toMatchObject({ v: 1, kind: "wash", booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
|
||||
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
|
||||
|
||||
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
|
||||
@@ -159,6 +160,18 @@ describe("queue + drain", () => {
|
||||
expect(fetchFn.mock.calls.length).toBe(before);
|
||||
expect(ob.status().failed).toBe(3);
|
||||
|
||||
// Entry sampling: one in N entry reads becomes a package with the crop and the
|
||||
// camera's class only — no order, no operator, no category.
|
||||
const sampler = new ReviewOutbox(db, silentLogger(), { ...cfg, entrySample: 3 }, fetchFn);
|
||||
expect([sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry()]).toEqual([false, false, true, false]);
|
||||
expect(ob.sampleEntry()).toBe(false); // entrySample 0 = off
|
||||
expect(await sampler.enqueueEntry(read)).toBe(true);
|
||||
const entryRow = db.select().from(carwashReviewOutbox).where(eq(carwashReviewOutbox.orderId, "entry:snap-1")).get()!;
|
||||
expect(entryRow.payload).toMatchObject({ v: 1, kind: "entry", booth: "booth-7", vision: { class: "car", confidence: 0.86 }, image: { plateBlurred: true } });
|
||||
expect(entryRow.payload).not.toHaveProperty("operator");
|
||||
expect(entryRow.payload).not.toHaveProperty("operatorCategory");
|
||||
expect(entryRow.image!.length).toBeGreaterThan(500);
|
||||
|
||||
// No vehicle box, no snapshot, or upload off → nothing queued.
|
||||
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
|
||||
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
|
||||
@@ -178,6 +191,7 @@ describe("through the app", () => {
|
||||
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
|
||||
process.env.CARWASH_REVIEW_TOKEN = "tok";
|
||||
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
|
||||
process.env.CARWASH_REVIEW_ENTRY_SAMPLE = "1";
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
@@ -187,7 +201,7 @@ describe("through the app", () => {
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID"]) {
|
||||
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID", "CARWASH_REVIEW_ENTRY_SAMPLE"]) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
@@ -214,6 +228,14 @@ describe("through the app", () => {
|
||||
// Enqueue is fire-and-forget: give the crop a moment.
|
||||
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
|
||||
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
|
||||
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0 });
|
||||
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0, entrySample: 1 });
|
||||
|
||||
// An ENTRY vehicle read announced by the core (snapshot.ts) is sampled by the module
|
||||
// (1 in 1 here) into an entry package; an exit read is not.
|
||||
deviceEventBus.emitVehicleRead({ identity: "T-X", direction: "exit", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
|
||||
deviceEventBus.emitVehicleRead({ identity: "T-R", direction: "entry", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
|
||||
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(2));
|
||||
const rows = db.select().from(carwashReviewOutbox).all();
|
||||
expect(rows.map((r) => (r.payload as { kind: string }).kind).sort()).toEqual(["entry", "wash"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface ReviewUploadConfig {
|
||||
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
|
||||
readonly boothId: string;
|
||||
readonly intervalSec: number;
|
||||
/** Queue one in N ENTRY vehicle reads (no order attached) for the reviewer — the gate
|
||||
* view is exactly what the classifier is trained on, and the entry stream is many times
|
||||
* the wash stream. 0 = off. */
|
||||
readonly entrySample: number;
|
||||
}
|
||||
|
||||
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
|
||||
@@ -43,7 +47,12 @@ export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env):
|
||||
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
|
||||
if (!url || !token || !boothId) return null;
|
||||
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60);
|
||||
return { url, token, boothId, intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60 };
|
||||
const sample = Number(env.CARWASH_REVIEW_ENTRY_SAMPLE ?? 0);
|
||||
return {
|
||||
url, token, boothId,
|
||||
intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60,
|
||||
entrySample: Number.isInteger(sample) && sample > 0 ? sample : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
|
||||
@@ -145,6 +154,8 @@ export interface OutboxStatus {
|
||||
readonly failed: number;
|
||||
readonly lastSentAt: string | null;
|
||||
readonly lastError: string | null;
|
||||
/** 0 = entry sampling off; N = one in N entry reads is queued. */
|
||||
readonly entrySample: number;
|
||||
}
|
||||
|
||||
export class ReviewOutbox {
|
||||
@@ -154,6 +165,7 @@ export class ReviewOutbox {
|
||||
readonly #fetch: FetchLike;
|
||||
#timer: NodeJS.Timeout | null = null;
|
||||
#draining = false;
|
||||
#entrySeen = 0;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
|
||||
this.#db = db;
|
||||
@@ -172,35 +184,68 @@ export class ReviewOutbox {
|
||||
* sample) or when upload is not configured (an unbounded queue nobody drains). */
|
||||
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
|
||||
if (!this.#cfg) return false;
|
||||
return this.#queue(item.orderId, read, (id, crop) => ({
|
||||
v: 1,
|
||||
kind: "wash",
|
||||
booth: this.#cfg!.boothId,
|
||||
item: id,
|
||||
order: item.orderId,
|
||||
at: item.createdAt,
|
||||
operator: operatorRef(this.#cfg!.boothId, item.createdBy),
|
||||
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
|
||||
service: item.serviceName,
|
||||
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
|
||||
downgraded: item.downgraded,
|
||||
image: crop,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Every Nth entry read is a sample (N = entrySample); the caller queues it. Counted
|
||||
* in-process, so "1 in 5" is exactly that across a booth's day. */
|
||||
sampleEntry(): boolean {
|
||||
const n = this.#cfg?.entrySample ?? 0;
|
||||
if (n <= 0) return false;
|
||||
this.#entrySeen += 1;
|
||||
return this.#entrySeen % n === 0;
|
||||
}
|
||||
|
||||
/** Queue an ENTRY sample: the crop and the camera's class only — no order, no operator,
|
||||
* no category. Pure training material in the gate view; the reviewer labels it. */
|
||||
async enqueueEntry(read: VehicleRead): Promise<boolean> {
|
||||
if (!this.#cfg) return false;
|
||||
return this.#queue(`entry:${read.snapshotId ?? "?"}`, read, (id, crop) => ({
|
||||
v: 1,
|
||||
kind: "entry",
|
||||
booth: this.#cfg!.boothId,
|
||||
item: id,
|
||||
at: new Date().toISOString(),
|
||||
vision: { class: read.bodyType, confidence: read.confidence },
|
||||
image: crop,
|
||||
}));
|
||||
}
|
||||
|
||||
async #queue(
|
||||
ref: string,
|
||||
read: VehicleRead,
|
||||
build: (id: string, image: { width: number; height: number; plateBlurred: boolean }) => Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
if (!read.box || !read.snapshotId) return false;
|
||||
try {
|
||||
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
|
||||
if (!snap) {
|
||||
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — order ${item.orderId} not queued`);
|
||||
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — ${ref} not queued`);
|
||||
return false;
|
||||
}
|
||||
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
|
||||
const id = randomUUID();
|
||||
const payload = {
|
||||
v: 1,
|
||||
booth: this.#cfg.boothId,
|
||||
item: id,
|
||||
order: item.orderId,
|
||||
at: item.createdAt,
|
||||
operator: operatorRef(this.#cfg.boothId, item.createdBy),
|
||||
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
|
||||
service: item.serviceName,
|
||||
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
|
||||
downgraded: item.downgraded,
|
||||
image: { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred },
|
||||
};
|
||||
const payload = build(id, { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred });
|
||||
this.#db
|
||||
.insert(carwashReviewOutbox)
|
||||
.values({ id, orderId: item.orderId, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
|
||||
.values({ id, orderId: ref, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
|
||||
.run();
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`carwash review: could not queue order ${item.orderId}: ${(err as Error).message}`);
|
||||
this.#logger.warn(`carwash review: could not queue ${ref}: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -320,6 +365,7 @@ export class ReviewOutbox {
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
boothId: this.#cfg?.boothId ?? null,
|
||||
entrySample: this.#cfg?.entrySample ?? 0,
|
||||
queued: count("queued"),
|
||||
sent: count("sent"),
|
||||
failed: count("failed"),
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps
|
||||
// The review outbox's health (Setup → Car wash): how many decisions wait for the
|
||||
// reviewer, how many went, the last error. Site admin's read.
|
||||
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
|
||||
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null },
|
||||
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null, entrySample: 0 },
|
||||
);
|
||||
|
||||
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||
|
||||
@@ -210,7 +210,16 @@ async function recognizePlate(
|
||||
occurredAt: new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
if (result.vehicle) logger.info(`vision vehicle '${result.vehicle.bodyType}' (${result.vehicle.confidence.toFixed(3)}) for ${identity}`);
|
||||
if (result.vehicle) {
|
||||
logger.info(`vision vehicle '${result.vehicle.bodyType}' (${result.vehicle.confidence.toFixed(3)}) for ${identity}`);
|
||||
if (vehicleBox) {
|
||||
deviceEvents.emitVehicleRead({
|
||||
identity,
|
||||
direction,
|
||||
read: { bodyType: result.vehicle.bodyType, confidence: result.vehicle.confidence, snapshotId, box: vehicleBox, plateBox },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!plate) return;
|
||||
logger.info(`anpr plate '${plate}' (${result.plate!.confidence.toFixed(3)}) for ${identity}`);
|
||||
// The session's entry/exit event already shipped without this (async) plate — tell the
|
||||
|
||||
Reference in New Issue
Block a user