feat(carwash): entry-stream sampling for the review outbox; park-2 wired to the collector
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:
2026-09-07 09:38:55 +02:00
parent 3e57af5abc
commit dbbb051ebd
18 changed files with 242 additions and 64 deletions
+19
View File
@@ -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. */
+8
View File
@@ -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"),
+1 -1
View File
@@ -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) => {
+10 -1
View File
@@ -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