2 Commits

Author SHA1 Message Date
julian dbbb051ebd 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
2026-09-07 09:38:55 +02:00
julian 3e57af5abc ui(carwash): Setup → Car wash as a two-column grid — the master-data card takes the width
The wrapping flex row capped the left card at max-w-2xl; once each category row grew its
camera-class chips it was squeezed while the right side sat empty (user, park-2). Now the
master data takes the remaining width and the sponsorship card keeps a fixed readable
column; stacks on narrow screens.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 09:38:55 +02:00
19 changed files with 249 additions and 67 deletions
+16 -4
View File
@@ -109,8 +109,8 @@ describe("review + export", () => {
const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json(); const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
expect(stats.booths).toEqual([ expect(stats.booths).toEqual([
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2 }, { booth: "booth-7", received: 2, pending: 0, reviewed: 2, entries: 0 },
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1 }, { booth: "booth-9", received: 1, pending: 0, reviewed: 1, entries: 0 },
]); ]);
expect(stats.operators).toEqual([ expect(stats.operators).toEqual([
{ booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 }, { booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 },
@@ -120,9 +120,21 @@ describe("review + export", () => {
const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } }); const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } });
expect(csv.statusCode).toBe(200); expect(csv.statusCode).toBe(200);
const lines = csv.body.trim().split("\n"); const lines = csv.body.trim().split("\n");
expect(lines[0]).toBe("item,booth,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at"); expect(lines[0]).toBe("item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at");
expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out
expect(lines[1]).toContain('"item-1","booth-7","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"'); expect(lines[1]).toContain('"item-1","booth-7","wash","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
// An ENTRY sample: no order, no operator — accepted, reviewable, in the export, and
// never counted in any operator's agreement.
const entry = await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-1", at: "2026-09-06T11:00:00.000Z", vision: { class: "car", confidence: 0.7 }, image: { width: 300, height: 180, plateBlurred: true } });
expect(entry.statusCode).toBe(201);
expect((await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-2", at: "x", vision: { class: "car", confidence: 0.7 }, image: { width: 1, height: 1, plateBlurred: true } })).statusCode).toBe(422);
expect((await post("entry-1", "suv")).statusCode).toBe(200);
const stats2 = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
expect(stats2.booths[0]).toEqual({ booth: "booth-7", received: 3, pending: 0, reviewed: 3, entries: 1 });
expect(stats2.operators.find((o: { booth: string }) => o.booth === "booth-7")).toMatchObject({ reviewed: 2, agree: 1, disagree: 1 });
const csv3 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
expect(csv3).toContain('"entry-1","booth-7","entry","crops/booth-7/entry-1.jpg","suv","","","car"');
// A booth-supplied name that looks like a spreadsheet formula is neutralised in the export. // A booth-supplied name that looks like a spreadsheet formula is neutralised in the export.
await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } })); await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } }));
+28 -19
View File
@@ -20,15 +20,18 @@ import { reviewPage } from "./review-page.js";
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */ /** The package's `meta` part, as the booth sends it (review-outbox.ts). */
interface IngestMeta { interface IngestMeta {
v: number; v: number;
/** "wash" (default when absent) = a desk decision; "entry" = a sampled entry read with
* no order and no operator — crop + the camera's class only. */
kind?: "wash" | "entry";
booth: string; booth: string;
item: string; item: string;
order: string; order?: string;
at: string; at: string;
operator: string; operator?: string;
operatorCategory: { id: string; name: string; classes?: string[] }; operatorCategory?: { id: string; name: string; classes?: string[] };
service: string; service?: string;
vision: { class: string; confidence: number; categoryId: string | null }; vision: { class: string; confidence: number; categoryId?: string | null };
downgraded: boolean; downgraded?: boolean;
image: { width: number; height: number; plateBlurred: boolean }; image: { width: number; height: number; plateBlurred: boolean };
} }
@@ -46,17 +49,21 @@ function checkMeta(m: unknown, booth: string): { ok: true; meta: IngestMeta } |
if (x.v !== 1) return { ok: false, why: "unsupported meta version" }; if (x.v !== 1) return { ok: false, why: "unsupported meta version" };
if (x.booth !== booth) return { ok: false, why: "meta.booth does not match the token's booth" }; if (x.booth !== booth) return { ok: false, why: "meta.booth does not match the token's booth" };
if (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" }; if (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" };
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
if (!str(x.at, 40) || Number.isNaN(Date.parse(x.at as string))) return { ok: false, why: "bad timestamp" }; if (!str(x.at, 40) || Number.isNaN(Date.parse(x.at as string))) return { ok: false, why: "bad timestamp" };
const kind = x.kind === undefined ? "wash" : x.kind;
if (kind !== "wash" && kind !== "entry") return { ok: false, why: "bad kind" };
const v = x.vision as Record<string, unknown> | undefined;
if (!v || !isVehicleClass(v.class) || typeof v.confidence !== "number" || v.confidence < 0 || v.confidence > 1) return { ok: false, why: "bad vision read" };
if (v.categoryId != null && !str(v.categoryId, 64)) return { ok: false, why: "bad vision.categoryId" };
if (kind === "wash") {
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" }; if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" };
const oc = x.operatorCategory as Record<string, unknown> | undefined; const oc = x.operatorCategory as Record<string, unknown> | undefined;
if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" }; if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" };
if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" }; if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" };
if (!str(x.service, 120)) return { ok: false, why: "bad service" }; if (!str(x.service, 120)) return { ok: false, why: "bad service" };
const v = x.vision as Record<string, unknown> | undefined;
if (!v || !isVehicleClass(v.class) || typeof v.confidence !== "number" || v.confidence < 0 || v.confidence > 1) return { ok: false, why: "bad vision read" };
if (v.categoryId != null && !str(v.categoryId, 64)) return { ok: false, why: "bad vision.categoryId" };
if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" }; if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
}
const im = x.image as Record<string, unknown> | undefined; const im = x.image as Record<string, unknown> | undefined;
if (!im || typeof im.width !== "number" || typeof im.height !== "number" || typeof im.plateBlurred !== "boolean") return { ok: false, why: "bad image meta" }; if (!im || typeof im.width !== "number" || typeof im.height !== "number" || typeof im.plateBlurred !== "boolean") return { ok: false, why: "bad image meta" };
return { ok: true, meta: x as unknown as IngestMeta }; return { ok: true, meta: x as unknown as IngestMeta };
@@ -148,16 +155,18 @@ export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: stri
const rel = path.posix.join("crops", booth, `${meta.item}.jpg`); const rel = path.posix.join("crops", booth, `${meta.item}.jpg`);
await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true }); await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true });
await writeFile(path.join(cfg.dataDir, rel), image); await writeFile(path.join(cfg.dataDir, rel), image);
const kind = meta.kind ?? "wash";
db.insert({ db.insert({
id: meta.item, id: meta.item,
booth, booth,
orderRef: meta.order, kind,
orderRef: meta.order ?? "",
at: meta.at, at: meta.at,
operatorRef: meta.operator, operatorRef: meta.operator ?? "",
operatorCategoryId: meta.operatorCategory.id, operatorCategoryId: meta.operatorCategory?.id ?? "",
operatorCategoryName: meta.operatorCategory.name, operatorCategoryName: meta.operatorCategory?.name ?? "",
operatorClasses: JSON.stringify(meta.operatorCategory.classes ?? []), operatorClasses: JSON.stringify(meta.operatorCategory?.classes ?? []),
service: meta.service, service: meta.service ?? "",
visionClass: meta.vision.class, visionClass: meta.vision.class,
visionConfidence: meta.vision.confidence, visionConfidence: meta.vision.confidence,
visionCategoryId: meta.vision.categoryId ?? null, visionCategoryId: meta.vision.categoryId ?? null,
@@ -168,7 +177,7 @@ export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: stri
imagePath: rel, imagePath: rel,
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
}); });
req.log.info(`ingest: ${booth} item ${meta.item} (${meta.vision.class} → ${meta.operatorCategory.name})`); req.log.info(`ingest: ${booth} ${kind} ${meta.item} (${meta.vision.class}${kind === "wash" ? ` → ${meta.operatorCategory!.name}` : ""})`);
return reply.code(201).send({ ok: true }); return reply.code(201).send({ ok: true });
}); });
@@ -214,9 +223,9 @@ export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: stri
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`; if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
return `"${v.replace(/"/g, '""')}"`; return `"${v.replace(/"/g, '""')}"`;
}; };
const head = "item,booth,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at"; const head = "item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
const lines = rows.map((r) => const lines = rows.map((r) =>
[r.id, r.booth, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","), [r.id, r.booth, r.kind, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","),
); );
return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n"); return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n");
}); });
+16 -9
View File
@@ -9,6 +9,9 @@ import type { VehicleClass } from "@parking/shared";
export interface ItemRow { export interface ItemRow {
id: string; id: string;
booth: string; booth: string;
/** "wash" = a desk decision (operator fields set); "entry" = a sampled entry read (pure
* training material: crop + the camera's class, operator fields empty). */
kind: "wash" | "entry";
orderRef: string; orderRef: string;
at: string; at: string;
operatorRef: string; operatorRef: string;
@@ -44,11 +47,12 @@ export class CollectorDb {
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
booth TEXT NOT NULL, booth TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'wash',
order_ref TEXT NOT NULL, order_ref TEXT NOT NULL,
at TEXT NOT NULL, at TEXT NOT NULL,
operator_ref TEXT NOT NULL, operator_ref TEXT NOT NULL DEFAULT '',
operator_category_id TEXT NOT NULL, operator_category_id TEXT NOT NULL DEFAULT '',
operator_category_name TEXT NOT NULL, operator_category_name TEXT NOT NULL DEFAULT '',
operator_classes TEXT NOT NULL DEFAULT '[]', operator_classes TEXT NOT NULL DEFAULT '[]',
service TEXT NOT NULL, service TEXT NOT NULL,
vision_class TEXT NOT NULL, vision_class TEXT NOT NULL,
@@ -77,6 +81,7 @@ export class CollectorDb {
return { return {
id: r.id as string, id: r.id as string,
booth: r.booth as string, booth: r.booth as string,
kind: r.kind === "entry" ? "entry" : "wash",
orderRef: r.order_ref as string, orderRef: r.order_ref as string,
at: r.at as string, at: r.at as string,
operatorRef: r.operator_ref as string, operatorRef: r.operator_ref as string,
@@ -107,10 +112,10 @@ export class CollectorDb {
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void { insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
this.#db this.#db
.prepare( .prepare(
`INSERT INTO items (id, booth, order_ref, at, operator_ref, operator_category_id, operator_category_name, `INSERT INTO items (id, booth, kind, order_ref, at, operator_ref, operator_category_id, operator_category_name,
operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded, operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded,
image_width, image_height, plate_blurred, image_path, received_at) image_width, image_height, plate_blurred, image_path, received_at)
VALUES (@id, @booth, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName, VALUES (@id, @booth, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded, @operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`, @imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
) )
@@ -142,19 +147,21 @@ export class CollectorDb {
* reviewer's class fell inside the operator's chosen category (agree) or outside * reviewer's class fell inside the operator's chosen category (agree) or outside
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */ * (disagree) — the honest-mistake / fraud rate the outbox exists for. */
stats(): { stats(): {
booths: { booth: string; received: number; pending: number; reviewed: number }[]; booths: { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[]; operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
} { } {
const booths = this.#db const booths = this.#db
.prepare( .prepare(
`SELECT booth, COUNT(*) AS received, `SELECT booth, COUNT(*) AS received,
SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending, SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed,
SUM(CASE WHEN kind = 'entry' THEN 1 ELSE 0 END) AS entries
FROM items GROUP BY booth ORDER BY booth`, FROM items GROUP BY booth ORDER BY booth`,
) )
.all() as { booth: string; received: number; pending: number; reviewed: number }[]; .all() as { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
// Operator agreement is a WASH thing — an entry sample has no operator decision.
const reviewed = this.#db const reviewed = this.#db
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL") .prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL AND kind = 'wash'")
.all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[]; .all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[];
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>(); const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
for (const r of reviewed) { for (const r of reviewed) {
+5 -2
View File
@@ -80,10 +80,13 @@ async function next() {
el.innerHTML = el.innerHTML =
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' + '<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
'<dl style="margin-top:.8rem">' + '<dl style="margin-top:.8rem">' +
'<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>' + (it.kind === 'entry'
? '<dt>sample</dt><dd><span class="muted">entry stream — no wash, no operator decision; label the vehicle</span></dd>'
: '<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>') +
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' + '<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
(it.kind === 'entry' ? '<dt>booth</dt><dd class="mono">' + esc(it.booth) + '</dd>' :
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' + '<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>' + '<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>') +
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' + '<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
'</dl>' + '</dl>' +
'<div class="buttons" style="margin-top:.8rem">' + '<div class="buttons" style="margin-top:.8rem">' +
+5
View File
@@ -99,3 +99,8 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
# CARWASH_REVIEW_TOKEN= # CARWASH_REVIEW_TOKEN=
# CARWASH_REVIEW_BOOTH_ID= # CARWASH_REVIEW_BOOTH_ID=
# CARWASH_REVIEW_INTERVAL_SEC=60 # CARWASH_REVIEW_INTERVAL_SEC=60
# Entry-stream sampling: also queue one in N ENTRY vehicle reads (no wash, no operator) as
# pure training material in the gate view — many times the wash stream, zero domain shift.
# 1 = every entry (the reviewer labels what they have time for; the rest waits and stays
# useful), N = one in N, 0/unset = off. Needs the three settings above.
# CARWASH_REVIEW_ENTRY_SAMPLE=1
+19
View File
@@ -1,6 +1,7 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices"; import type { PrinterStatus } from "@parking/devices";
import type { LedgerEventRow } from "@parking/db"; import type { LedgerEventRow } from "@parking/db";
import type { VehicleRead } from "@parking/shared";
// Internal event bus for device-originated events (button presses, etc.). // Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry // Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -105,6 +106,17 @@ export interface PlateRecognizedEvent {
readonly direction: "entry" | "exit"; 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 /** 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 * 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: * 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); this.on("plate-recognized", cb);
return () => this.off("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. */ /** 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 type { ServerModule } from "../index.js";
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js"; import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
import { carwashRoutes } from "./routes.js"; import { carwashRoutes } from "./routes.js";
@@ -18,6 +19,13 @@ export const carwashModule: ServerModule = {
const outbox = new ReviewOutbox(deps.db, app.log, cfg); 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"); app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
outbox.start(); 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()); app.addHook("onClose", async () => outbox.stop());
const service = new CarwashService(deps, app.log, outbox); const service = new CarwashService(deps, app.log, outbox);
// A wash ordered with payAt = "booth" is a charge line on the parking settlement; // 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 { createTestDb } from "@parking/db/testing";
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db"; import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { deviceEvents as deviceEventBus } from "../../device-events.js";
import { buildServer } from "../../server.js"; import { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js"; import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js"; import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
@@ -81,7 +82,7 @@ describe("queue + drain", () => {
}); });
afterEach(() => close()); 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 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 }; 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]!; const row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row.status).toBe("queued"); expect(row.status).toBe("queued");
expect(row.image!.length).toBeGreaterThan(500); 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(JSON.stringify(row.payload)).not.toContain("lavazhier");
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 }); 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(fetchFn.mock.calls.length).toBe(before);
expect(ob.status().failed).toBe(3); 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. // 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, box: null })).toBe(false);
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).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_URL = "https://collector.overlay/ingest";
process.env.CARWASH_REVIEW_TOKEN = "tok"; process.env.CARWASH_REVIEW_TOKEN = "tok";
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9"; process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
process.env.CARWASH_REVIEW_ENTRY_SAMPLE = "1";
const t = createTestDb(); const t = createTestDb();
db = t.db; db = t.db;
close = t.close; close = t.close;
@@ -187,7 +201,7 @@ describe("through the app", () => {
afterEach(async () => { afterEach(async () => {
await app.close(); await app.close();
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]; if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[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. // Enqueue is fire-and-forget: give the crop a moment.
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1)); 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(); 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. */ /** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
readonly boothId: string; readonly boothId: string;
readonly intervalSec: number; 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. */ /** 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(); const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
if (!url || !token || !boothId) return null; if (!url || !token || !boothId) return null;
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60); 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 /** 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 failed: number;
readonly lastSentAt: string | null; readonly lastSentAt: string | null;
readonly lastError: string | null; readonly lastError: string | null;
/** 0 = entry sampling off; N = one in N entry reads is queued. */
readonly entrySample: number;
} }
export class ReviewOutbox { export class ReviewOutbox {
@@ -154,6 +165,7 @@ export class ReviewOutbox {
readonly #fetch: FetchLike; readonly #fetch: FetchLike;
#timer: NodeJS.Timeout | null = null; #timer: NodeJS.Timeout | null = null;
#draining = false; #draining = false;
#entrySeen = 0;
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) { constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
this.#db = db; this.#db = db;
@@ -172,35 +184,68 @@ export class ReviewOutbox {
* sample) or when upload is not configured (an unbounded queue nobody drains). */ * sample) or when upload is not configured (an unbounded queue nobody drains). */
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> { async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
if (!this.#cfg) return false; if (!this.#cfg) return false;
if (!read.box || !read.snapshotId) return false; return this.#queue(item.orderId, read, (id, crop) => ({
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`);
return false;
}
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
const id = randomUUID();
const payload = {
v: 1, v: 1,
booth: this.#cfg.boothId, kind: "wash",
booth: this.#cfg!.boothId,
item: id, item: id,
order: item.orderId, order: item.orderId,
at: item.createdAt, at: item.createdAt,
operator: operatorRef(this.#cfg.boothId, item.createdBy), operator: operatorRef(this.#cfg!.boothId, item.createdBy),
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] }, operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
service: item.serviceName, service: item.serviceName,
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId }, vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
downgraded: item.downgraded, downgraded: item.downgraded,
image: { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred }, 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) — ${ref} not queued`);
return false;
}
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
const id = randomUUID();
const payload = build(id, { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred });
this.#db this.#db
.insert(carwashReviewOutbox) .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(); .run();
return true; return true;
} catch (err) { } 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; return false;
} }
} }
@@ -320,6 +365,7 @@ export class ReviewOutbox {
return { return {
enabled: this.enabled, enabled: this.enabled,
boothId: this.#cfg?.boothId ?? null, boothId: this.#cfg?.boothId ?? null,
entrySample: this.#cfg?.entrySample ?? 0,
queued: count("queued"), queued: count("queued"),
sent: count("sent"), sent: count("sent"),
failed: count("failed"), 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 // 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. // reviewer, how many went, the last error. Site admin's read.
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () => 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) => { 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(), occurredAt: new Date().toISOString(),
}) })
.run(); .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; if (!plate) return;
logger.info(`anpr plate '${plate}' (${result.plate!.confidence.toFixed(3)}) for ${identity}`); 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 // The session's entry/exit event already shipped without this (async) plate — tell the
+1
View File
@@ -143,6 +143,7 @@ export const en: Catalog = {
reviewTitle: "Remote review", reviewTitle: "Remote review",
reviewOff: "off — no collector configured for this booth", reviewOff: "off — no collector configured for this booth",
reviewCounts: "{{queued}} waiting · {{sent}} delivered · {{failed}} abandoned", reviewCounts: "{{queued}} waiting · {{sent}} delivered · {{failed}} abandoned",
reviewEntrySample: "1 in {{n}} entries sampled",
reviewHint: "Each wash order sends the vehicle crop (plate blurred) and the chosen category to a trusted reviewer over the private network. One-way; nothing that names this site leaves.", reviewHint: "Each wash order sends the vehicle crop (plate blurred) and the chosen category to a trusted reviewer over the private network. One-way; nothing that names this site leaves.",
visionClassesHint: "The camera's fixed vocabulary (set in code, not here). Tick the classes this category covers.", visionClassesHint: "The camera's fixed vocabulary (set in code, not here). Tick the classes this category covers.",
visionThreshold: "Camera confidence to flag a downgrade", visionThreshold: "Camera confidence to flag a downgrade",
+1
View File
@@ -145,6 +145,7 @@ export const sq = {
reviewTitle: "Shqyrtim në distancë", reviewTitle: "Shqyrtim në distancë",
reviewOff: "joaktiv — asnjë mbledhës i konfiguruar për këtë kabinë", reviewOff: "joaktiv — asnjë mbledhës i konfiguruar për këtë kabinë",
reviewCounts: "{{queued}} në pritje · {{sent}} të dërguara · {{failed}} të braktisura", reviewCounts: "{{queued}} në pritje · {{sent}} të dërguara · {{failed}} të braktisura",
reviewEntrySample: "1 në {{n}} hyrje merret mostër",
reviewHint: "Çdo porosi lavazhi dërgon prerjen e mjetit (targa e turbulluar) dhe kategorinë e zgjedhur te një shqyrtues i besuar përmes rrjetit privat. Njëkahësh; asgjë që emërton këtë vend nuk del.", reviewHint: "Çdo porosi lavazhi dërgon prerjen e mjetit (targa e turbulluar) dhe kategorinë e zgjedhur te një shqyrtues i besuar përmes rrjetit privat. Njëkahësh; asgjë që emërton këtë vend nuk del.",
visionClassesHint: "Fjalori i fiksuar i kamerës (vendoset në kod, jo këtu). Shëno klasat që mbulon kjo kategori.", visionClassesHint: "Fjalori i fiksuar i kamerës (vendoset në kod, jo këtu). Shëno klasat që mbulon kjo kategori.",
visionThreshold: "Siguria e kamerës për të shënuar një ulje kategorie", visionThreshold: "Siguria e kamerës për të shënuar një ulje kategorie",
@@ -199,8 +199,11 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
const currency = settings?.currency ?? ""; const currency = settings?.currency ?? "";
return ( return (
<div className="mt-6 flex flex-wrap items-start gap-6"> // Two columns on a wide screen: the master data (categories with their camera-class
<section className="card w-full max-w-2xl p-4"> // chips, services, the price matrix, policies) takes the room it needs; the sponsorship
// program keeps a fixed, readable width beside it. Stacks on narrow screens.
<div className="mt-6 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,26rem)]">
<section className="card min-w-0 p-4">
<div className="grid gap-4"> <div className="grid gap-4">
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} visionMap /> <ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} visionMap />
<ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} /> <ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} />
@@ -274,6 +277,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
{review.enabled {review.enabled
? t("wash.reviewCounts", { queued: review.queued, sent: review.sent, failed: review.failed }) ? t("wash.reviewCounts", { queued: review.queued, sent: review.sent, failed: review.failed })
: t("wash.reviewOff")} : t("wash.reviewOff")}
{review.enabled && review.entrySample > 0 && <span className="ml-2 text-term-muted">· {t("wash.reviewEntrySample", { n: review.entrySample })}</span>}
{review.enabled && review.lastError && <span className="ml-2 text-term-amber">{review.lastError}</span>} {review.enabled && review.lastError && <span className="ml-2 text-term-amber">{review.lastError}</span>}
</span> </span>
<span className="hint">{t("wash.reviewHint")}</span> <span className="hint">{t("wash.reviewHint")}</span>
@@ -290,7 +294,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
</section> </section>
{canEdit && program && ( {canEdit && program && (
<section className="card w-full max-w-md p-4"> <section className="card min-w-0 p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div> <div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div>
<span className="hint">{t("wash.sponsorshipHint")}</span> <span className="hint">{t("wash.sponsorshipHint")}</span>
<div className="mt-2"> <div className="mt-2">
+2
View File
@@ -40,6 +40,8 @@ export interface CarwashReviewStatus {
failed: number; failed: number;
lastSentAt: string | null; lastSentAt: string | null;
lastError: string | null; lastError: string | null;
/** 0 = entry sampling off; N = one in N entry reads is queued as training material. */
entrySample: number;
} }
export function fetchCarwashReviewStatus(): Promise<CarwashReviewStatus> { export function fetchCarwashReviewStatus(): Promise<CarwashReviewStatus> {
return apiFetch("/api/carwash/review/status"); return apiFetch("/api/carwash/review/status");
+1
View File
@@ -33,6 +33,7 @@ services:
CARWASH_REVIEW_URL: ${CARWASH_REVIEW_URL:-} CARWASH_REVIEW_URL: ${CARWASH_REVIEW_URL:-}
CARWASH_REVIEW_TOKEN: ${CARWASH_REVIEW_TOKEN:-} CARWASH_REVIEW_TOKEN: ${CARWASH_REVIEW_TOKEN:-}
CARWASH_REVIEW_BOOTH_ID: ${CARWASH_REVIEW_BOOTH_ID:-} CARWASH_REVIEW_BOOTH_ID: ${CARWASH_REVIEW_BOOTH_ID:-}
CARWASH_REVIEW_ENTRY_SAMPLE: ${CARWASH_REVIEW_ENTRY_SAMPLE:-0}
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, # CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators # so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook". # CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
+6 -3
View File
@@ -94,9 +94,12 @@ MODULES_ENTITLED=parking,carwash
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md): the collector's ingest URL # Car Wash review outbox (wiki/concepts/vision-review-outbox.md): the collector's ingest URL
# on the Netbird overlay, this booth's pseudonymous id, and its token — the SAME secret the # on the Netbird overlay, this booth's pseudonymous id, and its token — the SAME secret the
# wash-collector stack lists under that id. Leave all three unset to keep the outbox off. # wash-collector stack lists under that id. Leave all three unset to keep the outbox off.
#CARWASH_REVIEW_URL=http://docker-station.nb.infra:8090/ingest CARWASH_REVIEW_URL=http://docker-station.nb.infra:8090/ingest
#CARWASH_REVIEW_BOOTH_ID=booth-2 CARWASH_REVIEW_BOOTH_ID=booth-2
#CARWASH_REVIEW_TOKEN=[[wash_review_token_booth_2]] CARWASH_REVIEW_TOKEN=[[wash_review_token_booth_2]]
# Also send ENTRY reads as training material (gate view, no wash): 1 = every entry (storage
# and bandwidth are not the limit; review what you have time for). N = one in N. 0 = off.
CARWASH_REVIEW_ENTRY_SAMPLE=1
VISION_ENABLED=1 VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach # platform-ws.ts, since the native WS plugin has no page context to auto-attach
+18
View File
@@ -39,6 +39,24 @@ locked-down collector without exposing anything to the open internet.
image is dropped from the row once delivered; a voided order is abandoned unsent; anything image is dropped from the row once delivered; a voided order is abandoned unsent; anything
older than 14 days is abandoned ("expired") rather than resurfacing a fortnight in a burst. older than 14 days is abandoned ("expired") rather than resurfacing a fortnight in a burst.
## The entry stream — the real accelerator (built 2026-09-07)
The wash stream is small; the **entry camera photographs every car**, in exactly the view the
classifier is trained on, with zero domain shift. So the booth can also queue **one in N entry
vehicle reads** as pure training material: the crop and the camera's class, *no* order, *no*
operator, *no* category — same crop-and-blur pipeline, same one-way path, same privacy
properties. `CARWASH_REVIEW_ENTRY_SAMPLE=N` (0/unset = off; needs the three upload settings).
Seam: the core announces every vehicle read (`deviceEvents.emitVehicleRead`, snapshot.ts, entry
and exit) and the Car Wash module decides — it samples entry reads in-process (`sampleEntry()`,
exactly one in N) and calls `enqueueEntry()`; the core never imports the module. Packages carry
`kind: "wash" | "entry"`; the collector stores the kind, the review screen shows an entry sample
as "entry stream — label the vehicle", the export carries a `kind` column, and **operator
agreement is computed from wash items only** (an entry sample has no operator decision).
An internet feed was considered the same day and kept OUT of the collector's ingest: licensed
sets only, in a separate folder with provenance, used as warm-up and weighted down, and never
the judge of accuracy — the evaluation set is gate crops only.
## The package ## The package
`multipart/form-data`: `meta` (JSON) + `image` (JPEG). Meta = `{ v, booth, item, order, at, `multipart/form-data`: `meta` (JSON) + `image` (JPEG). Meta = `{ v, booth, item, order, at,
+12
View File
@@ -3121,3 +3121,15 @@ the overlay address; commented `trainer` profile seam for the GPU), a third buil
build-images.yml, and a `wash-collector` stack on `art-docker-station` in komodo/resources.toml build-images.yml, and a `wash-collector` stack on `art-docker-station` in komodo/resources.toml
(secret refs to fill). Booth payload now carries `operatorCategory.classes`. Tests: app.test.ts. (secret refs to fill). Booth payload now carries `operatorCategory.classes`. Tests: app.test.ts.
Updated [[vision-review-outbox]], [[fleet-deployment-komodo]]. Updated [[vision-review-outbox]], [[fleet-deployment-komodo]].
## [2026-09-07] ingest | Entry-stream sampling for the review outbox
User asked about feeding internet pictures through the collector; assessment: licensed only,
separate folder, warm-up weight, never the evaluation set — and the stronger accelerator is the
ENTRY stream (every car, the gate view, zero domain shift). Built: `deviceEvents.emitVehicleRead`
from snapshot.ts (core announces; the module listens), `ReviewOutbox.sampleEntry()` (one in N,
in-process) + `enqueueEntry()` (crop + camera class, no order/operator/category), env
`CARWASH_REVIEW_ENTRY_SAMPLE` (compose + resources template + .env.example), packages carry
`kind`; the collector stores kind, the review screen shows entry samples as such, export has a
kind column, operator agreement is wash-only. Setup line shows "1 in N entries sampled". Also:
Setup → Car wash is a two-column grid (the master-data card was squeezed at max-w-2xl). Tests
on both sides. Updated [[vision-review-outbox]].