feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the open half (no bundled recognizer emits body_type yet, so the desk shows nothing until phase A lands in the vision service). - Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT, reason code carwash.categoryDowngrade; settings/order/lookup views carry the read. - Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node client normalises to the vocabulary and drops the rest. - Record: snapshot.ts stores the read in the plate's device_events row (or its own when the plate was unreadable); vehicleForIdentity() resolves it like the plate. - Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"), carwash_config.vision_threshold (signed config_change when it moves), four vision columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId. - Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup offers class chips per category and the threshold. Operator decides. - Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no reason prompt (user, 2026-09-06). Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -84,6 +84,7 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
|
||||
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
vehicle: null,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
};
|
||||
@@ -177,6 +178,7 @@ describe("AnprBridge", () => {
|
||||
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
vehicle: null,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
})),
|
||||
@@ -207,6 +209,7 @@ describe("AnprBridge", () => {
|
||||
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
vehicle: null,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
})),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { type Db } from "@parking/db";
|
||||
import { deviceEvents, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
|
||||
@@ -560,3 +560,87 @@ describe("a shift's activity log is per till", () => {
|
||||
expect((await app.inject({ method: "GET", url: "/api/events", headers: { cookie: c.cookie } })).statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vision category — advisory, flagged, never authoritative", () => {
|
||||
/** What snapshot.ts records when vision classifies the entry frame. */
|
||||
function seeVehicle(identity: string, bodyType: string, bodyConfidence: number) {
|
||||
db.insert(deviceEvents).values({
|
||||
id: `read-${identity}-${bodyType}`, deviceId: "cam-1", category: "camera", kind: "read",
|
||||
detail: { identity, direction: "entry", bodyType, bodyConfidence, snapshotId: "snap-1", source: "entry-exit-snapshot" },
|
||||
occurredAt: new Date().toISOString(),
|
||||
}).run();
|
||||
}
|
||||
async function mapClasses(a: Auth, ids: { car: string; suv: string }) {
|
||||
const cur = (await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json();
|
||||
const r = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: {
|
||||
categories: cur.categories.map((c: { id: string }) => ({ ...c, visionClasses: c.id === ids.suv ? ["suv", "pickup"] : c.id === ids.car ? ["car", "sedan", "hatchback"] : [] })),
|
||||
visionThreshold: 0.75,
|
||||
},
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
it("Setup maps the vocabulary onto site categories; the lookup suggests the mapped category", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
const saved = await mapClasses(a, ids);
|
||||
expect(saved.categories.find((c: { id: string }) => c.id === ids.suv).visionClasses).toEqual(["suv", "pickup"]);
|
||||
expect(saved.visionThreshold).toBe(0.75);
|
||||
expect((await events(a)).some((e) => e.type === "config_change" && e.payload.setting === "carwash.visionThreshold")).toBe(true);
|
||||
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { categories: [{ id: ids.car, name: "Car", visionClasses: ["spaceship"] }] } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
|
||||
await openSession("T-V1");
|
||||
seeVehicle("T-V1", "suv", 0.91);
|
||||
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
|
||||
expect(look.vision).toEqual({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
|
||||
expect(look.suggestedCategoryId).toBe(ids.suv);
|
||||
// Unmapped class → shown, nothing suggested.
|
||||
await openSession("T-V2");
|
||||
seeVehicle("T-V2", "bus", 0.99);
|
||||
const look2 = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V2", headers: { cookie: a.cookie } })).json();
|
||||
expect(look2.vision.bodyType).toBe("bus");
|
||||
expect(look2.suggestedCategoryId).toBeNull();
|
||||
});
|
||||
|
||||
it("a confident downgrade signs an anomaly with both categories and the snapshot; equal, upgrade or unsure reads do not; the order is never blocked", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await mapClasses(a, ids);
|
||||
const order = async (identity: string, categoryId: string) => {
|
||||
const r = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity, categoryId, serviceId: ids.std } });
|
||||
expect(r.statusCode).toBe(201);
|
||||
return r.json();
|
||||
};
|
||||
// Camera: SUV (0.91) — operator picks Car (cheaper) → flagged, recorded, still created.
|
||||
await openSession("T-D1"); seeVehicle("T-D1", "suv", 0.91);
|
||||
const down = await order("T-D1", ids.car);
|
||||
expect(down).toMatchObject({ visionClass: "suv", visionConfidence: 0.91, visionCategoryId: ids.suv, categoryId: ids.car });
|
||||
expect(down.downgradeEventId).toBeTruthy();
|
||||
const flag = (await events(a)).find((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")!;
|
||||
expect(flag).toBeTruthy();
|
||||
expect(flag.payload).toMatchObject({
|
||||
visionClass: "suv", visionCategoryName: "SUV", chosenCategoryName: "Car", operator: "boss",
|
||||
visionPriceMinor: 70000, chosenPriceMinor: 50000, snapshotId: "snap-1",
|
||||
});
|
||||
// Same category as the camera → nothing.
|
||||
await openSession("T-D2"); seeVehicle("T-D2", "suv", 0.91);
|
||||
expect((await order("T-D2", ids.suv)).downgradeEventId).toBeNull();
|
||||
// Upgrade (camera Car, operator SUV) → recorded on the order, no anomaly.
|
||||
await openSession("T-D3"); seeVehicle("T-D3", "sedan", 0.95);
|
||||
const up = await order("T-D3", ids.suv);
|
||||
expect(up).toMatchObject({ visionClass: "sedan", visionCategoryId: ids.car, downgradeEventId: null });
|
||||
// Below the site threshold → shown, never flagged.
|
||||
await openSession("T-D4"); seeVehicle("T-D4", "suv", 0.6);
|
||||
expect((await order("T-D4", ids.car)).downgradeEventId).toBeNull();
|
||||
// No read at all → nulls.
|
||||
await openSession("T-D5");
|
||||
expect(await order("T-D5", ids.car)).toMatchObject({ visionClass: null, visionCategoryId: null, downgradeEventId: null });
|
||||
expect((await events(a)).filter((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,10 +23,16 @@ import {
|
||||
type CarwashOrderView,
|
||||
type CarwashSettingsView,
|
||||
type ChargeLine,
|
||||
CARWASH_VISION_THRESHOLD_DEFAULT,
|
||||
isVehicleClass,
|
||||
reasonPayload,
|
||||
type VehicleClass,
|
||||
type VehicleRead,
|
||||
type Tender,
|
||||
type TillId,
|
||||
} from "@parking/shared";
|
||||
import type { EventLog } from "../../event-log.js";
|
||||
import { vehicleForIdentity } from "../../plate-lookup.js";
|
||||
import { effectiveModulesFor } from "../../modules.js";
|
||||
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
||||
import type { ShiftService } from "../../shift-service.js";
|
||||
@@ -57,11 +63,12 @@ export class CarwashError extends Error {
|
||||
}
|
||||
|
||||
export interface SettingsBody {
|
||||
categories?: { id?: string; name?: string; active?: boolean }[];
|
||||
categories?: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[];
|
||||
services?: { id?: string; name?: string; active?: boolean }[];
|
||||
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
|
||||
/** Where wash money is taken at this site (site-level policy). */
|
||||
payAt?: unknown;
|
||||
visionThreshold?: unknown;
|
||||
}
|
||||
|
||||
export interface CreateOrderInput {
|
||||
@@ -83,6 +90,10 @@ export interface TicketLookup {
|
||||
enteredAt: string | null;
|
||||
currency: string | null;
|
||||
orders: CarwashOrderView[];
|
||||
/** What the camera saw at entry (advisory) and the category the site mapping
|
||||
* suggests for it — the desk pre-selects it; the operator may change it. */
|
||||
vision: VehicleRead | null;
|
||||
suggestedCategoryId: string | null;
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
@@ -127,7 +138,7 @@ export class CarwashService {
|
||||
.where(isNull(carwashCategories.deletedAt))
|
||||
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
|
||||
.all()
|
||||
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
||||
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active, visionClasses: r.visionClasses.filter(isVehicleClass) }));
|
||||
const services = this.#db
|
||||
.select()
|
||||
.from(carwashServices)
|
||||
@@ -142,7 +153,7 @@ export class CarwashService {
|
||||
.all()
|
||||
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
|
||||
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor }));
|
||||
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt() };
|
||||
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt(), visionThreshold: this.visionThreshold() };
|
||||
}
|
||||
|
||||
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
|
||||
@@ -151,6 +162,25 @@ export class CarwashService {
|
||||
return row?.payAt ?? CARWASH_PAY_AT_DEFAULT;
|
||||
}
|
||||
|
||||
/** Confidence floor for a vision class to flag a category downgrade (site config). */
|
||||
visionThreshold(): number {
|
||||
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
|
||||
return row?.visionThreshold ?? CARWASH_VISION_THRESHOLD_DEFAULT;
|
||||
}
|
||||
|
||||
/** The category the site mapping suggests for a vision class (first active category
|
||||
* listing it, in display order), or null when unmapped. */
|
||||
#categoryForClass(cls: VehicleClass): { id: string; name: string } | null {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(carwashCategories)
|
||||
.where(isNull(carwashCategories.deletedAt))
|
||||
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
|
||||
.all();
|
||||
const hit = rows.find((r) => r.active && r.visionClasses.includes(cls));
|
||||
return hit ? { id: hit.id, name: hit.name } : null;
|
||||
}
|
||||
|
||||
/** The site's currency = the active tariff's (the wash is priced in the same money
|
||||
* the booth takes). null when no tariff is published yet. */
|
||||
#currency(): string | null {
|
||||
@@ -171,7 +201,7 @@ export class CarwashService {
|
||||
const now = new Date().toISOString();
|
||||
const upsertList = (
|
||||
table: typeof carwashCategories | typeof carwashServices,
|
||||
items: { id?: string; name?: string; active?: boolean }[] | undefined,
|
||||
items: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[] | undefined,
|
||||
label: string,
|
||||
): string[] => {
|
||||
if (items === undefined) {
|
||||
@@ -190,11 +220,19 @@ export class CarwashService {
|
||||
while (seen.has(id)) id = `${id}-${sort}`;
|
||||
seen.add(id);
|
||||
const active = it.active !== false;
|
||||
// Vision mapping lives on CATEGORIES only; absent = keep what the row has.
|
||||
let visionClasses: string[] | undefined;
|
||||
if (table === carwashCategories && it.visionClasses !== undefined) {
|
||||
if (!Array.isArray(it.visionClasses) || !it.visionClasses.every(isVehicleClass)) {
|
||||
throw new CarwashError(400, `${label}: visionClasses must be an array of vehicle classes`);
|
||||
}
|
||||
visionClasses = [...new Set(it.visionClasses as string[])];
|
||||
}
|
||||
const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
|
||||
if (existing) {
|
||||
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null }).where(eq(table.id, id)).run();
|
||||
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null, ...(visionClasses ? { visionClasses } : {}) }).where(eq(table.id, id)).run();
|
||||
} else {
|
||||
this.#db.insert(table).values({ id, name, sortOrder: sort, active }).run();
|
||||
this.#db.insert(table).values({ id, name, sortOrder: sort, active, ...(visionClasses ? { visionClasses } : {}) }).run();
|
||||
}
|
||||
keep.push(id);
|
||||
sort += 1;
|
||||
@@ -260,6 +298,25 @@ export class CarwashService {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (body.visionThreshold !== undefined) {
|
||||
const v = Number(body.visionThreshold);
|
||||
if (!Number.isFinite(v) || v < 0 || v > 1) throw new CarwashError(400, "visionThreshold must be between 0 and 1");
|
||||
const prev = this.visionThreshold();
|
||||
if (v !== prev) {
|
||||
this.#db
|
||||
.insert(carwashConfig)
|
||||
.values({ id: 1, visionThreshold: v, updatedAt: now, updatedBy: actor })
|
||||
.onConflictDoUpdate({ target: carwashConfig.id, set: { visionThreshold: v, updatedAt: now, updatedBy: actor } })
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: "module:carwash",
|
||||
payload: { setting: "carwash.visionThreshold", value: v, prev, operator: actor },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.settings();
|
||||
}
|
||||
|
||||
@@ -289,6 +346,10 @@ export class CarwashService {
|
||||
validationEventId: r.validationEventId,
|
||||
voidBy: r.voidBy,
|
||||
voidReason: r.voidReason,
|
||||
visionClass: isVehicleClass(r.visionClass) ? r.visionClass : null,
|
||||
visionConfidence: r.visionConfidence,
|
||||
visionCategoryId: r.visionCategoryId,
|
||||
downgradeEventId: r.downgradeEventId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -335,6 +396,7 @@ export class CarwashService {
|
||||
lookup(identity: string): TicketLookup {
|
||||
const id = identity.trim();
|
||||
const s = this.#pay.lookup(id);
|
||||
const vision = s.found ? vehicleForIdentity(this.#db, id) : null;
|
||||
return {
|
||||
identity: id,
|
||||
found: s.found,
|
||||
@@ -344,6 +406,8 @@ export class CarwashService {
|
||||
enteredAt: s.enteredAt,
|
||||
currency: s.currency,
|
||||
orders: this.#ordersFor(id),
|
||||
vision,
|
||||
suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,6 +447,51 @@ export class CarwashService {
|
||||
const currency = s.currency ?? this.#pay.activeCurrency();
|
||||
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff");
|
||||
|
||||
// Vision, advisory: what the camera saw at entry and the category the site maps it
|
||||
// to. A DOWNGRADE — the operator chose a category that prices LOWER than the mapped
|
||||
// one for this service, with the read above the site threshold — is signed as an
|
||||
// anomaly for the reviewer (both categories, operator, snapshot). Recorded only:
|
||||
// never blocks, no reason prompt (user, 2026-09-06).
|
||||
const vision = vehicleForIdentity(this.#db, identity);
|
||||
const visionCategory = vision ? this.#categoryForClass(vision.bodyType) : null;
|
||||
let downgradeEventId: string | null = null;
|
||||
if (vision && visionCategory && visionCategory.id !== category.id && vision.confidence >= this.visionThreshold()) {
|
||||
const visionPrice = this.#db
|
||||
.select()
|
||||
.from(carwashPrices)
|
||||
.where(and(eq(carwashPrices.categoryId, visionCategory.id), eq(carwashPrices.serviceId, service.id)))
|
||||
.get();
|
||||
if (visionPrice && visionPrice.priceMinor > price.priceMinor) {
|
||||
const ev = await this.#log.append({
|
||||
type: "anomaly",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
...reasonPayload("carwash.categoryDowngrade", {
|
||||
visionClass: vision.bodyType,
|
||||
visionCategory: visionCategory.name,
|
||||
operator: input.actor,
|
||||
chosenCategory: category.name,
|
||||
}),
|
||||
sessionRef: identity,
|
||||
visionClass: vision.bodyType,
|
||||
visionConfidence: vision.confidence,
|
||||
visionCategoryId: visionCategory.id,
|
||||
visionCategoryName: visionCategory.name,
|
||||
chosenCategoryId: category.id,
|
||||
chosenCategoryName: category.name,
|
||||
serviceName: service.name,
|
||||
visionPriceMinor: visionPrice.priceMinor,
|
||||
chosenPriceMinor: price.priceMinor,
|
||||
currency,
|
||||
snapshotId: vision.snapshotId,
|
||||
operator: input.actor,
|
||||
},
|
||||
});
|
||||
downgradeEventId = ev.id;
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const row: CarwashOrderRow = {
|
||||
id: randomUUID(),
|
||||
@@ -408,6 +517,10 @@ export class CarwashService {
|
||||
voidAt: null,
|
||||
voidBy: null,
|
||||
voidReason: null,
|
||||
visionClass: vision?.bodyType ?? null,
|
||||
visionConfidence: vision?.confidence ?? null,
|
||||
visionCategoryId: visionCategory?.id ?? null,
|
||||
downgradeEventId,
|
||||
};
|
||||
this.#db.insert(carwashOrders).values(row).run();
|
||||
await this.#log.append({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||||
import { isVehicleClass, type VehicleRead } from "@parking/shared";
|
||||
|
||||
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
|
||||
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
|
||||
@@ -23,6 +24,30 @@ interface ReadDetail {
|
||||
plate?: string;
|
||||
confidence?: number;
|
||||
direction?: string;
|
||||
bodyType?: string;
|
||||
bodyConfidence?: number;
|
||||
snapshotId?: string;
|
||||
}
|
||||
|
||||
/** The advisory VEHICLE read (body type) for a session — the same stream and the same
|
||||
* preference as the plate (entry over exit, newest first). Null when vision never
|
||||
* classified the vehicle. See venue-modules.md §Vehicle category from vision. */
|
||||
export function vehicleForIdentity(db: Db, identity: string): VehicleRead | null {
|
||||
const rows = db
|
||||
.select({ detail: deviceEvents.detail })
|
||||
.from(deviceEvents)
|
||||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||||
.orderBy(desc(deviceEvents.occurredAt))
|
||||
.all();
|
||||
let fallback: VehicleRead | null = null;
|
||||
for (const r of rows) {
|
||||
const d = (r.detail ?? {}) as ReadDetail;
|
||||
if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue;
|
||||
const v: VehicleRead = { bodyType: d.bodyType, confidence: d.bodyConfidence, snapshotId: d.snapshotId ?? null };
|
||||
if (d.direction === "entry") return v;
|
||||
if (!fallback) fallback = v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
|
||||
|
||||
@@ -167,22 +167,29 @@ async function recognizePlate(
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||
if (!result || !result.plate || result.lowConfidence) return; // nothing trustworthy to record
|
||||
const plate = result.plate.text.trim().toUpperCase();
|
||||
if (!plate) return;
|
||||
if (!result) return;
|
||||
// The vehicle's body type (advisory; the wash desk's category suggestion — see
|
||||
// venue-modules.md §Vehicle category). Rides the plate's read row when there is one,
|
||||
// else a row of its own: a car with an unreadable plate is still a car of some class.
|
||||
const vehicle = result.vehicle
|
||||
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence }
|
||||
: {};
|
||||
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
|
||||
if (!plate && !result.vehicle) return; // nothing trustworthy to record
|
||||
db.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId,
|
||||
category: "camera",
|
||||
kind: "read",
|
||||
// `identity` ties the plate to the session; `snapshotId` to the evidence image.
|
||||
// `identity` ties the read to the session; `snapshotId` to the evidence image.
|
||||
detail: {
|
||||
identity,
|
||||
direction,
|
||||
plate,
|
||||
confidence: result.plate.confidence,
|
||||
region: result.plate.region ?? null,
|
||||
...(plate
|
||||
? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null }
|
||||
: {}),
|
||||
...vehicle,
|
||||
modelVersion: result.modelVersion,
|
||||
snapshotId,
|
||||
source: "entry-exit-snapshot",
|
||||
@@ -190,7 +197,9 @@ async function recognizePlate(
|
||||
occurredAt: new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
|
||||
if (result.vehicle) logger.info(`vision vehicle '${result.vehicle.bodyType}' (${result.vehicle.confidence.toFixed(3)}) for ${identity}`);
|
||||
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
|
||||
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
|
||||
deviceEvents.emitPlateRecognized({ identity, plate, direction });
|
||||
|
||||
@@ -23,6 +23,8 @@ import type { FastifyBaseLogger } from "fastify";
|
||||
// transport + contract adapter only.
|
||||
|
||||
/** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */
|
||||
import { isVehicleClass, type VehicleClass } from "@parking/shared";
|
||||
|
||||
export interface PlateBBox {
|
||||
readonly x1: number;
|
||||
readonly y1: number;
|
||||
@@ -40,12 +42,19 @@ export interface VisionPlate {
|
||||
readonly region?: string | null;
|
||||
}
|
||||
|
||||
/** The raw /analyze response shape (the Python contract). `vehicle` is reserved for
|
||||
* Job 2 (vehicle verification) — not yet produced. */
|
||||
/** The vehicle attributes stage of /analyze (advisory). `body_type` is one of the shared
|
||||
* VEHICLE_CLASSES vocabulary (the service's raw label is normalised there); a stub or a
|
||||
* plate-only recognizer sends null. */
|
||||
export interface VisionVehicle {
|
||||
readonly bodyType: VehicleClass;
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
/** The raw /analyze response shape (the Python contract). */
|
||||
interface AnalyzeResponse {
|
||||
readonly plate: VisionPlate | null;
|
||||
readonly plates: VisionPlate[];
|
||||
readonly vehicle: unknown | null;
|
||||
readonly vehicle: { body_type?: string | null; confidence?: number | null } | null;
|
||||
readonly low_confidence: boolean;
|
||||
readonly model_version: string;
|
||||
readonly took_ms: number;
|
||||
@@ -61,6 +70,8 @@ export interface VisionResult {
|
||||
/** True when the best plate is below the confidence floor — treat as advisory only
|
||||
* and fall back to the ticket/manual path. */
|
||||
readonly lowConfidence: boolean;
|
||||
/** The vehicle's body type, when the service ran that stage and named a known class. */
|
||||
readonly vehicle: VisionVehicle | null;
|
||||
readonly modelVersion: string;
|
||||
readonly tookMs: number;
|
||||
}
|
||||
@@ -124,10 +135,16 @@ export class VisionClient {
|
||||
const best = res.plate ?? null;
|
||||
const lowConfidence =
|
||||
res.low_confidence || (best != null && best.confidence < this.#minConfidence);
|
||||
const v = res.vehicle;
|
||||
const vehicle: VisionVehicle | null =
|
||||
v && isVehicleClass(v.body_type) && typeof v.confidence === "number"
|
||||
? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)) }
|
||||
: null;
|
||||
return {
|
||||
plate: best,
|
||||
plates: Array.isArray(res.plates) ? res.plates : [],
|
||||
lowConfidence,
|
||||
vehicle,
|
||||
modelVersion: res.model_version ?? "unknown",
|
||||
tookMs: typeof res.took_ms === "number" ? res.took_ms : 0,
|
||||
};
|
||||
|
||||
@@ -31,10 +31,18 @@ class PlateResult(BaseModel):
|
||||
|
||||
|
||||
class VehicleResult(BaseModel):
|
||||
"""Job 2 — vehicle attributes / fingerprint (anti-spoofing). Not yet produced."""
|
||||
"""Job 2 — vehicle attributes. `body_type` is ADVISORY: the Node server records it
|
||||
beside the plate and the Car Wash desk pre-selects the site category it maps to; the
|
||||
operator decides, a disagreement is flagged, nothing is ever gated on it. Values come
|
||||
from the shared vocabulary (car, sedan, hatchback, suv, minivan, pickup, van, truck,
|
||||
bus, motorcycle) — anything else is ignored by Node. Phase A (a COCO detector) emits
|
||||
car/truck/bus/motorcycle; the finer classes need the body-type classifier. Not yet
|
||||
produced by any bundled recognizer."""
|
||||
|
||||
colour: str | None = None
|
||||
body_type: str | None = None
|
||||
# Confidence of `body_type` (0–1). Node compares it to the site's threshold.
|
||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
make: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
@@ -67,6 +67,18 @@ export const en: Catalog = {
|
||||
carwash: "Car wash",
|
||||
},
|
||||
},
|
||||
vehicleClass: {
|
||||
car: "car",
|
||||
sedan: "sedan",
|
||||
hatchback: "hatchback",
|
||||
suv: "SUV",
|
||||
minivan: "minivan",
|
||||
pickup: "pickup",
|
||||
van: "van",
|
||||
truck: "truck",
|
||||
bus: "bus",
|
||||
motorcycle: "motorcycle",
|
||||
},
|
||||
wash: {
|
||||
tillTitle: "Wash till",
|
||||
tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.",
|
||||
@@ -124,6 +136,12 @@ export const en: Catalog = {
|
||||
sponsorship: "Parking discount",
|
||||
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
|
||||
sponsorshipLabel: "Car wash",
|
||||
// Vision (advisory): the entry camera's body-type read, mapped to a site category.
|
||||
visionSaw: "Camera saw",
|
||||
visionUnmapped: "not mapped to a category",
|
||||
visionClasses: "Camera classes",
|
||||
visionThreshold: "Camera confidence to flag a downgrade",
|
||||
visionThresholdHint: "When the camera is at least this sure and the operator picks a cheaper category than the one its class maps to, the order is flagged for review. It is never blocked.",
|
||||
},
|
||||
update: {
|
||||
available: "Update available",
|
||||
@@ -385,6 +403,7 @@ export const en: Catalog = {
|
||||
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
|
||||
"entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry",
|
||||
"entry.duplicatePlate": "Possible duplicate entry — plate {{plate}} is already inside under ticket {{otherIdentity}}",
|
||||
"carwash.categoryDowngrade": "Wash category downgraded — camera saw {{visionClass}} ({{visionCategory}}), operator {{operator}} chose {{chosenCategory}}",
|
||||
"exit.refused.closed": "Exit refused — session already closed",
|
||||
"exit.refused.noSession": "Exit refused — unknown ticket",
|
||||
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
||||
|
||||
@@ -70,6 +70,18 @@ export const sq = {
|
||||
carwash: "Lavazh",
|
||||
},
|
||||
},
|
||||
vehicleClass: {
|
||||
car: "veturë",
|
||||
sedan: "sedan",
|
||||
hatchback: "hatchback",
|
||||
suv: "SUV",
|
||||
minivan: "minivan",
|
||||
pickup: "pikap",
|
||||
van: "furgon",
|
||||
truck: "kamion",
|
||||
bus: "autobus",
|
||||
motorcycle: "motor",
|
||||
},
|
||||
wash: {
|
||||
tillTitle: "Arka e lavazhit",
|
||||
tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.",
|
||||
@@ -127,6 +139,11 @@ export const sq = {
|
||||
sponsorship: "Zbritje parkimi",
|
||||
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
|
||||
sponsorshipLabel: "Lavazh",
|
||||
visionSaw: "Kamera pa",
|
||||
visionUnmapped: "pa kategori të lidhur",
|
||||
visionClasses: "Klasat e kamerës",
|
||||
visionThreshold: "Siguria e kamerës për të shënuar një ulje kategorie",
|
||||
visionThresholdHint: "Kur kamera është të paktën kaq e sigurt dhe operatori zgjedh një kategori më të lirë se ajo ku lidhet klasa, porosia shënohet për shqyrtim. Nuk bllokohet kurrë.",
|
||||
},
|
||||
update: {
|
||||
available: "Përditësim i disponueshëm",
|
||||
@@ -389,6 +406,7 @@ export const sq = {
|
||||
"entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)",
|
||||
"entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja",
|
||||
"entry.duplicatePlate": "Hyrje e dyfishtë e mundshme — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
|
||||
"carwash.categoryDowngrade": "Kategoria e lavazhit u ul — kamera pa {{visionClass}} ({{visionCategory}}), operatori {{operator}} zgjodhi {{chosenCategory}}",
|
||||
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
|
||||
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
||||
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, type CarWashPayAt } from "@parking/shared";
|
||||
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, VEHICLE_CLASSES, type CarWashPayAt, type VehicleClass } from "@parking/shared";
|
||||
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
|
||||
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
|
||||
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
|
||||
@@ -10,7 +10,7 @@ import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } f
|
||||
// validation program (id "carwash"), composed with the same editor the merchant
|
||||
// programs use. See wiki/decisions/venue-modules.md ("v1 answers").
|
||||
|
||||
type Item = { id?: string; name: string; active: boolean };
|
||||
type Item = { id?: string; name: string; active: boolean; visionClasses?: VehicleClass[] };
|
||||
|
||||
const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, ""));
|
||||
const toMinor = (s: string): number | null => {
|
||||
@@ -25,18 +25,32 @@ function ListEditor({
|
||||
items,
|
||||
onChange,
|
||||
addLabel,
|
||||
visionMap,
|
||||
}: {
|
||||
title: string;
|
||||
items: Item[];
|
||||
onChange: (items: Item[]) => void;
|
||||
addLabel: string;
|
||||
/** Categories only: offer the vision vocabulary as chips under each row — the site's
|
||||
* own "car, sedan → Vetura" mapping (venue-modules.md §Vehicle category). */
|
||||
visionMap?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toggleClass = (i: number, cls: VehicleClass) =>
|
||||
onChange(
|
||||
items.map((x, j) => {
|
||||
if (j !== i) return x;
|
||||
const cur = new Set(x.visionClasses ?? []);
|
||||
cur.has(cls) ? cur.delete(cls) : cur.add(cls);
|
||||
return { ...x, visionClasses: VEHICLE_CLASSES.filter((c) => cur.has(c)) };
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
|
||||
{items.map((it, i) => (
|
||||
<div key={it.id ?? `new-${i}`} className="flex items-center gap-2">
|
||||
<div key={it.id ?? `new-${i}`} className="grid gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={it.name}
|
||||
@@ -55,6 +69,20 @@ function ListEditor({
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{visionMap && (
|
||||
<div className="flex flex-wrap items-center gap-1 pl-1">
|
||||
<span className="mr-1 text-[0.625rem] uppercase tracking-wider text-term-muted">{t("wash.visionClasses")}</span>
|
||||
{VEHICLE_CLASSES.map((cls) => {
|
||||
const on = (it.visionClasses ?? []).includes(cls);
|
||||
return (
|
||||
<button key={cls} type="button" className={`btn btn-sm ${on ? "btn-primary" : "btn-ghost"}`} onClick={() => toggleClass(i, cls)}>
|
||||
{t(`vehicleClass.${cls}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
|
||||
+ {addLabel}
|
||||
@@ -73,6 +101,8 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
const [prices, setPrices] = useState<Record<string, string>>({});
|
||||
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
|
||||
const [payAt, setPayAt] = useState<CarWashPayAt>("booth");
|
||||
/** Confidence floor for a vision read to flag a downgrade (percent, as typed). */
|
||||
const [threshold, setThreshold] = useState("80");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [program, setProgram] = useState<ValidationProgramView | null>(null);
|
||||
|
||||
@@ -80,12 +110,13 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
fetchCarwashSettings()
|
||||
.then((s) => {
|
||||
setSettings(s);
|
||||
setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
|
||||
setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active, visionClasses: c.visionClasses })));
|
||||
setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
|
||||
const p: Record<string, string> = {};
|
||||
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||
setPrices(p);
|
||||
setPayAt(s.payAt);
|
||||
setThreshold(String(Math.round(s.visionThreshold * 100)));
|
||||
})
|
||||
.catch((e) => setMsg((e as Error).message));
|
||||
fetchValidationPrograms()
|
||||
@@ -103,7 +134,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
setMsg(null);
|
||||
try {
|
||||
const listBody = {
|
||||
categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
|
||||
categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active, visionClasses: c.visionClasses ?? [] })),
|
||||
services: services.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
|
||||
};
|
||||
// New rows have no id until the server assigns one, and the price matrix is keyed
|
||||
@@ -133,15 +164,18 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
|
||||
}),
|
||||
);
|
||||
const thr = Number(threshold);
|
||||
const saved = await saveCarwashSettings({
|
||||
categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
|
||||
categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active, visionClasses: c.visionClasses ?? [] })),
|
||||
services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
|
||||
prices: priceRows,
|
||||
payAt,
|
||||
...(Number.isFinite(thr) && thr >= 0 && thr <= 100 ? { visionThreshold: thr / 100 } : {}),
|
||||
});
|
||||
setSettings(saved);
|
||||
setPayAt(saved.payAt);
|
||||
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
|
||||
setThreshold(String(Math.round(saved.visionThreshold * 100)));
|
||||
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active, visionClasses: c.visionClasses })));
|
||||
setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
|
||||
const p: Record<string, string> = {};
|
||||
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||
@@ -158,7 +192,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||
<section className="card w-full max-w-2xl p-4">
|
||||
<div className="grid gap-4">
|
||||
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} />
|
||||
<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")} />
|
||||
|
||||
<div>
|
||||
@@ -215,6 +249,14 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
</div>
|
||||
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("wash.visionThreshold")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="input w-20 text-right tabular-nums" inputMode="numeric" value={threshold} disabled={!canEdit} onChange={(e) => setThreshold(e.target.value)} />
|
||||
<span className="text-[0.75rem] text-term-muted">%</span>
|
||||
</div>
|
||||
<span className="hint">{t("wash.visionThresholdHint")}</span>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -73,7 +73,10 @@ export function WashDesk() {
|
||||
setMsg(null);
|
||||
if (!ticket.trim()) return;
|
||||
try {
|
||||
setLookup(await lookupCarwashTicket(ticket));
|
||||
const found = await lookupCarwashTicket(ticket);
|
||||
setLookup(found);
|
||||
// Vision proposes, the operator decides: pre-select the mapped category.
|
||||
if (found.suggestedCategoryId) setCategoryId(found.suggestedCategoryId);
|
||||
} catch (err) {
|
||||
setMsg((err as Error).message);
|
||||
}
|
||||
@@ -170,6 +173,23 @@ export function WashDesk() {
|
||||
<span className="text-term-muted">{t("wash.plate")}</span>
|
||||
<span className="font-mono">{lookup.plate ?? "—"}</span>
|
||||
</div>
|
||||
{lookup.vision && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-term-muted">{t("wash.visionSaw")}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{lookup.vision.snapshotId && (
|
||||
<img src={`/api/snapshots/${lookup.vision.snapshotId}`} alt="" className="h-8 w-12 rounded-sm object-cover" />
|
||||
)}
|
||||
<span>
|
||||
{t(`vehicleClass.${lookup.vision.bodyType}`)}
|
||||
<span className="ml-1 tabular-nums text-term-muted">{Math.round(lookup.vision.confidence * 100)}%</span>
|
||||
{lookup.suggestedCategoryId
|
||||
? <span className="ml-1 text-term-amber">→ {categories.find((c) => c.id === lookup.suggestedCategoryId)?.name ?? lookup.suggestedCategoryId}</span>
|
||||
: <span className="ml-1 text-term-muted">{t("wash.visionUnmapped")}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{lookup.enteredAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-term-muted">{t("wash.enteredAt")}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender } from "@parking/shared";
|
||||
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender, VehicleClass, VehicleRead } from "@parking/shared";
|
||||
import { apiFetch } from "../../api.js";
|
||||
|
||||
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
|
||||
@@ -15,14 +15,20 @@ export interface CarwashTicketLookup {
|
||||
enteredAt: string | null;
|
||||
currency: string | null;
|
||||
orders: CarwashOrderView[];
|
||||
/** What the camera saw at entry (advisory) and the category the site mapping
|
||||
* suggests — pre-selected on the desk; the operator may change it. */
|
||||
vision: VehicleRead | null;
|
||||
suggestedCategoryId: string | null;
|
||||
}
|
||||
|
||||
export interface CarwashSettingsBody {
|
||||
categories?: { id?: string; name: string; active?: boolean }[];
|
||||
categories?: { id?: string; name: string; active?: boolean; visionClasses?: VehicleClass[] }[];
|
||||
services?: { id?: string; name: string; active?: boolean }[];
|
||||
prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
|
||||
/** Where wash money is taken at this site (site-level; the desk no longer asks). */
|
||||
payAt?: CarWashPayAt;
|
||||
/** Confidence floor (0–1) for a vision class to flag a category downgrade. */
|
||||
visionThreshold?: number;
|
||||
}
|
||||
|
||||
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
||||
|
||||
Reference in New Issue
Block a user