Compare commits
20 Commits
ea304bbfd1
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 29594f8bad | |||
| 4ff31557a8 | |||
| 3e77a4ad7c | |||
| 3f16925fe0 | |||
| f7a262ac9a | |||
| f9cb973fe9 | |||
| 1a0fe59488 | |||
| 8bfc29db2a | |||
| dbbb051ebd | |||
| 3e57af5abc | |||
| ef55d1c6a9 | |||
| 0411b71c2d | |||
| b485e9870b | |||
| ec44547122 | |||
| e67f0ccef0 | |||
| 78ca58d264 | |||
| 20a3cb3e80 | |||
| 5e1395db18 | |||
| 50c18405b6 | |||
| e14e31a840 |
@@ -1,6 +1,6 @@
|
||||
name: Build & push images
|
||||
|
||||
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
||||
# Build the SERVER (API + SPA), COLLECTOR (wash review), VISION (ANPR) and TRAINER (phase-B job) container images and push them to the
|
||||
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
|
||||
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
|
||||
# Mirrors the house pattern (cf. trm/processor build.yml). See
|
||||
@@ -13,6 +13,8 @@ on:
|
||||
- 'apps/server/**'
|
||||
- 'apps/web/**'
|
||||
- 'apps/vision/**'
|
||||
- 'apps/collector/**'
|
||||
- 'apps/trainer/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
@@ -60,6 +62,11 @@ jobs:
|
||||
working-directory: apps/vision
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Sync trainer deps
|
||||
# Light core only — NOT the `train` extra (CPU torch, ~200 MB); the torch tests skip.
|
||||
working-directory: apps/trainer
|
||||
run: uv sync --frozen
|
||||
|
||||
# Don't publish a broken image — run the same checks as ci.yml first.
|
||||
- name: Build + lint + test (Turbo)
|
||||
run: pnpm turbo run build lint test
|
||||
@@ -100,18 +107,47 @@ jobs:
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
|
||||
|
||||
- name: Build & push COLLECTOR (wash review)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: apps/collector/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}
|
||||
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache,mode=max
|
||||
|
||||
- name: Build & push VISION (ANPR)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: apps/vision
|
||||
file: apps/vision/Dockerfile
|
||||
push: true
|
||||
# The phase-B body-type classifier is fetched from the Gitea generic package registry
|
||||
# at build when apps/vision/models/bodytype.version pins a version (empty = none). The
|
||||
# registry user's credentials double as the fetch auth (BuildKit secret, never a layer).
|
||||
secrets: |
|
||||
bodytype_auth=${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}
|
||||
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache,mode=max
|
||||
|
||||
- name: Build & push TRAINER (phase-B job)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: apps/trainer
|
||||
file: apps/trainer/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/parking-trainer:${{ steps.meta.outputs.branch }}
|
||||
${{ env.REGISTRY }}/parking-trainer:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-trainer:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-trainer:buildcache,mode=max
|
||||
|
||||
# Optional: trigger a Komodo stack redeploy (cf. trm/processor). Enable by setting the
|
||||
# KOMODO_* secrets; left guarded so it no-ops until the parking stack is wired.
|
||||
- name: Trigger Komodo redeploy
|
||||
|
||||
@@ -48,6 +48,11 @@ jobs:
|
||||
working-directory: apps/vision
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Sync trainer deps
|
||||
# Same rule: light core only, not the `train` extra (CPU torch); torch tests skip.
|
||||
working-directory: apps/trainer
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Build + lint (Turbo)
|
||||
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
|
||||
# key fails the build), AND the vision service's ruff lint via uv.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Car Wash review collector (wiki/concepts/vision-review-outbox.md). Runs on the
|
||||
# reviewer's host (art-docker-station), reachable by the booths ONLY over the Netbird
|
||||
# overlay. Deployed by its own Komodo stack (komodo/resources.toml, "wash-collector").
|
||||
|
||||
# COLLECTOR_HOST=0.0.0.0 # in Docker the compose file binds the published port to the overlay IP
|
||||
# COLLECTOR_PORT=8090
|
||||
# COLLECTOR_DATA_DIR=/data # collector.sqlite + crops/<booth>/<item>.jpg
|
||||
|
||||
# One bearer token per booth: "<boothId>:<token>" pairs, comma- or newline-separated. The
|
||||
# booth id is the pseudonymous CARWASH_REVIEW_BOOTH_ID that booth was deployed with — never
|
||||
# a site name. Generate tokens with: openssl rand -hex 32
|
||||
COLLECTOR_BOOTH_TOKENS=booth-7:REPLACE,booth-9:REPLACE
|
||||
|
||||
# The reviewer's login for the review screen and the export (HTTP Basic over the overlay).
|
||||
COLLECTOR_REVIEWER_USER=reviewer
|
||||
COLLECTOR_REVIEWER_PASS=REPLACE
|
||||
@@ -0,0 +1,48 @@
|
||||
# parking-collector — the Car Wash review collector (wiki/concepts/vision-review-outbox.md).
|
||||
# Built from the monorepo root (context: .) like the server image, so it shares the
|
||||
# lockfile and @parking/shared. Runs on the REVIEWER's host (not a booth), delivered by
|
||||
# its own Komodo stack (docker-compose.collector.yml). Data on /data: collector.sqlite +
|
||||
# crops/<booth>/<item>.jpg — the trainer on the same host reads the crops off that volume.
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
|
||||
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/server/package.json apps/server/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/vision/package.json apps/vision/
|
||||
COPY apps/collector/package.json apps/collector/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/devices/package.json packages/devices/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm fetch
|
||||
|
||||
FROM deps AS build
|
||||
ENV CI=true
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --offline
|
||||
RUN pnpm turbo run build --filter=@parking/collector
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm --filter=@parking/collector --legacy deploy --prod /deploy
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ARG BUILD_VERSION=""
|
||||
ENV BUILD_VERSION=$BUILD_VERSION
|
||||
ENV NODE_ENV=production
|
||||
RUN apk add --no-cache libstdc++ wget # better-sqlite3 native runtime; wget for the healthcheck
|
||||
RUN addgroup -S app && adduser -S -G app app
|
||||
COPY --from=build --chown=app:app /deploy ./
|
||||
ENV COLLECTOR_DATA_DIR=/data
|
||||
ENV COLLECTOR_HOST=0.0.0.0
|
||||
ENV COLLECTOR_PORT=8090
|
||||
RUN mkdir -p /data && chown app:app /data
|
||||
VOLUME ["/data"]
|
||||
USER app
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- "http://localhost:${COLLECTOR_PORT:-8090}/health" >/dev/null 2>&1 || exit 1
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@parking/collector",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Car Wash review collector: receives plate-blurred vehicle crops + the operator's category choice from booths over the private overlay, serves the reviewer's screen, exports labels for training. See wiki/concepts/vision-review-outbox.md.",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
|
||||
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/multipart": "^9.2.1",
|
||||
"@parking/shared": "workspace:*",
|
||||
"better-sqlite3": "12.10.1",
|
||||
"fastify": "5.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/node": "25.9.3",
|
||||
"tsx": "4.22.4",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { buildCollector, type CollectorApp } from "./app.js";
|
||||
import { parseBoothTokens } from "./config.js";
|
||||
|
||||
// The collector: one ingest surface (bearer per booth, idempotent), one review surface
|
||||
// (Basic), one export. Exercised over app.inject with a hand-built multipart body.
|
||||
|
||||
let app: CollectorApp;
|
||||
let dir: string;
|
||||
const TOKENS = new Map([["booth-7", "0123456789abcdef0123456789abcdef"], ["booth-9", "fedcba9876543210fedcba9876543210"]]);
|
||||
const REVIEWER = { user: "julian", pass: "review-pass-123" };
|
||||
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
|
||||
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER, trainerUrl: null }, { dbFile: ":memory:" });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A minimal JPEG-looking blob (SOI marker + padding) — the collector checks the magic only. */
|
||||
const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(200, 1)]);
|
||||
|
||||
function meta(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
v: 1, booth: "booth-7", item: "item-1", order: "o-1", at: "2026-09-06T10:00:00.000Z", operator: "ab12cd34ef56ab12",
|
||||
operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan", "hatchback"] }, service: "Standard",
|
||||
vision: { class: "suv", confidence: 0.91, categoryId: "suv" }, downgraded: true,
|
||||
image: { width: 320, height: 200, plateBlurred: true },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function multipart(fields: Record<string, string>, file: Buffer | null): { body: Buffer; type: string } {
|
||||
const b = "----collector-test";
|
||||
const parts: Buffer[] = [];
|
||||
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||||
if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="image"; filename="x.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`), file, Buffer.from("\r\n"));
|
||||
parts.push(Buffer.from(`--${b}--\r\n`));
|
||||
return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };
|
||||
}
|
||||
|
||||
async function ingest(m: Record<string, unknown>, token = TOKENS.get("booth-7")!, file: Buffer | null = JPEG, extra: Record<string, string> = {}) {
|
||||
const { body, type } = multipart({ meta: JSON.stringify(m) }, file);
|
||||
return app.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${token}`, "content-type": type, ...extra }, payload: body });
|
||||
}
|
||||
|
||||
describe("ingest", () => {
|
||||
it("stores the crop and the decision under the token's booth; retries are idempotent", async () => {
|
||||
const r = await ingest(meta());
|
||||
expect(r.statusCode).toBe(201);
|
||||
const row = app.collectorDb.get("item-1")!;
|
||||
expect(row).toMatchObject({ booth: "booth-7", operatorCategoryName: "Vetura", visionClass: "suv", downgraded: 1, plateBlurred: 1, imagePath: "crops/booth-7/item-1.jpg" });
|
||||
expect(JSON.parse(row.operatorClasses)).toEqual(["car", "sedan", "hatchback"]);
|
||||
const again = await ingest(meta());
|
||||
expect(again.statusCode).toBe(200);
|
||||
expect(again.json()).toEqual({ ok: true, duplicate: true });
|
||||
expect((await app.inject({ method: "GET", url: "/health" })).json()).toMatchObject({ ok: true, booths: 1, pending: 1 });
|
||||
});
|
||||
|
||||
it("refuses a bad token, a booth mismatch, a non-JPEG, and malformed meta", async () => {
|
||||
expect((await ingest(meta(), "nope-nope-nope-nope-nope")).statusCode).toBe(401);
|
||||
expect((await ingest(meta({ booth: "booth-9" }))).statusCode).toBe(422); // token is booth-7's
|
||||
expect((await ingest(meta(), TOKENS.get("booth-7")!, JPEG, { "x-booth-id": "booth-9" })).statusCode).toBe(403);
|
||||
expect((await ingest(meta(), TOKENS.get("booth-7")!, Buffer.alloc(300, 7))).statusCode).toBe(415);
|
||||
expect((await ingest(meta(), TOKENS.get("booth-7")!, null)).statusCode).toBe(400);
|
||||
expect((await ingest(meta({ vision: { class: "spaceship", confidence: 0.5, categoryId: null } }))).statusCode).toBe(422);
|
||||
expect((await ingest(meta({ item: "../../etc/passwd" }))).statusCode).toBe(422);
|
||||
expect((await ingest(meta({ v: 2 }))).statusCode).toBe(422);
|
||||
expect(app.collectorDb.stats().booths).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("review + export", () => {
|
||||
it("the reviewer lists pending items, sees the crop, labels it; stats compare the label with the operator's category; the export lists usable labels only", async () => {
|
||||
await ingest(meta());
|
||||
await ingest(meta({ item: "item-2", operator: "ab12cd34ef56ab12", vision: { class: "car", confidence: 0.8, categoryId: "car" }, downgraded: false }));
|
||||
await ingest(meta({ item: "item-3", booth: "booth-9", operator: "9999999999999999" }), TOKENS.get("booth-9")!);
|
||||
|
||||
// No login → 401 with a challenge; nothing without a configured reviewer is tested in config.
|
||||
const anon = await app.inject({ method: "GET", url: "/api/items" });
|
||||
expect(anon.statusCode).toBe(401);
|
||||
expect(anon.headers["www-authenticate"]).toContain("Basic");
|
||||
expect((await app.inject({ method: "GET", url: "/review", headers: { authorization: basic } })).headers["content-type"]).toContain("text/html");
|
||||
|
||||
const list = (await app.inject({ method: "GET", url: "/api/items?status=pending", headers: { authorization: basic } })).json();
|
||||
expect(list.items.map((i: { id: string }) => i.id)).toEqual(["item-1", "item-2", "item-3"]);
|
||||
expect(list.items[0].imagePath).toBeUndefined();
|
||||
const img = await app.inject({ method: "GET", url: "/api/items/item-1/image", headers: { authorization: basic } });
|
||||
expect(img.statusCode).toBe(200);
|
||||
expect(img.headers["content-type"]).toBe("image/jpeg");
|
||||
expect(img.rawPayload.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
||||
|
||||
// item-1: operator said Vetura (car/sedan/hatchback), reviewer says suv → disagree.
|
||||
// item-2: reviewer says sedan → inside Vetura → agree. item-3: unusable.
|
||||
const post = (id: string, label: string) =>
|
||||
app.inject({ method: "POST", url: `/api/items/${id}/review`, headers: { authorization: basic, "content-type": "application/json" }, payload: { label } });
|
||||
expect((await post("item-1", "suv")).json()).toMatchObject({ reviewLabel: "suv", reviewer: "julian" });
|
||||
expect((await post("item-2", "sedan")).statusCode).toBe(200);
|
||||
expect((await post("item-3", "unusable")).statusCode).toBe(200);
|
||||
expect((await post("item-3", "spaceship")).statusCode).toBe(400);
|
||||
expect((await post("nope", "suv")).statusCode).toBe(404);
|
||||
|
||||
const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
|
||||
expect(stats.booths).toEqual([
|
||||
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2, entries: 0 },
|
||||
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1, entries: 0 },
|
||||
]);
|
||||
expect(stats.operators).toEqual([
|
||||
{ booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 },
|
||||
{ booth: "booth-9", operatorRef: "9999999999999999", reviewed: 1, agree: 0, disagree: 0, unusable: 1 },
|
||||
]);
|
||||
|
||||
const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } });
|
||||
expect(csv.statusCode).toBe(200);
|
||||
const lines = csv.body.trim().split("\n");
|
||||
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[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.
|
||||
await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } }));
|
||||
await post("item-4", "car");
|
||||
const csv2 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
|
||||
expect(csv2).toContain(`"'=HYPERLINK(""http://evil"")"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
it("parses booth:token pairs and refuses short tokens", () => {
|
||||
expect([...parseBoothTokens("a:0123456789abcdef, b:fedcba9876543210\nc:0000000000000000").keys()]).toEqual(["a", "b", "c"]);
|
||||
expect(() => parseBoothTokens("a:short")).toThrow(/too short/);
|
||||
expect(() => parseBoothTokens("nocolon")).toThrow(/bad pair/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { isVehicleClass } from "@parking/shared";
|
||||
import type { CollectorConfig } from "./config.js";
|
||||
import { CollectorDb, type ItemRow, type ReviewVerdict } from "./db.js";
|
||||
import { reviewPage } from "./review-page.js";
|
||||
|
||||
// The collector — the far end of the booth's review outbox
|
||||
// (wiki/concepts/vision-review-outbox.md). Three surfaces and nothing else:
|
||||
// POST /ingest one package from one booth (bearer token per booth; idempotent)
|
||||
// /review + /api/* the reviewer's screen (HTTP Basic, one login)
|
||||
// GET /export/labels.csv the training set: reviewed, usable rows (crops sit beside it on
|
||||
// the volume, so the trainer on this host reads them directly)
|
||||
// /api/training/* the Training section: a thin proxy to the trainer's job API on
|
||||
// the compose network (never published), behind the reviewer login
|
||||
// It deliberately has no fleet features and no path back into a booth.
|
||||
|
||||
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */
|
||||
interface IngestMeta {
|
||||
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;
|
||||
item: string;
|
||||
order?: string;
|
||||
at: string;
|
||||
operator?: string;
|
||||
operatorCategory?: { id: string; name: string; classes?: string[] };
|
||||
service?: string;
|
||||
vision: { class: string; confidence: number; categoryId?: string | null };
|
||||
downgraded?: boolean;
|
||||
image: { width: number; height: number; plateBlurred: boolean };
|
||||
}
|
||||
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function str(v: unknown, max = 200): string | null {
|
||||
return typeof v === "string" && v.length > 0 && v.length <= max ? v : null;
|
||||
}
|
||||
|
||||
/** Validate the meta part; returns a message on the first problem. */
|
||||
function checkMeta(m: unknown, booth: string): { ok: true; meta: IngestMeta } | { ok: false; why: string } {
|
||||
if (!m || typeof m !== "object") return { ok: false, why: "meta must be an object" };
|
||||
const x = m as Record<string, unknown>;
|
||||
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 (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" };
|
||||
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" };
|
||||
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.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 (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
|
||||
}
|
||||
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" };
|
||||
return { ok: true, meta: x as unknown as IngestMeta };
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
export interface CollectorApp extends FastifyInstance {
|
||||
collectorDb: CollectorDb;
|
||||
}
|
||||
|
||||
export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: string } = {}): Promise<CollectorApp> {
|
||||
await mkdir(path.join(cfg.dataDir, "crops"), { recursive: true });
|
||||
const db = new CollectorDb(opts.dbFile ?? path.join(cfg.dataDir, "collector.sqlite"));
|
||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, bodyLimit: 64 * 1024 }) as unknown as CollectorApp;
|
||||
app.collectorDb = db;
|
||||
await app.register(multipart, { limits: { fileSize: MAX_IMAGE_BYTES, files: 1, fields: 4, parts: 6 } });
|
||||
app.addHook("onClose", async () => db.close());
|
||||
|
||||
/** Which booth this bearer token belongs to, or null. Constant-time per candidate. */
|
||||
function boothForToken(req: FastifyRequest): string | null {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (!h.startsWith("Bearer ")) return null;
|
||||
const token = h.slice(7).trim();
|
||||
let found: string | null = null;
|
||||
for (const [booth, t] of cfg.boothTokens) if (safeEqual(token, t)) found = booth;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** HTTP Basic for the reviewer. */
|
||||
async function requireReviewer(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
if (!cfg.reviewer) return reply.code(503).send({ error: "reviewer login not configured" });
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (h.startsWith("Basic ")) {
|
||||
const [user, ...rest] = Buffer.from(h.slice(6), "base64").toString("utf8").split(":");
|
||||
const pass = rest.join(":");
|
||||
if (user && safeEqual(user, cfg.reviewer.user) && safeEqual(pass, cfg.reviewer.pass)) return;
|
||||
}
|
||||
return reply.code(401).header("www-authenticate", 'Basic realm="wash review", charset="UTF-8"').send({ error: "unauthorized" });
|
||||
}
|
||||
|
||||
app.get("/health", async () => {
|
||||
const s = db.stats();
|
||||
return { ok: true, booths: s.booths.length, pending: s.booths.reduce((n, b) => n + b.pending, 0) };
|
||||
});
|
||||
|
||||
// --- Ingest (booths) -----------------------------------------------------------------
|
||||
app.post("/ingest", async (req, reply) => {
|
||||
const booth = boothForToken(req);
|
||||
if (!booth) return reply.code(401).send({ error: "unauthorized" });
|
||||
const claimed = req.headers["x-booth-id"];
|
||||
if (typeof claimed === "string" && claimed !== booth) return reply.code(403).send({ error: "booth id does not match the token" });
|
||||
if (!req.isMultipart()) return reply.code(415).send({ error: "multipart/form-data expected" });
|
||||
|
||||
let metaRaw: string | null = null;
|
||||
let image: Buffer | null = null;
|
||||
try {
|
||||
for await (const part of req.parts()) {
|
||||
if (part.type === "file" && part.fieldname === "image") {
|
||||
image = await part.toBuffer();
|
||||
} else if (part.type === "field" && part.fieldname === "meta") {
|
||||
metaRaw = String(part.value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
return reply.code(code === "FST_REQ_FILE_TOO_LARGE" ? 413 : 400).send({ error: (err as Error).message });
|
||||
}
|
||||
if (!metaRaw) return reply.code(400).send({ error: "meta part missing" });
|
||||
if (!image || image.length < 100) return reply.code(400).send({ error: "image part missing" });
|
||||
if (!(image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff)) return reply.code(415).send({ error: "image must be a JPEG" });
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(metaRaw);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: "meta is not JSON" });
|
||||
}
|
||||
const checked = checkMeta(parsed, booth);
|
||||
if (!checked.ok) return reply.code(422).send({ error: checked.why });
|
||||
const meta = checked.meta;
|
||||
|
||||
// Idempotent on the item id: a booth retrying after a lost 2xx must not duplicate.
|
||||
if (db.get(meta.item)) return reply.code(200).send({ ok: true, duplicate: true });
|
||||
|
||||
const rel = path.posix.join("crops", booth, `${meta.item}.jpg`);
|
||||
await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true });
|
||||
await writeFile(path.join(cfg.dataDir, rel), image);
|
||||
const kind = meta.kind ?? "wash";
|
||||
db.insert({
|
||||
id: meta.item,
|
||||
booth,
|
||||
kind,
|
||||
orderRef: meta.order ?? "",
|
||||
at: meta.at,
|
||||
operatorRef: meta.operator ?? "",
|
||||
operatorCategoryId: meta.operatorCategory?.id ?? "",
|
||||
operatorCategoryName: meta.operatorCategory?.name ?? "",
|
||||
operatorClasses: JSON.stringify(meta.operatorCategory?.classes ?? []),
|
||||
service: meta.service ?? "",
|
||||
visionClass: meta.vision.class,
|
||||
visionConfidence: meta.vision.confidence,
|
||||
visionCategoryId: meta.vision.categoryId ?? null,
|
||||
downgraded: meta.downgraded ? 1 : 0,
|
||||
imageWidth: meta.image.width,
|
||||
imageHeight: meta.image.height,
|
||||
plateBlurred: meta.image.plateBlurred ? 1 : 0,
|
||||
imagePath: rel,
|
||||
receivedAt: new Date().toISOString(),
|
||||
});
|
||||
req.log.info(`ingest: ${booth} ${kind} ${meta.item} (${meta.vision.class}${kind === "wash" ? ` → ${meta.operatorCategory!.name}` : ""})`);
|
||||
return reply.code(201).send({ ok: true });
|
||||
});
|
||||
|
||||
// --- Review (the trusted person) -----------------------------------------------------
|
||||
const page = reviewPage();
|
||||
app.get("/", { preHandler: requireReviewer }, async (_req, reply) => reply.redirect("/review"));
|
||||
app.get("/review", { preHandler: requireReviewer }, async (_req, reply) => reply.type("text/html; charset=utf-8").send(page));
|
||||
|
||||
app.get<{ Querystring: { status?: string; limit?: string; booth?: string } }>(
|
||||
"/api/items",
|
||||
{ preHandler: requireReviewer },
|
||||
async (req) => {
|
||||
const status = req.query.status === "reviewed" ? "reviewed" : "pending";
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 25, 1), 200);
|
||||
return { items: db.list(status, limit, req.query.booth || undefined).map(publicItem) };
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/items/:id/image", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
const row = db.get(req.params.id);
|
||||
if (!row) return reply.code(404).send({ error: "not found" });
|
||||
return reply.type("image/jpeg").header("cache-control", "private, max-age=3600").send(createReadStream(path.join(cfg.dataDir, row.imagePath)));
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { label?: unknown } }>("/api/items/:id/review", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
const label = req.body?.label;
|
||||
if (label !== "unusable" && !isVehicleClass(label)) return reply.code(400).send({ error: "label must be a vehicle class or 'unusable'" });
|
||||
if (!db.get(req.params.id)) return reply.code(404).send({ error: "not found" });
|
||||
const row = db.review(req.params.id, label as ReviewVerdict, cfg.reviewer!.user);
|
||||
return publicItem(row!);
|
||||
});
|
||||
|
||||
app.get("/api/stats", { preHandler: requireReviewer }, async () => db.stats());
|
||||
|
||||
// --- Export (the training set) --------------------------------------------------------
|
||||
app.get("/export/labels.csv", { preHandler: requireReviewer }, async (_req, reply) => {
|
||||
const rows = db.labelled();
|
||||
// Quote every cell; a cell starting like a spreadsheet formula (=, +, -, @, tab, CR)
|
||||
// gets a leading apostrophe — the category/service names are booth-supplied text and
|
||||
// the reviewer will open this in a spreadsheet (CSV formula injection).
|
||||
const q = (s: string | number | null) => {
|
||||
let v = String(s ?? "");
|
||||
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
|
||||
return `"${v.replace(/"/g, '""')}"`;
|
||||
};
|
||||
const head = "item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
|
||||
const lines = rows.map((r) =>
|
||||
[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");
|
||||
});
|
||||
|
||||
// --- Training (proxy to the trainer's job API) ----------------------------------------
|
||||
// The trainer is a sibling container reading the same volume; it is reachable only on the
|
||||
// compose network, so the reviewer's login here is the only gate. The proxy forwards a
|
||||
// fixed set of paths and passes the trainer's status codes through (409 = a job runs).
|
||||
const trainer = cfg.trainerUrl;
|
||||
async function viaTrainer(reply: FastifyReply, tpath: string, init?: RequestInit): Promise<unknown> {
|
||||
if (!trainer) return reply.code(503).send({ error: "trainer not configured" });
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(trainer + tpath, { ...init, signal: AbortSignal.timeout(15_000) });
|
||||
} catch (err) {
|
||||
return reply.code(502).send({ error: `trainer unreachable: ${(err as Error).message}` });
|
||||
}
|
||||
const ctype = r.headers.get("content-type") ?? "application/json";
|
||||
return reply.code(r.status).type(ctype).send(Buffer.from(await r.arrayBuffer()));
|
||||
}
|
||||
app.get("/api/training/status", { preHandler: requireReviewer }, async (_req, reply) => {
|
||||
if (!trainer) return { configured: false };
|
||||
try {
|
||||
const get = async (p: string) => {
|
||||
const r = await fetch(trainer + p, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!r.ok) throw new Error(`${p} → HTTP ${r.status}`);
|
||||
return r.json() as Promise<Record<string, unknown>>;
|
||||
};
|
||||
const [health, readiness, versions, jobs] = await Promise.all([get("/health"), get("/readiness"), get("/versions"), get("/jobs")]);
|
||||
return { configured: true, reachable: true, health, readiness, versions: versions.versions, jobs: jobs.jobs, current: jobs.current };
|
||||
} catch (err) {
|
||||
return reply.code(200).send({ configured: true, reachable: false, error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
app.post<{ Body: Record<string, unknown> }>("/api/training/jobs", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
const b = req.body && typeof req.body === "object" ? req.body : {};
|
||||
const kind = b.kind;
|
||||
if (kind !== "train" && kind !== "evaluate" && kind !== "publish") return reply.code(400).send({ error: "kind must be train, evaluate or publish" });
|
||||
// Only the knobs the UI offers cross over; the trainer validates their values.
|
||||
const allowed = ["kind", "mode", "backbone", "minAccuracy", "minPerClass", "epochs", "version"];
|
||||
const body: Record<string, unknown> = {};
|
||||
for (const k of allowed) if (b[k] !== undefined) body[k] = b[k];
|
||||
return viaTrainer(reply, "/jobs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
});
|
||||
app.get<{ Params: { id: string } }>("/api/training/jobs/:id", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
if (!ID_RE.test(req.params.id)) return reply.code(400).send({ error: "bad job id" });
|
||||
return viaTrainer(reply, `/jobs/${encodeURIComponent(req.params.id)}`);
|
||||
});
|
||||
app.get<{ Params: { v: string } }>("/api/training/versions/:v/report", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
if (!ID_RE.test(req.params.v)) return reply.code(400).send({ error: "bad version" });
|
||||
return viaTrainer(reply, `/versions/${encodeURIComponent(req.params.v)}/report`);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** The row as the review screen sees it (no server paths). */
|
||||
function publicItem(r: ItemRow): Omit<ItemRow, "imagePath"> {
|
||||
const { imagePath: _p, ...rest } = r;
|
||||
return rest;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface CollectorConfig {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly dataDir: string;
|
||||
/** boothId → bearer token. */
|
||||
readonly boothTokens: ReadonlyMap<string, string>;
|
||||
/** The single reviewer login; null = review screen and export refuse (503). */
|
||||
readonly reviewer: { readonly user: string; readonly pass: string } | null;
|
||||
/** The trainer's job API on the compose network (http://trainer:8091); null = the
|
||||
* Training section is hidden and /api/training/* answers 503. */
|
||||
readonly trainerUrl: string | null;
|
||||
}
|
||||
|
||||
/** "booth-7:abc,booth-9:def" (commas, whitespace or newlines between pairs). */
|
||||
export function parseBoothTokens(raw: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(/[,\s]+/)) {
|
||||
if (!pair) continue;
|
||||
const i = pair.indexOf(":");
|
||||
if (i <= 0) throw new Error(`COLLECTOR_BOOTH_TOKENS: bad pair "${pair}" (want boothId:token)`);
|
||||
const booth = pair.slice(0, i).trim();
|
||||
const token = pair.slice(i + 1).trim();
|
||||
if (!booth || token.length < 16) throw new Error(`COLLECTOR_BOOTH_TOKENS: token for "${booth}" too short (>=16 chars)`);
|
||||
out.set(booth, token);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function configFromEnv(env: NodeJS.ProcessEnv = process.env): CollectorConfig {
|
||||
const user = (env.COLLECTOR_REVIEWER_USER ?? "").trim();
|
||||
const pass = env.COLLECTOR_REVIEWER_PASS ?? "";
|
||||
return {
|
||||
host: env.COLLECTOR_HOST ?? "0.0.0.0",
|
||||
port: Number(env.COLLECTOR_PORT ?? 8090),
|
||||
dataDir: env.COLLECTOR_DATA_DIR ?? "/data",
|
||||
boothTokens: parseBoothTokens(env.COLLECTOR_BOOTH_TOKENS ?? ""),
|
||||
reviewer: user && pass.length >= 8 ? { user, pass } : null,
|
||||
trainerUrl: (env.COLLECTOR_TRAINER_URL ?? "").trim().replace(/\/+$/, "") || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import Database from "better-sqlite3";
|
||||
import type { VehicleClass } from "@parking/shared";
|
||||
|
||||
// One table. Each row is one booth decision: what the camera saw, what the operator
|
||||
// chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself
|
||||
// lives on disk beside the DB (crops/<booth>/<item>.jpg) so the trainer on the same host
|
||||
// reads it straight off the volume.
|
||||
|
||||
export interface ItemRow {
|
||||
id: 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;
|
||||
at: string;
|
||||
operatorRef: string;
|
||||
operatorCategoryId: string;
|
||||
operatorCategoryName: string;
|
||||
/** The vision classes the operator's category covers at that site (its mapping) — what
|
||||
* lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */
|
||||
operatorClasses: string;
|
||||
service: string;
|
||||
visionClass: string;
|
||||
visionConfidence: number;
|
||||
visionCategoryId: string | null;
|
||||
downgraded: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
plateBlurred: number;
|
||||
imagePath: string;
|
||||
receivedAt: string;
|
||||
reviewLabel: string | null; // a VehicleClass, or "unusable"
|
||||
reviewedAt: string | null;
|
||||
reviewer: string | null;
|
||||
}
|
||||
|
||||
export type ReviewVerdict = VehicleClass | "unusable";
|
||||
|
||||
export class CollectorDb {
|
||||
readonly #db: Database.Database;
|
||||
|
||||
constructor(file: string) {
|
||||
this.#db = new Database(file);
|
||||
this.#db.pragma("journal_mode = WAL");
|
||||
this.#db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY,
|
||||
booth TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'wash',
|
||||
order_ref TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
operator_ref TEXT NOT NULL DEFAULT '',
|
||||
operator_category_id TEXT NOT NULL DEFAULT '',
|
||||
operator_category_name TEXT NOT NULL DEFAULT '',
|
||||
operator_classes TEXT NOT NULL DEFAULT '[]',
|
||||
service TEXT NOT NULL,
|
||||
vision_class TEXT NOT NULL,
|
||||
vision_confidence REAL NOT NULL,
|
||||
vision_category_id TEXT,
|
||||
downgraded INTEGER NOT NULL DEFAULT 0,
|
||||
image_width INTEGER NOT NULL,
|
||||
image_height INTEGER NOT NULL,
|
||||
plate_blurred INTEGER NOT NULL,
|
||||
image_path TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
review_label TEXT,
|
||||
reviewed_at TEXT,
|
||||
reviewer TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at);
|
||||
CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at);
|
||||
`);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#db.close();
|
||||
}
|
||||
|
||||
static #map(r: Record<string, unknown>): ItemRow {
|
||||
return {
|
||||
id: r.id as string,
|
||||
booth: r.booth as string,
|
||||
kind: r.kind === "entry" ? "entry" : "wash",
|
||||
orderRef: r.order_ref as string,
|
||||
at: r.at as string,
|
||||
operatorRef: r.operator_ref as string,
|
||||
operatorCategoryId: r.operator_category_id as string,
|
||||
operatorCategoryName: r.operator_category_name as string,
|
||||
operatorClasses: r.operator_classes as string,
|
||||
service: r.service as string,
|
||||
visionClass: r.vision_class as string,
|
||||
visionConfidence: r.vision_confidence as number,
|
||||
visionCategoryId: (r.vision_category_id as string | null) ?? null,
|
||||
downgraded: r.downgraded as number,
|
||||
imageWidth: r.image_width as number,
|
||||
imageHeight: r.image_height as number,
|
||||
plateBlurred: r.plate_blurred as number,
|
||||
imagePath: r.image_path as string,
|
||||
receivedAt: r.received_at as string,
|
||||
reviewLabel: (r.review_label as string | null) ?? null,
|
||||
reviewedAt: (r.reviewed_at as string | null) ?? null,
|
||||
reviewer: (r.reviewer as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
get(id: string): ItemRow | null {
|
||||
const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
||||
return r ? CollectorDb.#map(r) : null;
|
||||
}
|
||||
|
||||
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
|
||||
this.#db
|
||||
.prepare(
|
||||
`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,
|
||||
image_width, image_height, plate_blurred, image_path, received_at)
|
||||
VALUES (@id, @booth, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
|
||||
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
|
||||
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
|
||||
)
|
||||
.run(row);
|
||||
}
|
||||
|
||||
list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] {
|
||||
const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"];
|
||||
const params: unknown[] = [];
|
||||
if (booth) {
|
||||
where.push("booth = ?");
|
||||
params.push(booth);
|
||||
}
|
||||
const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC";
|
||||
const rows = this.#db
|
||||
.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`)
|
||||
.all(...params, limit) as Record<string, unknown>[];
|
||||
return rows.map((r) => CollectorDb.#map(r));
|
||||
}
|
||||
|
||||
review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null {
|
||||
this.#db
|
||||
.prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?")
|
||||
.run(label, new Date().toISOString(), reviewer, id);
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the
|
||||
* reviewer's class fell inside the operator's chosen category (agree) or outside
|
||||
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */
|
||||
stats(): {
|
||||
booths: { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
|
||||
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
|
||||
} {
|
||||
const booths = this.#db
|
||||
.prepare(
|
||||
`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 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`,
|
||||
)
|
||||
.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
|
||||
.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 }[];
|
||||
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
|
||||
for (const r of reviewed) {
|
||||
const key = `${r.booth} ${r.operator_ref}`;
|
||||
let o = ops.get(key);
|
||||
if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 }));
|
||||
o.reviewed += 1;
|
||||
if (r.review_label === "unusable") o.unusable += 1;
|
||||
else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1;
|
||||
else o.disagree += 1;
|
||||
}
|
||||
return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) };
|
||||
}
|
||||
|
||||
/** Reviewed, usable rows — the training set. */
|
||||
labelled(): ItemRow[] {
|
||||
const rows = this.#db
|
||||
.prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at")
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => CollectorDb.#map(r));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { buildCollector } from "./app.js";
|
||||
import { configFromEnv } from "./config.js";
|
||||
|
||||
const cfg = configFromEnv();
|
||||
const app = await buildCollector(cfg);
|
||||
if (cfg.boothTokens.size === 0) app.log.warn("COLLECTOR_BOOTH_TOKENS is empty — no booth can ingest");
|
||||
if (!cfg.reviewer) app.log.warn("COLLECTOR_REVIEWER_USER/PASS not set — the review screen and export refuse");
|
||||
app.log.info(`collector: ${cfg.boothTokens.size} booth token(s), data in ${cfg.dataDir}, trainer ${cfg.trainerUrl ?? "not configured"}`);
|
||||
await app.listen({ host: cfg.host, port: cfg.port });
|
||||
|
||||
const stop = async () => {
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", () => void stop());
|
||||
process.on("SIGINT", () => void stop());
|
||||
@@ -0,0 +1,234 @@
|
||||
import { VEHICLE_CLASSES } from "@parking/shared";
|
||||
|
||||
// The reviewer's screen: one pending crop at a time, the operator's pick and the camera's
|
||||
// pick beside it, one button per vocabulary class + "unusable". Served by the collector
|
||||
// itself (no build step, no framework) — this is deliberately the whole UI.
|
||||
|
||||
export function reviewPage(): string {
|
||||
const classes = JSON.stringify(VEHICLE_CLASSES);
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wash review</title>
|
||||
<style>
|
||||
:root { --bg:#111; --panel:#1b1b1b; --text:#e8e8e8; --muted:#9a9a9a; --amber:#e0a030; --green:#4caf50; --red:#e05050; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
|
||||
header { display:flex; justify-content:space-between; align-items:center; padding:.6rem 1rem; border-bottom:1px solid #333; }
|
||||
header b { letter-spacing:.08em; text-transform:uppercase; color:var(--amber); font-size:.75rem; }
|
||||
main { max-width:960px; margin:0 auto; padding:1rem; display:grid; gap:1rem; }
|
||||
.card { background:var(--panel); border:1px solid #333; border-radius:6px; padding:1rem; }
|
||||
img { max-width:100%; max-height:60vh; display:block; margin:0 auto; background:#000; border-radius:4px; }
|
||||
dl { display:grid; grid-template-columns:max-content 1fr; gap:.2rem .8rem; margin:0; font-variant-numeric:tabular-nums; }
|
||||
dt { color:var(--muted); }
|
||||
.buttons { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||||
button { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.5rem .8rem; font:inherit; cursor:pointer; }
|
||||
button:hover { border-color:var(--amber); }
|
||||
button.mono { font-family:ui-monospace, monospace; }
|
||||
button.hint { border-color:var(--amber); }
|
||||
button.unusable { color:var(--red); }
|
||||
button.skip { color:var(--muted); }
|
||||
.muted { color:var(--muted); }
|
||||
.warn { color:var(--amber); }
|
||||
table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; }
|
||||
td, th { text-align:left; padding:.2rem .5rem; border-bottom:1px solid #2a2a2a; }
|
||||
th { color:var(--muted); font-weight:normal; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; }
|
||||
kbd { background:#2a2a2a; border:1px solid #444; border-radius:3px; padding:0 .3rem; font-size:.75rem; }
|
||||
h2 { font-size:.8rem; letter-spacing:.08em; text-transform:uppercase; color:var(--amber); margin:0 0 .6rem; }
|
||||
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; }
|
||||
select, input { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.4rem .5rem; font:inherit; }
|
||||
input[type=number] { width:5rem; }
|
||||
label { color:var(--muted); font-size:.8rem; }
|
||||
pre { background:#0d0d0d; border:1px solid #2a2a2a; border-radius:4px; padding:.6rem; max-height:22rem; overflow:auto; font-size:.75rem; white-space:pre-wrap; margin:.6rem 0 0; }
|
||||
.ok { color:var(--green); }
|
||||
.bad { color:var(--red); }
|
||||
button:disabled { opacity:.45; cursor:not-allowed; }
|
||||
button.small { padding:.25rem .5rem; font-size:.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><b>Wash review</b><span id="counts" class="muted"></span></header>
|
||||
<main>
|
||||
<section class="card" id="item">
|
||||
<p class="muted">Loading…</p>
|
||||
</section>
|
||||
<section class="card">
|
||||
<table id="stats"><thead><tr><th>booth</th><th>operator</th><th>reviewed</th><th>agree</th><th>disagree</th><th>unusable</th></tr></thead><tbody></tbody></table>
|
||||
</section>
|
||||
<section class="card" id="training" hidden>
|
||||
<h2>Training</h2>
|
||||
<div id="tr-body"></div>
|
||||
</section>
|
||||
<p class="muted">Keys: <kbd>1</kbd>–<kbd>9</kbd>, <kbd>0</kbd> pick a class in order · <kbd>u</kbd> unusable · <kbd>s</kbd> skip. Skipped items come back after a reload. Your verdict is the training label; the operator's pick is only compared against it.</p>
|
||||
</main>
|
||||
<script>
|
||||
const CLASSES = ${classes};
|
||||
const skipped = new Set();
|
||||
let current = null;
|
||||
|
||||
async function api(path, init) {
|
||||
const r = await fetch(path, init);
|
||||
if (!r.ok) throw new Error(path + ' → HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
|
||||
async function loadStats() {
|
||||
const s = await api('/api/stats');
|
||||
const pending = s.booths.reduce((n, b) => n + b.pending, 0);
|
||||
const reviewed = s.booths.reduce((n, b) => n + b.reviewed, 0);
|
||||
document.getElementById('counts').textContent = pending + ' waiting · ' + reviewed + ' reviewed';
|
||||
const tb = document.querySelector('#stats tbody');
|
||||
tb.innerHTML = s.operators.map(o => '<tr><td>' + esc(o.booth) + '</td><td class="mono">' + esc(o.operatorRef) + '</td><td>' + o.reviewed + '</td><td>' + o.agree + '</td><td' + (o.disagree ? ' class="warn"' : '') + '>' + o.disagree + '</td><td>' + o.unusable + '</td></tr>').join('') || '<tr><td colspan="6" class="muted">nothing reviewed yet</td></tr>';
|
||||
}
|
||||
|
||||
async function next() {
|
||||
const { items } = await api('/api/items?status=pending&limit=25');
|
||||
current = items.find(i => !skipped.has(i.id)) || null;
|
||||
const el = document.getElementById('item');
|
||||
if (!current) { el.innerHTML = '<p class="muted">Nothing waiting for review.</p>'; return; }
|
||||
const it = current;
|
||||
const opClasses = JSON.parse(it.operatorClasses || '[]');
|
||||
el.innerHTML =
|
||||
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
|
||||
'<dl style="margin-top:.8rem">' +
|
||||
(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>' +
|
||||
(it.kind === 'entry' ? '<dt>booth</dt><dd class="mono">' + esc(it.booth) + '</dd>' :
|
||||
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
|
||||
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>') +
|
||||
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
|
||||
'</dl>' +
|
||||
'<div class="buttons" style="margin-top:.8rem">' +
|
||||
CLASSES.map((c, i) => '<button class="mono' + (c === it.visionClass ? ' hint' : '') + '" data-label="' + c + '" title="key ' + ((i + 1) % 10) + '">' + c + '</button>').join('') +
|
||||
'<button class="unusable" data-label="unusable">unusable</button>' +
|
||||
'<button class="skip" data-skip="1">skip</button>' +
|
||||
'</div>';
|
||||
el.querySelectorAll('button[data-label]').forEach(b => b.addEventListener('click', () => verdict(b.dataset.label)));
|
||||
el.querySelector('button[data-skip]').addEventListener('click', skip);
|
||||
}
|
||||
|
||||
async function verdict(label) {
|
||||
if (!current) return;
|
||||
await api('/api/items/' + encodeURIComponent(current.id) + '/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) });
|
||||
await Promise.all([next(), loadStats()]);
|
||||
}
|
||||
function skip() { if (current) { skipped.add(current.id); next(); } }
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target.tagName === 'INPUT') return;
|
||||
if (e.key === 'u') verdict('unusable');
|
||||
else if (e.key === 's') skip();
|
||||
else if (/^[0-9]$/.test(e.key)) { const i = e.key === '0' ? 9 : Number(e.key) - 1; if (CLASSES[i]) verdict(CLASSES[i]); }
|
||||
});
|
||||
|
||||
next().catch(e => { document.getElementById('item').innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; });
|
||||
loadStats().catch(() => {});
|
||||
|
||||
// ---- Training: the trainer's job API, proxied by the collector -------------------------
|
||||
// Readiness (labels per class vs the minimum), one job at a time with a live log, the
|
||||
// versions a run produced (written or refused) with Report / Evaluate / Publish. Pinning a
|
||||
// published version into the vision image stays a git commit — that is the deploy control.
|
||||
let trPoll = null;
|
||||
let trShownReport = null;
|
||||
const trDefaults = { mode: 'features', backbone: 'resnet18', minAccuracy: 0.85 };
|
||||
|
||||
function pct(x) { return x == null ? '—' : Math.round(x * 100) + ' %'; }
|
||||
|
||||
async function training() {
|
||||
const box = document.getElementById('training');
|
||||
const el = document.getElementById('tr-body');
|
||||
let s;
|
||||
try { s = await api('/api/training/status'); } catch (e) { box.hidden = false; el.innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; return; }
|
||||
if (!s.configured) { box.hidden = true; return; }
|
||||
box.hidden = false;
|
||||
if (!s.reachable) { el.innerHTML = '<p class="warn">trainer not reachable: ' + esc(s.error || '') + '</p>'; schedule(true); return; }
|
||||
const r = s.readiness, run = r.run || {}, minPer = run.minPerClass || 20;
|
||||
const byClass = (r.labelled && r.labelled.byClass) || {};
|
||||
const classes = Object.keys(byClass);
|
||||
const cur = s.current;
|
||||
const readyLine = r.ready
|
||||
? '<span class="ok">enough labels to train</span> — classes this run: ' + esc((run.classes || []).join(', '))
|
||||
: '<span class="warn">not enough labels yet</span> — a class needs ' + minPer + ' reviewed crops; two classes must clear it';
|
||||
let html = '<p>' + readyLine + ' <span class="muted">(' + (r.labelled ? r.labelled.total : 0) + ' labelled, ' + (r.missingCrops || 0) + ' missing crop files)</span></p>';
|
||||
html += '<table><thead><tr><th>class</th><th>reviewed</th><th>train</th><th>val</th><th></th></tr></thead><tbody>' +
|
||||
(classes.map(c => '<tr><td class="mono">' + esc(c) + '</td><td>' + byClass[c] + '</td><td>' + ((run.train || {})[c] ?? '—') + '</td><td>' + ((run.val || {})[c] ?? '—') + '</td><td class="muted">' + (byClass[c] < minPer ? 'below ' + minPer + ' — dropped' : '') + '</td></tr>').join('') || '<tr><td colspan="5" class="muted">no labels yet — review crops above</td></tr>') +
|
||||
'</tbody></table>';
|
||||
const d = Object.assign({}, trDefaults, r.defaults || {});
|
||||
html += '<div class="row" style="margin-top:.8rem">' +
|
||||
'<label>mode <select id="tr-mode">' + (r.modes || ['features', 'finetune']).map(m => '<option' + (m === d.mode ? ' selected' : '') + '>' + m + '</option>').join('') + '</select></label>' +
|
||||
'<label>backbone <select id="tr-backbone">' + (r.backbones || ['resnet18']).map(b => '<option' + (b === d.backbone ? ' selected' : '') + '>' + b + '</option>').join('') + '</select></label>' +
|
||||
'<label>floor <input id="tr-floor" type="number" min="0" max="1" step="0.01" value="' + d.minAccuracy + '"></label>' +
|
||||
'<button id="tr-train"' + (r.ready && !cur ? '' : ' disabled') + '>Train</button>' +
|
||||
(cur ? '<span class="warn">running: ' + esc(cur.kind) + ' ' + esc(cur.id) + '</span>' : '') +
|
||||
'</div>';
|
||||
const last = cur || (s.jobs && s.jobs[0]);
|
||||
if (last) {
|
||||
const cls = last.status === 'done' ? 'ok' : last.status === 'running' ? 'warn' : 'bad';
|
||||
html += '<p style="margin:.8rem 0 0"><span class="' + cls + '">' + esc(last.status) + '</span> <span class="mono">' + esc(last.kind) + ' ' + esc(last.id) + '</span> <span class="muted">' + esc(last.startedAt || '') + (last.exitCode != null ? ' · exit ' + last.exitCode : '') + '</span> <button class="small" data-job="' + esc(last.id) + '">log</button></p>' +
|
||||
'<pre id="tr-log" hidden></pre>';
|
||||
}
|
||||
const vs = s.versions || [];
|
||||
html += '<h2 style="margin-top:1rem">Versions</h2>';
|
||||
html += vs.length
|
||||
? '<table><thead><tr><th>version</th><th>model</th><th>accuracy</th><th>classes</th><th>mode</th><th></th></tr></thead><tbody>' +
|
||||
vs.map(v => '<tr><td class="mono">' + esc(v.version) + '</td><td>' + (v.written ? '<span class="ok">written</span>' : '<span class="bad">refused</span>') + '</td><td>' + pct(v.accuracy) + (v.floor != null ? ' <span class="muted">/ floor ' + pct(v.floor) + '</span>' : '') + '</td><td class="muted">' + esc((v.classes || []).join(', ')) + '</td><td class="muted">' + esc(v.mode || '') + '</td><td>' +
|
||||
'<button class="small" data-report="' + esc(v.version) + '">report</button> ' +
|
||||
(v.written ? '<button class="small" data-eval="' + esc(v.version) + '"' + (cur ? ' disabled' : '') + '>evaluate</button> <button class="small" data-publish="' + esc(v.version) + '"' + (cur ? ' disabled' : '') + '>publish</button>' : '') +
|
||||
'</td></tr>').join('') + '</tbody></table>'
|
||||
: '<p class="muted">no runs yet</p>';
|
||||
html += '<pre id="tr-report" hidden></pre>';
|
||||
html += '<p class="muted" style="margin:.8rem 0 0">A written model is only a file here. To put it on a booth: publish, then pin the version in <span class="mono">apps/vision/models/bodytype.version</span>, commit, and bump the TAG of the booth.</p>';
|
||||
el.innerHTML = html;
|
||||
|
||||
const trainBtn = document.getElementById('tr-train');
|
||||
if (trainBtn) trainBtn.addEventListener('click', () => startJob({ kind: 'train', mode: document.getElementById('tr-mode').value, backbone: document.getElementById('tr-backbone').value, minAccuracy: Number(document.getElementById('tr-floor').value) }));
|
||||
el.querySelectorAll('button[data-eval]').forEach(b => b.addEventListener('click', () => startJob({ kind: 'evaluate', version: b.dataset.eval })));
|
||||
el.querySelectorAll('button[data-publish]').forEach(b => b.addEventListener('click', () => { if (confirm('Publish ' + b.dataset.publish + ' to the package registry?')) startJob({ kind: 'publish', version: b.dataset.publish }); }));
|
||||
el.querySelectorAll('button[data-job]').forEach(b => b.addEventListener('click', () => showLog(b.dataset.job)));
|
||||
el.querySelectorAll('button[data-report]').forEach(b => b.addEventListener('click', () => showReport(b.dataset.report)));
|
||||
if (cur) showLog(cur.id).catch(() => {});
|
||||
if (trShownReport) showReport(trShownReport).catch(() => {});
|
||||
schedule(!!cur);
|
||||
}
|
||||
|
||||
function schedule(soon) {
|
||||
if (trPoll) clearTimeout(trPoll);
|
||||
trPoll = setTimeout(() => training().catch(() => {}), soon ? 4000 : 60000);
|
||||
}
|
||||
|
||||
async function startJob(body) {
|
||||
try {
|
||||
const r = await fetch('/api/training/jobs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); alert('trainer: ' + (e.error || ('HTTP ' + r.status))); }
|
||||
} catch (e) { alert(e.message); }
|
||||
training().catch(() => {});
|
||||
}
|
||||
|
||||
async function showLog(id) {
|
||||
const j = await api('/api/training/jobs/' + encodeURIComponent(id));
|
||||
const pre = document.getElementById('tr-log');
|
||||
if (!pre) return;
|
||||
pre.hidden = false;
|
||||
pre.textContent = j.log || '(no output yet)';
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
|
||||
async function showReport(v) {
|
||||
const r = await fetch('/api/training/versions/' + encodeURIComponent(v) + '/report');
|
||||
const pre = document.getElementById('tr-report');
|
||||
if (!pre) return;
|
||||
trShownReport = v;
|
||||
pre.hidden = false;
|
||||
pre.textContent = r.ok ? await r.text() : 'no report for ' + v + ' (HTTP ' + r.status + ')';
|
||||
}
|
||||
|
||||
training().catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { buildCollector, type CollectorApp } from "./app.js";
|
||||
|
||||
// The Training section's proxy: reviewer-gated, forwards a fixed set of paths to the
|
||||
// trainer's job API, passes its status codes through, and degrades cleanly when the trainer
|
||||
// is not configured or not reachable. The trainer is faked with a bare node http server.
|
||||
|
||||
const REVIEWER = { user: "julian", pass: "review-pass-123" };
|
||||
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
|
||||
|
||||
let dir: string;
|
||||
let fake: Server;
|
||||
let fakeUrl: string;
|
||||
let seen: { method: string; url: string; body: string }[];
|
||||
let app: CollectorApp;
|
||||
|
||||
async function start(trainerUrl: string | null): Promise<void> {
|
||||
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: new Map(), reviewer: REVIEWER, trainerUrl }, { dbFile: ":memory:" });
|
||||
await app.ready();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
|
||||
seen = [];
|
||||
fake = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
seen.push({ method: req.method ?? "", url: req.url ?? "", body });
|
||||
const json = (code: number, obj: unknown) => {
|
||||
res.writeHead(code, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
if (req.url === "/health") return json(200, { ok: true, busy: false });
|
||||
if (req.url === "/readiness") return json(200, { ready: false, labelled: { total: 3 } });
|
||||
if (req.url === "/versions") return json(200, { versions: [{ version: "v1", written: true }] });
|
||||
if (req.url === "/jobs" && req.method === "GET") return json(200, { jobs: [{ id: "j1" }], current: null });
|
||||
if (req.url === "/jobs" && req.method === "POST") return body.includes('"busy"') ? json(409, { error: "a job is already running" }) : json(202, { id: "j2", status: "running" });
|
||||
if (req.url === "/jobs/j1") return json(200, { id: "j1", status: "done", log: "ok" });
|
||||
if (req.url === "/versions/v1/report") {
|
||||
res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
|
||||
return res.end("# Body-type classifier v1\n");
|
||||
}
|
||||
return json(404, { error: "not found" });
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => fake.listen(0, "127.0.0.1", r));
|
||||
const a = fake.address() as { port: number };
|
||||
fakeUrl = `http://127.0.0.1:${a.port}`;
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app?.close();
|
||||
await new Promise<void>((r) => fake.close(() => r()));
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("review page script", () => {
|
||||
it("parses as JavaScript (an apostrophe in a template literal once broke the whole page)", async () => {
|
||||
const { reviewPage } = await import("./review-page.js");
|
||||
const html = reviewPage();
|
||||
const script = html.slice(html.indexOf("<script>") + 8, html.lastIndexOf("</script>"));
|
||||
expect(() => new Function(script)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("training proxy", () => {
|
||||
it("is hidden when no trainer is configured", async () => {
|
||||
await start(null);
|
||||
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
|
||||
expect(s.json()).toEqual({ configured: false });
|
||||
const j = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "train" } });
|
||||
expect(j.statusCode).toBe(503);
|
||||
});
|
||||
|
||||
it("aggregates status and forwards jobs and reports behind the reviewer login", async () => {
|
||||
await start(fakeUrl);
|
||||
expect((await app.inject({ method: "GET", url: "/api/training/status" })).statusCode).toBe(401);
|
||||
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
|
||||
expect(s.statusCode).toBe(200);
|
||||
const body = s.json();
|
||||
expect(body.configured).toBe(true);
|
||||
expect(body.reachable).toBe(true);
|
||||
expect(body.readiness.labelled.total).toBe(3);
|
||||
expect(body.versions[0].version).toBe("v1");
|
||||
expect(body.jobs[0].id).toBe("j1");
|
||||
|
||||
const j = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/training/jobs",
|
||||
headers: { authorization: basic },
|
||||
payload: { kind: "train", mode: "features", minAccuracy: 0.9, secret: "nope", version: "v2" },
|
||||
});
|
||||
expect(j.statusCode).toBe(202);
|
||||
expect(j.json().id).toBe("j2");
|
||||
const posted = seen.find((r) => r.method === "POST")!;
|
||||
expect(JSON.parse(posted.body)).toEqual({ kind: "train", mode: "features", minAccuracy: 0.9, version: "v2" }); // unknown keys dropped
|
||||
|
||||
const busy = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "evaluate", version: "busy" } });
|
||||
expect(busy.statusCode).toBe(409); // the trainer's answer passes through
|
||||
|
||||
const bad = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "rm-rf" } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
|
||||
const one = await app.inject({ method: "GET", url: "/api/training/jobs/j1", headers: { authorization: basic } });
|
||||
expect(one.json().status).toBe("done");
|
||||
expect((await app.inject({ method: "GET", url: "/api/training/jobs/..%2Fx", headers: { authorization: basic } })).statusCode).toBe(400);
|
||||
|
||||
const rep = await app.inject({ method: "GET", url: "/api/training/versions/v1/report", headers: { authorization: basic } });
|
||||
expect(rep.statusCode).toBe(200);
|
||||
expect(rep.headers["content-type"]).toContain("text/markdown");
|
||||
expect(rep.body).toContain("# Body-type classifier v1");
|
||||
});
|
||||
|
||||
it("reports an unreachable trainer without failing the page", async () => {
|
||||
await start("http://127.0.0.1:9"); // nothing listens on the discard port
|
||||
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
|
||||
expect(s.statusCode).toBe(200);
|
||||
expect(s.json().reachable).toBe(false);
|
||||
const j = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "train" } });
|
||||
expect(j.statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"references": [{ "path": "../../packages/shared" }],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: { include: ["src/**/*.test.ts"], env: { LOG_LEVEL: "silent" } },
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Parking System",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.parking.desktop",
|
||||
"build": {
|
||||
"devUrl": "http://localhost:5173",
|
||||
|
||||
@@ -87,3 +87,20 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
|
||||
# is never entitled to a module its Komodo stack env does not name. Required modules
|
||||
# (parking) are always on. See wiki/decisions/venue-modules.md.
|
||||
#MODULES_ENTITLED=parking,validation
|
||||
|
||||
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) -------------------------
|
||||
# The operator's category choice is a hypothesis: each wash order with a vehicle read queues
|
||||
# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way
|
||||
# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint
|
||||
# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a
|
||||
# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every
|
||||
# item). Set in the Komodo stack env, per booth. Nothing is queued while off.
|
||||
# CARWASH_REVIEW_URL=
|
||||
# CARWASH_REVIEW_TOKEN=
|
||||
# CARWASH_REVIEW_BOOTH_ID=
|
||||
# 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
|
||||
|
||||
@@ -15,6 +15,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/server/package.json apps/server/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/vision/package.json apps/vision/
|
||||
COPY apps/collector/package.json apps/collector/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/devices/package.json packages/devices/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -222,6 +222,22 @@ export function requirePermission(...required: Permission[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler guard satisfied by ANY ONE of the listed permissions — for a read that
|
||||
* two jobs legitimately share (a module's master data: the desk that works with it
|
||||
* reads it under the module's own permission, Setup reads it under site:read).
|
||||
*/
|
||||
export function requireAnyPermission(...anyOf: Permission[]) {
|
||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||
await req.jwtVerify();
|
||||
assertCsrf(req);
|
||||
refreshRole(req);
|
||||
if (!req.user || !anyOf.some((p) => roleHasPermissions(req.user!.roleId, [p]))) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler that requires a valid signed-in session but NO specific permission —
|
||||
* for "about me" routes (/me, change own language) every authenticated user may
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type PrinterInstance,
|
||||
type ReceiptData,
|
||||
type TicketHeader,
|
||||
printerRoleOf,
|
||||
} from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devicesByDirection } from "./device-resolve.js";
|
||||
@@ -41,7 +42,7 @@ function loadPrinters(db: Db): PrinterInstance[] {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||
const role = printerRoleOf(cfg);
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { PrinterStatus } from "@parking/devices";
|
||||
import type { LedgerEventRow } from "@parking/db";
|
||||
import type { VehicleRead } from "@parking/shared";
|
||||
|
||||
// Internal event bus for device-originated events (button presses, etc.).
|
||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||
@@ -52,7 +53,7 @@ export interface ReadOutcome {
|
||||
export interface PrinterStatusEvent {
|
||||
readonly deviceId: string; // devices id
|
||||
readonly driverId: string;
|
||||
readonly role?: string; // entry-dispenser | booth-receipt
|
||||
readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
|
||||
readonly status: PrinterStatus;
|
||||
}
|
||||
|
||||
@@ -74,10 +75,10 @@ export interface DeviceStatusEvent {
|
||||
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
|
||||
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
|
||||
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
|
||||
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
|
||||
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
|
||||
* - undetermined: null (chip shows the category alone)
|
||||
*/
|
||||
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||||
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
|
||||
readonly state: "ready" | "degraded" | "offline";
|
||||
readonly detail?: string;
|
||||
readonly checkedAt: string; // ISO-8601
|
||||
@@ -105,6 +106,17 @@ export interface PlateRecognizedEvent {
|
||||
readonly direction: "entry" | "exit";
|
||||
}
|
||||
|
||||
/** Emitted when vision classified the vehicle in an entry/exit frame (advisory; stored on
|
||||
* the read row like the plate). A module may sample these — the Car Wash review outbox
|
||||
* queues one in N ENTRY reads for the remote reviewer, in the gate view the classifier
|
||||
* will be trained on (wiki/concepts/vision-review-outbox.md). The core emits; it never
|
||||
* knows who listens. */
|
||||
export interface VehicleReadEvent {
|
||||
readonly identity: string;
|
||||
readonly direction: "entry" | "exit";
|
||||
readonly read: VehicleRead;
|
||||
}
|
||||
|
||||
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||
@@ -197,6 +209,13 @@ class DeviceEventBus extends EventEmitter {
|
||||
this.on("plate-recognized", cb);
|
||||
return () => this.off("plate-recognized", cb);
|
||||
}
|
||||
emitVehicleRead(event: VehicleReadEvent): void {
|
||||
this.emit("vehicle-read", event);
|
||||
}
|
||||
onVehicleRead(cb: (event: VehicleReadEvent) => void): () => void {
|
||||
this.on("vehicle-read", cb);
|
||||
return () => this.off("vehicle-read", cb);
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide device event bus. */
|
||||
|
||||
@@ -64,7 +64,7 @@ export function localIsoWithOffset(tz: string, at = new Date()): string {
|
||||
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
|
||||
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
|
||||
* than one direction; null if it declares none yet
|
||||
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt)
|
||||
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
|
||||
*/
|
||||
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||
switch (row.category) {
|
||||
@@ -89,6 +89,7 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||
const role = (row.config as { role?: string }).role;
|
||||
if (role === "booth-receipt") return "booth";
|
||||
if (role === "entry-dispenser") return "lane";
|
||||
if (role === "wash-desk") return "wash";
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type PrinterInstance,
|
||||
type TicketData,
|
||||
type TicketHeader,
|
||||
printerRoleOf,
|
||||
} from "@parking/devices";
|
||||
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
@@ -523,7 +524,7 @@ export class EntryFlow {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||
const role = printerRoleOf(cfg);
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
|
||||
@@ -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";
|
||||
@@ -188,6 +188,17 @@ describe("orders", () => {
|
||||
// A second lookup no longer carries the line (it's settled).
|
||||
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||
expect(again.json().chargeLines).toEqual([]);
|
||||
|
||||
// The booth's Z-report: the wash money is inside cash (it is in the drawer) but
|
||||
// OUT of the ticket bucket, under its own module — Bileta is parking money only.
|
||||
const parking = payment.payload.parkingMinor as number;
|
||||
const z = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||
expect(z).toMatchObject({ till: "booth", cashTotalMinor: parking + 50000, ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||||
expect(z.ticketTotalMinor + z.subscriptionTotalMinor + 50000).toBe(z.cashTotalMinor + z.cardTotalMinor);
|
||||
const summary = (await app.inject({ method: "GET", url: "/api/shifts", headers: { cookie: a.cookie } })).json().shifts[0];
|
||||
expect(summary).toMatchObject({ till: "booth", ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||||
const signed = (await events(a)).find((e) => e.type === "shift_z_report")!;
|
||||
expect(signed.payload.chargesByModuleMinor).toEqual({ carwash: 50000 });
|
||||
});
|
||||
|
||||
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
|
||||
@@ -429,6 +440,12 @@ describe("tills are gated by the module permission", () => {
|
||||
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
|
||||
});
|
||||
const w = await login(app, washer.username, washer.password);
|
||||
// The desk's category/service pickers come from the settings read — the job has no
|
||||
// site:read, so the module permission must open it (found on park dev, 2026-09-06).
|
||||
const list = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: w.cookie } });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json().categories.length).toBeGreaterThan(0);
|
||||
expect((await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(w), payload: { payAt: "bay" } })).statusCode).toBe(403);
|
||||
// What the UI offers: only the wash till.
|
||||
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||||
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||||
@@ -492,3 +509,138 @@ describe("a role reassignment takes effect without re-login", () => {
|
||||
expect(me.roleId).toBe("wash-op");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a shift's activity log is per till", () => {
|
||||
it("/api/events?till= applies tillOfEvent; a feed-only role reads its module's events and nothing else", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-L");
|
||||
await setPayAt(a, "bay");
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-L", categoryId: ids.suv, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 500, till: "carwash" } });
|
||||
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 700 } });
|
||||
|
||||
const types = async (qs: string, auth: Auth = a) => {
|
||||
const r = await app.inject({ method: "GET", url: `/api/events?limit=200${qs}`, headers: { cookie: auth.cookie } });
|
||||
expect(r.statusCode).toBe(200);
|
||||
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).map((e) => `${e.type}${e.payload?.till ? `@${e.payload.till}` : ""}`);
|
||||
};
|
||||
// The wash till's log: its shift, its order (no money moved, but wash-desk activity),
|
||||
// its bay payment and its voucher — none of the booth's.
|
||||
const wash = await types("&till=carwash");
|
||||
expect(wash).toEqual(expect.arrayContaining(["shift_open@carwash", "carwash_order", "carwash_payment@carwash", "cash_in@carwash"]));
|
||||
expect(wash.some((t) => t.startsWith("vehicle_entry") || t === "shift_open@booth" || t === "cash_in@booth")).toBe(false);
|
||||
// The booth's log: entry, its shift, its voucher — and no wash-desk activity.
|
||||
const booth = await types("&till=booth");
|
||||
expect(booth).toEqual(expect.arrayContaining(["vehicle_entry", "shift_open@booth", "cash_in@booth"]));
|
||||
expect(booth.some((t) => t.startsWith("carwash_") || t.endsWith("@carwash"))).toBe(false);
|
||||
// No till → everything (unchanged).
|
||||
const all = await types("");
|
||||
expect(all.length).toBe(wash.length + booth.length);
|
||||
expect((await app.inject({ method: "GET", url: "/api/events?till=bar", headers: { cookie: a.cookie } })).statusCode).toBe(400);
|
||||
|
||||
// A wash operator holds carwash:read but not event:read: the log opens for them
|
||||
// with ONLY the module's own event types (the live-socket rule, feedPermissionFor).
|
||||
const washer = await seedUser(db, { username: "lavazhier", roleId: "washer", permissions: ["carwash:read", "carwash:cash"] });
|
||||
const w = await login(app, washer.username, washer.password);
|
||||
const mine = await types("&till=carwash", w);
|
||||
expect(mine).toEqual(expect.arrayContaining(["carwash_order", "carwash_payment@carwash"]));
|
||||
expect(mine.every((t) => t.startsWith("carwash_"))).toBe(true);
|
||||
// A role with neither event:read nor any module feed permission reads nothing.
|
||||
const clerk = await seedUser(db, { username: "clerk", roleId: "clerk", permissions: ["session:read"] });
|
||||
const c = await login(app, clerk.username, clerk.password);
|
||||
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).toMatchObject({ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { deviceEvents } from "../../device-events.js";
|
||||
import type { ServerModule } from "../index.js";
|
||||
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||
import { carwashRoutes } from "./routes.js";
|
||||
import { CarwashService } from "./service.js";
|
||||
|
||||
@@ -10,10 +12,25 @@ import { CarwashService } from "./service.js";
|
||||
export const carwashModule: ServerModule = {
|
||||
id: "carwash",
|
||||
async register(app, deps) {
|
||||
const service = new CarwashService(deps, app.log);
|
||||
// The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env
|
||||
// names a collector URL, a per-booth token and a pseudonymous booth id; off = no
|
||||
// queueing at all. One-way, background, never on the intake path.
|
||||
const cfg = reviewUploadConfigFromEnv();
|
||||
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
|
||||
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
|
||||
outbox.start();
|
||||
// Entry-stream sampling: one in N entry vehicle reads goes to the reviewer as pure
|
||||
// training material (the gate view, no order attached). The core announces the read;
|
||||
// the module decides. Off unless CARWASH_REVIEW_ENTRY_SAMPLE is set.
|
||||
const offVehicleRead = deviceEvents.onVehicleRead((e) => {
|
||||
if (e.direction === "entry" && outbox.sampleEntry()) void outbox.enqueueEntry(e.read);
|
||||
});
|
||||
app.addHook("onClose", async () => offVehicleRead());
|
||||
app.addHook("onClose", async () => outbox.stop());
|
||||
const service = new CarwashService(deps, app.log, outbox);
|
||||
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||
// the core calls back after the payment is signed so the order is marked paid.
|
||||
deps.payStation.registerChargeProvider(service.chargeProvider());
|
||||
await carwashRoutes(app, deps, service);
|
||||
await carwashRoutes(app, deps, service, outbox);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { deviceEvents as deviceEventBus } from "../../device-events.js";
|
||||
import { buildServer } from "../../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
|
||||
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||
|
||||
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
|
||||
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
|
||||
// backoff, never blocking the wash, never naming the site.
|
||||
|
||||
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
|
||||
async function frame(): Promise<Buffer> {
|
||||
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
|
||||
.composite([
|
||||
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
|
||||
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
|
||||
])
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
}
|
||||
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
|
||||
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
|
||||
|
||||
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
|
||||
* blurred plate drops far below 255 as the red bleeds in. */
|
||||
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
|
||||
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
|
||||
let sum = 0;
|
||||
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
|
||||
return sum / (data.length / info.channels);
|
||||
}
|
||||
|
||||
describe("makeReviewCrop", () => {
|
||||
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
|
||||
const shot = await frame();
|
||||
const crop = await makeReviewCrop(shot, CAR, PLATE);
|
||||
expect(crop.plateBlurred).toBe(true);
|
||||
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
|
||||
expect(crop.width).toBeGreaterThanOrEqual(228);
|
||||
expect(crop.width).toBeLessThanOrEqual(236);
|
||||
expect(crop.height).toBeGreaterThanOrEqual(135);
|
||||
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
|
||||
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
|
||||
// frame is white, the review crop is the red bleeding in.
|
||||
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
|
||||
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
|
||||
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
|
||||
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
|
||||
// Without a plate box: same crop, nothing blurred.
|
||||
const noPlate = await makeReviewCrop(shot, CAR, null);
|
||||
expect(noPlate.plateBlurred).toBe(false);
|
||||
// A big frame is capped to the max edge.
|
||||
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
|
||||
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
|
||||
expect(Math.max(capped.width, capped.height)).toBe(640);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config + pseudonyms", () => {
|
||||
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
|
||||
expect(reviewUploadConfigFromEnv({})).toBeNull();
|
||||
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
|
||||
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
|
||||
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
|
||||
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
|
||||
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
|
||||
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("queue + drain", () => {
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
beforeEach(() => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
});
|
||||
afterEach(() => close());
|
||||
|
||||
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60, entrySample: 0 };
|
||||
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
|
||||
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false };
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||
db.insert(carwashOrders).values({
|
||||
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
|
||||
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
|
||||
}).run();
|
||||
}
|
||||
|
||||
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
|
||||
await seed();
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||
expect(await ob.enqueue(item, read)).toBe(true);
|
||||
const row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row.status).toBe("queued");
|
||||
expect(row.image!.length).toBeGreaterThan(500);
|
||||
expect(row.payload).toMatchObject({ v: 1, kind: "wash", booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
|
||||
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
|
||||
|
||||
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.url).toBe(cfg.url);
|
||||
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
|
||||
const form = calls[0]!.init.body as FormData;
|
||||
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
|
||||
expect((form.get("image") as File).type).toBe("image/jpeg");
|
||||
const after = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(after.status).toBe("sent");
|
||||
expect(after.image).toBeNull();
|
||||
expect(after.sentAt).toBeTruthy();
|
||||
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
|
||||
});
|
||||
|
||||
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
|
||||
await seed();
|
||||
let status = 503;
|
||||
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
|
||||
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||
await ob.enqueue(item, read);
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||
let row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
|
||||
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
|
||||
// Not due yet → untouched.
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
|
||||
// Due again: a network error defers too; a 422 abandons.
|
||||
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||
status = 0;
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||
status = 422;
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||
row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
|
||||
expect(row.image).toBeNull();
|
||||
|
||||
// A voided order is not a sample.
|
||||
status = 200;
|
||||
await ob.enqueue({ ...item, orderId: "o-1" }, read);
|
||||
db.update(carwashOrders).set({ status: "void" }).run();
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||
// Expired items are abandoned without a request.
|
||||
await ob.enqueue(item, read);
|
||||
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
|
||||
db.update(carwashOrders).set({ status: "open" }).run();
|
||||
const before = fetchFn.mock.calls.length;
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||
expect(fetchFn.mock.calls.length).toBe(before);
|
||||
expect(ob.status().failed).toBe(3);
|
||||
|
||||
// Entry sampling: one in N entry reads becomes a package with the crop and the
|
||||
// camera's class only — no order, no operator, no category.
|
||||
const sampler = new ReviewOutbox(db, silentLogger(), { ...cfg, entrySample: 3 }, fetchFn);
|
||||
expect([sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry()]).toEqual([false, false, true, false]);
|
||||
expect(ob.sampleEntry()).toBe(false); // entrySample 0 = off
|
||||
expect(await sampler.enqueueEntry(read)).toBe(true);
|
||||
const entryRow = db.select().from(carwashReviewOutbox).where(eq(carwashReviewOutbox.orderId, "entry:snap-1")).get()!;
|
||||
expect(entryRow.payload).toMatchObject({ v: 1, kind: "entry", booth: "booth-7", vision: { class: "car", confidence: 0.86 }, image: { plateBlurred: true } });
|
||||
expect(entryRow.payload).not.toHaveProperty("operator");
|
||||
expect(entryRow.payload).not.toHaveProperty("operatorCategory");
|
||||
expect(entryRow.image!.length).toBeGreaterThan(500);
|
||||
|
||||
// No vehicle box, no snapshot, or upload off → nothing queued.
|
||||
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
|
||||
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
|
||||
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
import { eq } from "@parking/db";
|
||||
|
||||
describe("through the app", () => {
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
const saved = { ...process.env };
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
|
||||
process.env.CARWASH_REVIEW_TOKEN = "tok";
|
||||
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
|
||||
process.env.CARWASH_REVIEW_ENTRY_SAMPLE = "1";
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID", "CARWASH_REVIEW_ENTRY_SAMPLE"]) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const a = await login(app, username, password);
|
||||
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
|
||||
seedTariff(db);
|
||||
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
|
||||
const cat = s.categories[0].id, svc = s.services[0].id;
|
||||
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
|
||||
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
|
||||
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||
db.insert(deviceEvents).values({
|
||||
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
|
||||
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
|
||||
occurredAt: new Date().toISOString(),
|
||||
}).run();
|
||||
|
||||
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
|
||||
expect(order.statusCode).toBe(201);
|
||||
// Enqueue is fire-and-forget: give the crop a moment.
|
||||
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
|
||||
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
|
||||
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0, 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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db";
|
||||
import type { NormBox, VehicleRead } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md).
|
||||
//
|
||||
// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model:
|
||||
// the operator may err or cheat). So every wash order that has a vehicle read queues a
|
||||
// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry
|
||||
// snapshot with the plate BLURRED, the operator's choice, and what the camera thought.
|
||||
// The reviewer's verdict becomes the training label for the body-type classifier (phase
|
||||
// B) and, per operator, the honest-mistake / fraud rate.
|
||||
//
|
||||
// Rules that shape this file:
|
||||
// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path;
|
||||
// a background loop drains the queue when the private overlay (Netbird) is up, with
|
||||
// backoff, and gives up loudly after EXPIRE_DAYS.
|
||||
// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The
|
||||
// signed ledger stays the only record of what happened at the wash.
|
||||
// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no
|
||||
// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy,
|
||||
// the operator as a keyed hash. The mapping back to people and places stays with the
|
||||
// reviewer, off the collector.
|
||||
// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the
|
||||
// booth can do nothing at the collector but this one POST.
|
||||
|
||||
export interface ReviewUploadConfig {
|
||||
/** The collector's ingest URL (reachable only over the overlay). */
|
||||
readonly url: string;
|
||||
/** Per-booth bearer token. */
|
||||
readonly token: string;
|
||||
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
|
||||
readonly boothId: string;
|
||||
readonly intervalSec: number;
|
||||
/** Queue one in N ENTRY vehicle reads (no order attached) for the reviewer — the gate
|
||||
* view is exactly what the classifier is trained on, and the entry stream is many times
|
||||
* the wash stream. 0 = off. */
|
||||
readonly entrySample: number;
|
||||
}
|
||||
|
||||
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
|
||||
export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null {
|
||||
const url = (env.CARWASH_REVIEW_URL ?? "").trim();
|
||||
const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim();
|
||||
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
|
||||
if (!url || !token || !boothId) return null;
|
||||
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 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
|
||||
* enough that a day of washes is a few megabytes. */
|
||||
export const CROP_MAX_EDGE = 640;
|
||||
/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */
|
||||
const CROP_MARGIN = 0.08;
|
||||
/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks
|
||||
* should not resurface a fortnight of crops in one burst. */
|
||||
export const EXPIRE_DAYS = 14;
|
||||
/** Backoff: 1 min · 2^attempts, capped. */
|
||||
const BACKOFF_BASE_MS = 60_000;
|
||||
const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000;
|
||||
const UPLOAD_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** What one order contributes to the package (the service hands this over at intake). */
|
||||
export interface ReviewItemInput {
|
||||
readonly orderId: string;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly categoryId: string;
|
||||
readonly categoryName: string;
|
||||
/** The vision classes the chosen category covers at this site (its mapping) — lets the
|
||||
* reviewer's class be judged against the operator's category without the site's setup. */
|
||||
readonly categoryClasses: readonly string[];
|
||||
readonly serviceName: string;
|
||||
readonly visionCategoryId: string | null;
|
||||
readonly downgraded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions
|
||||
* of the frame, so this works on the stored (downscaled) copy. Returns a JPEG.
|
||||
*/
|
||||
export async function makeReviewCrop(
|
||||
snapshotBytes: Buffer,
|
||||
box: NormBox,
|
||||
plateBox: NormBox | null | undefined,
|
||||
): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> {
|
||||
const img = sharp(snapshotBytes, { failOn: "none" }).rotate();
|
||||
const meta = await img.metadata();
|
||||
const W = meta.width ?? 0;
|
||||
const H = meta.height ?? 0;
|
||||
if (!W || !H) throw new Error("snapshot has no dimensions");
|
||||
const px = (b: NormBox) => ({
|
||||
left: Math.round(b.x1 * W), top: Math.round(b.y1 * H),
|
||||
right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H),
|
||||
});
|
||||
const v = px(box);
|
||||
const mw = Math.round((v.right - v.left) * CROP_MARGIN);
|
||||
const mh = Math.round((v.bottom - v.top) * CROP_MARGIN);
|
||||
const left = Math.max(0, v.left - mw);
|
||||
const top = Math.max(0, v.top - mh);
|
||||
const right = Math.min(W, v.right + mw);
|
||||
const bottom = Math.min(H, v.bottom + mh);
|
||||
const width = right - left;
|
||||
const height = bottom - top;
|
||||
if (width < 8 || height < 8) throw new Error("vehicle box too small to crop");
|
||||
|
||||
let crop = img.clone().extract({ left, top, width, height });
|
||||
let plateBlurred = false;
|
||||
if (plateBox) {
|
||||
// The plate region, in CROP coordinates, padded a little so the blur eats the edges.
|
||||
const p = px(plateBox);
|
||||
const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25);
|
||||
const pl = Math.max(0, p.left - pad - left);
|
||||
const pt = Math.max(0, p.top - pad - top);
|
||||
const pr = Math.min(width, p.right + pad - left);
|
||||
const pb = Math.min(height, p.bottom + pad - top);
|
||||
if (pr - pl >= 2 && pb - pt >= 2) {
|
||||
const region = await sharp(await crop.clone().toBuffer())
|
||||
.extract({ left: pl, top: pt, width: pr - pl, height: pb - pt })
|
||||
.blur(Math.max(6, Math.round((pr - pl) / 6)))
|
||||
.toBuffer();
|
||||
crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]);
|
||||
plateBlurred = true;
|
||||
}
|
||||
}
|
||||
const out = await crop
|
||||
.resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 85, mozjpeg: true })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred };
|
||||
}
|
||||
|
||||
/** The operator as a keyed hash — stable per booth so the reviewer can count per person,
|
||||
* meaningless anywhere else. */
|
||||
export function operatorRef(boothId: string, username: string): string {
|
||||
return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface OutboxStatus {
|
||||
readonly enabled: boolean;
|
||||
readonly boothId: string | null;
|
||||
readonly queued: number;
|
||||
readonly sent: number;
|
||||
readonly failed: number;
|
||||
readonly lastSentAt: string | null;
|
||||
readonly lastError: string | null;
|
||||
/** 0 = entry sampling off; N = one in N entry reads is queued. */
|
||||
readonly entrySample: number;
|
||||
}
|
||||
|
||||
export class ReviewOutbox {
|
||||
readonly #db: Db;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #cfg: ReviewUploadConfig | null;
|
||||
readonly #fetch: FetchLike;
|
||||
#timer: NodeJS.Timeout | null = null;
|
||||
#draining = false;
|
||||
#entrySeen = 0;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
this.#cfg = cfg;
|
||||
this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.#cfg != null;
|
||||
}
|
||||
|
||||
/** Queue one order's package. Fire-and-forget: the caller does NOT await this on the
|
||||
* intake path; every failure is logged, none is thrown. Skipped when there is no
|
||||
* vehicle box (nothing to crop — a frame without a detected vehicle is no training
|
||||
* sample) or when upload is not configured (an unbounded queue nobody drains). */
|
||||
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
|
||||
if (!this.#cfg) return false;
|
||||
return this.#queue(item.orderId, read, (id, crop) => ({
|
||||
v: 1,
|
||||
kind: "wash",
|
||||
booth: this.#cfg!.boothId,
|
||||
item: id,
|
||||
order: item.orderId,
|
||||
at: item.createdAt,
|
||||
operator: operatorRef(this.#cfg!.boothId, item.createdBy),
|
||||
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
|
||||
service: item.serviceName,
|
||||
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
|
||||
downgraded: item.downgraded,
|
||||
image: crop,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Every Nth entry read is a sample (N = entrySample); the caller queues it. Counted
|
||||
* in-process, so "1 in 5" is exactly that across a booth's day. */
|
||||
sampleEntry(): boolean {
|
||||
const n = this.#cfg?.entrySample ?? 0;
|
||||
if (n <= 0) return false;
|
||||
this.#entrySeen += 1;
|
||||
return this.#entrySeen % n === 0;
|
||||
}
|
||||
|
||||
/** Queue an ENTRY sample: the crop and the camera's class only — no order, no operator,
|
||||
* no category. Pure training material in the gate view; the reviewer labels it. */
|
||||
async enqueueEntry(read: VehicleRead): Promise<boolean> {
|
||||
if (!this.#cfg) return false;
|
||||
return this.#queue(`entry:${read.snapshotId ?? "?"}`, read, (id, crop) => ({
|
||||
v: 1,
|
||||
kind: "entry",
|
||||
booth: this.#cfg!.boothId,
|
||||
item: id,
|
||||
at: new Date().toISOString(),
|
||||
vision: { class: read.bodyType, confidence: read.confidence },
|
||||
image: crop,
|
||||
}));
|
||||
}
|
||||
|
||||
async #queue(
|
||||
ref: string,
|
||||
read: VehicleRead,
|
||||
build: (id: string, image: { width: number; height: number; plateBlurred: boolean }) => Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
if (!read.box || !read.snapshotId) return false;
|
||||
try {
|
||||
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
|
||||
if (!snap) {
|
||||
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — ${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
|
||||
.insert(carwashReviewOutbox)
|
||||
.values({ id, orderId: ref, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
|
||||
.run();
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`carwash review: could not queue ${ref}: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.#cfg || this.#timer) return;
|
||||
const tick = () => {
|
||||
void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`));
|
||||
};
|
||||
this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000);
|
||||
this.#timer.unref?.();
|
||||
setTimeout(tick, 5_000).unref?.();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.#timer) clearInterval(this.#timer);
|
||||
this.#timer = null;
|
||||
}
|
||||
|
||||
/** Send what is due, oldest first. Returns the tally; never throws for a single item. */
|
||||
async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> {
|
||||
const tally = { sent: 0, failed: 0, deferred: 0 };
|
||||
if (!this.#cfg || this.#draining) return tally;
|
||||
this.#draining = true;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const due = this.#db
|
||||
.select()
|
||||
.from(carwashReviewOutbox)
|
||||
.where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now))))
|
||||
.orderBy(asc(carwashReviewOutbox.createdAt))
|
||||
.limit(limit)
|
||||
.all();
|
||||
for (const row of due) {
|
||||
const outcome = await this.#send(row);
|
||||
tally[outcome] += 1;
|
||||
}
|
||||
if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`);
|
||||
} finally {
|
||||
this.#draining = false;
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> {
|
||||
const cfg = this.#cfg!;
|
||||
const ageMs = Date.now() - Date.parse(row.createdAt);
|
||||
if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`);
|
||||
// A wash voided before delivery is not a sample (and not a decision to review).
|
||||
const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get();
|
||||
if (order?.status === "void") return this.#fail(row, "order voided");
|
||||
if (!row.image) return this.#fail(row, "image missing");
|
||||
|
||||
const form = new FormData();
|
||||
form.set("meta", JSON.stringify(row.payload));
|
||||
form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`);
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await this.#fetch(cfg.url, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId },
|
||||
body: form,
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
this.#db
|
||||
.update(carwashReviewOutbox)
|
||||
.set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 })
|
||||
.where(eq(carwashReviewOutbox.id, row.id))
|
||||
.run();
|
||||
return "sent";
|
||||
}
|
||||
// The collector refused the package itself → no retry will help.
|
||||
if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`);
|
||||
// Everything else (auth not yet fixed, throttled, collector down) → try again later.
|
||||
return this.#defer(row, `HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message);
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
#fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" {
|
||||
this.#db
|
||||
.update(carwashReviewOutbox)
|
||||
.set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 })
|
||||
.where(eq(carwashReviewOutbox.id, row.id))
|
||||
.run();
|
||||
this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`);
|
||||
return "failed";
|
||||
}
|
||||
|
||||
#defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" {
|
||||
const attempts = row.attempts + 1;
|
||||
const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS);
|
||||
this.#db
|
||||
.update(carwashReviewOutbox)
|
||||
.set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() })
|
||||
.where(eq(carwashReviewOutbox.id, row.id))
|
||||
.run();
|
||||
return "deferred";
|
||||
}
|
||||
|
||||
status(): OutboxStatus {
|
||||
const count = (s: "queued" | "sent" | "failed") =>
|
||||
this.#db.select({ n: sql<number>`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0;
|
||||
const lastSent = this.#db.select({ at: sql<string | null>`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null;
|
||||
const lastErr = this.#db
|
||||
.select({ e: carwashReviewOutbox.lastError })
|
||||
.from(carwashReviewOutbox)
|
||||
.where(sql`${carwashReviewOutbox.lastError} is not null`)
|
||||
.orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`)
|
||||
.limit(1)
|
||||
.get()?.e ?? null;
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
boothId: this.#cfg?.boothId ?? null,
|
||||
entrySample: this.#cfg?.entrySample ?? 0,
|
||||
queued: count("queued"),
|
||||
sent: count("sent"),
|
||||
failed: count("failed"),
|
||||
lastSentAt: lastSent,
|
||||
lastError: lastErr,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { Tender } from "@parking/shared";
|
||||
import { requirePermission } from "../../auth.js";
|
||||
import { requireAnyPermission, requirePermission } from "../../auth.js";
|
||||
import { requireModule } from "../../modules.js";
|
||||
import { NoShiftOpenError } from "../../shift-service.js";
|
||||
import type { ServerModuleDeps } from "../index.js";
|
||||
import type { ReviewOutbox } from "./review-outbox.js";
|
||||
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
||||
|
||||
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
||||
@@ -27,15 +28,22 @@ function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||
throw err;
|
||||
}
|
||||
|
||||
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise<void> {
|
||||
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise<void> {
|
||||
const moduleOn = requireModule(deps.db, "carwash");
|
||||
const settingsRead = [moduleOn, requirePermission("site:read")];
|
||||
// The price list is the desk's working data as much as Setup's: the wash operator
|
||||
// reads it under the module's own permission (the Wash operator job holds no site:*).
|
||||
const settingsRead = [moduleOn, requireAnyPermission("carwash:read", "site:read")];
|
||||
const settingsWrite = [moduleOn, requirePermission("site:update")];
|
||||
const read = [moduleOn, requirePermission("carwash:read")];
|
||||
const create = [moduleOn, requirePermission("carwash:create")];
|
||||
const update = [moduleOn, requirePermission("carwash:update")];
|
||||
|
||||
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
||||
// The review outbox's health (Setup → Car wash): how many decisions wait for the
|
||||
// reviewer, how many went, the last error. Site admin's read.
|
||||
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
|
||||
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null, entrySample: 0 },
|
||||
);
|
||||
|
||||
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||
try {
|
||||
|
||||
@@ -23,10 +23,17 @@ 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 type { ReviewOutbox } from "./review-outbox.js";
|
||||
import { effectiveModulesFor } from "../../modules.js";
|
||||
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
||||
import type { ShiftService } from "../../shift-service.js";
|
||||
@@ -57,11 +64,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 +91,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}$/;
|
||||
@@ -105,13 +117,15 @@ export class CarwashService {
|
||||
readonly #pay: PayStation;
|
||||
readonly #shift: ShiftService;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #outbox: ReviewOutbox | null;
|
||||
|
||||
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger) {
|
||||
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) {
|
||||
this.#db = deps.db;
|
||||
this.#log = deps.eventLog;
|
||||
this.#pay = deps.payStation;
|
||||
this.#shift = deps.shiftService;
|
||||
this.#logger = logger;
|
||||
this.#outbox = outbox;
|
||||
}
|
||||
|
||||
#enabled(): boolean {
|
||||
@@ -127,7 +141,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 +156,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 +165,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 +204,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 +223,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 +301,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 +349,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 +399,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 +409,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 +450,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,8 +520,29 @@ 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();
|
||||
// Hand the decision to the remote reviewer (crop + choice), off the intake path.
|
||||
if (vision && this.#outbox?.enabled) {
|
||||
void this.#outbox.enqueue(
|
||||
{
|
||||
orderId: row.id,
|
||||
createdAt: now,
|
||||
createdBy: input.actor,
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
categoryClasses: category.visionClasses,
|
||||
serviceName: service.name,
|
||||
visionCategoryId: visionCategory?.id ?? null,
|
||||
downgraded: downgradeEventId != null,
|
||||
},
|
||||
vision,
|
||||
);
|
||||
}
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||||
import { isNormBox, 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,38 @@ interface ReadDetail {
|
||||
plate?: string;
|
||||
confidence?: number;
|
||||
direction?: string;
|
||||
bodyType?: string;
|
||||
bodyConfidence?: number;
|
||||
snapshotId?: string;
|
||||
vehicleBox?: unknown;
|
||||
plateBox?: unknown;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
box: isNormBox(d.vehicleBox) ? d.vehicleBox : null,
|
||||
plateBox: isNormBox(d.plateBox) ? d.plateBox : 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. */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db";
|
||||
import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared";
|
||||
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { effectiveModulesFor } from "../modules.js";
|
||||
import { enrichEvents } from "../event-enrich.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
|
||||
@@ -15,25 +16,59 @@ export async function eventRoutes(
|
||||
db: Db,
|
||||
eventLog: EventLog,
|
||||
): Promise<void> {
|
||||
// Reading the log (the audit trail).
|
||||
const guard = requirePermission("event:read");
|
||||
// Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it
|
||||
// may still hold a module's feed permission (a wash operator's `carwash:read`) and
|
||||
// then reads ONLY that module's event types — the same rule the live socket applies
|
||||
// (feedPermissionFor; venue-modules.md §Permissions matrix, move 3).
|
||||
|
||||
/** The event types a role may read, or null for "everything" (event:read). Empty =
|
||||
* the role reads nothing → 403 at the route. */
|
||||
function readableTypes(roleId: string): LedgerEventType[] | null {
|
||||
if (roleHasPermissions(roleId, ["event:read"])) return null;
|
||||
const effective = effectiveModulesFor(db);
|
||||
const out: LedgerEventType[] = [];
|
||||
for (const m of MODULES) {
|
||||
if (!m.feedPermission || !effective.includes(m.id)) continue;
|
||||
if (roleHasPermissions(roleId, [m.feedPermission])) out.push(...m.ledgerEventTypes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** SQL form of the shared `tillOfEvent` rule: the payload's `till`, else the till of
|
||||
* the module owning the event type, else the booth. Computed in the query so the
|
||||
* page limit applies AFTER the till filter (a shift's window can hold thousands of
|
||||
* device events). */
|
||||
const tillExpr = (() => {
|
||||
const cases = MODULES.filter((m) => m.till && m.till !== BOOTH_TILL && m.ledgerEventTypes.length > 0).map(
|
||||
(m) => sql`when ${ledgerEvents.type} in (${sql.join(m.ledgerEventTypes.map((t) => sql`${t}`), sql`, `)}) then ${m.till}`,
|
||||
);
|
||||
return sql`coalesce(json_extract(${ledgerEvents.payload}, '$.till'), case ${sql.join(cases, sql` `)} else ${BOOTH_TILL} end)`;
|
||||
})();
|
||||
|
||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes
|
||||
// the current shift's start so the live feed shows ONLY this shift's activity. An
|
||||
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a
|
||||
// selected shift's [start, end] to show just that shift's signed activity log.
|
||||
// (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
||||
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>(
|
||||
// (logs are per-shift, not all history). An optional `till` keeps only that till's
|
||||
// activity (tillOfEvent) — a booth shift's log no longer shows the wash desk's, and
|
||||
// vice versa. See wiki/concepts/shift.md §Tills.
|
||||
app.get<{ Querystring: { limit?: string; since?: string; until?: string; till?: string } }>(
|
||||
"/api/events",
|
||||
{ preHandler: guard },
|
||||
async (req) => {
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const types = readableTypes(req.user?.roleId ?? "");
|
||||
if (types && types.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||
const since = (req.query.since ?? "").trim();
|
||||
const until = (req.query.until ?? "").trim();
|
||||
const till = (req.query.till ?? "").trim();
|
||||
if (till && !isTillId(till)) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||
const bounds = [
|
||||
since ? gte(ledgerEvents.occurredAt, since) : undefined,
|
||||
until ? lte(ledgerEvents.occurredAt, until) : undefined,
|
||||
till ? sql`${tillExpr} = ${till}` : undefined,
|
||||
types ? inArray(ledgerEvents.type, types) : undefined,
|
||||
].filter(Boolean);
|
||||
const rows = db
|
||||
.select()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { jobsBehind } from "@parking/shared";
|
||||
import { buildServer } from "../server.js";
|
||||
import { login, seedUser } from "../test-helpers.js";
|
||||
|
||||
// Roles are data composed from the permission grid (venue-modules.md §Permissions
|
||||
// matrix): every edit is SIGNED as a config_change, and a role remembers the manifest
|
||||
// JOBS it was built from so a grown job can be surfaced and re-applied.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
type Auth = { cookie: string; csrf: string };
|
||||
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||
async function admin(): Promise<Auth> {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
return login(app, username, password);
|
||||
}
|
||||
async function roleChanges(a: Auth) {
|
||||
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).filter(
|
||||
(e) => e.type === "config_change" && String(e.payload.setting).startsWith("role."),
|
||||
);
|
||||
}
|
||||
|
||||
describe("role edits are signed and jobs are remembered", () => {
|
||||
it("create / update / delete each sign one config_change with prev + value + operator; a no-op resave signs nothing", async () => {
|
||||
const a = await admin();
|
||||
const created = await app.inject({
|
||||
method: "POST", url: "/api/roles", headers: hdrs(a),
|
||||
payload: { name: "Lavazh", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const role = created.json();
|
||||
expect(role.jobs).toEqual(["wash-operator"]);
|
||||
let evs = await roleChanges(a);
|
||||
expect(evs).toHaveLength(1);
|
||||
expect(evs[0]!.payload).toMatchObject({
|
||||
setting: `role.${role.id}`, prev: null, operator: "boss",
|
||||
value: { name: "Lavazh", jobs: ["wash-operator"] },
|
||||
});
|
||||
expect((evs[0]!.payload.value as { permissions: string[] }).permissions).toEqual(["carwash:cash", "carwash:create", "carwash:read", "carwash:update"]);
|
||||
|
||||
// Same content again → nothing new on the chain.
|
||||
const same = await app.inject({
|
||||
method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a),
|
||||
payload: { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
||||
});
|
||||
expect(same.statusCode).toBe(200);
|
||||
expect(await roleChanges(a)).toHaveLength(1);
|
||||
|
||||
// A real change: prev is the old shape, value the new.
|
||||
const renamed = await app.inject({ method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a), payload: { name: "Lavazh NEW" } });
|
||||
expect(renamed.statusCode).toBe(200);
|
||||
evs = await roleChanges(a);
|
||||
expect(evs).toHaveLength(2);
|
||||
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh" }, value: { name: "Lavazh NEW" } });
|
||||
|
||||
const gone = await app.inject({ method: "DELETE", url: `/api/roles/${role.id}`, headers: hdrs(a) });
|
||||
expect(gone.statusCode).toBe(200);
|
||||
evs = await roleChanges(a);
|
||||
expect(evs).toHaveLength(3);
|
||||
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh NEW" }, value: null });
|
||||
});
|
||||
|
||||
it("unknown jobs are refused; a role built from a job that later grew reports what it is missing", async () => {
|
||||
const a = await admin();
|
||||
const bad = await app.inject({ method: "POST", url: "/api/roles", headers: hdrs(a), payload: { name: "X", permissions: [], jobs: ["bar-tender"] } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
// Compose "behind": the role follows wash-operator but holds only part of today's bundle
|
||||
// — exactly what an older release's chip would have left once the job grew.
|
||||
const r = (await app.inject({
|
||||
method: "POST", url: "/api/roles", headers: hdrs(a),
|
||||
payload: { name: "Old wash", permissions: ["carwash:read", "carwash:create"], jobs: ["wash-operator"] },
|
||||
})).json();
|
||||
const view = (await app.inject({ method: "GET", url: "/api/roles", headers: { cookie: a.cookie } })).json().roles.find((x: { id: string }) => x.id === r.id);
|
||||
const has = new Set<string>(view.permissions);
|
||||
expect(jobsBehind(view.jobs, (p) => has.has(p))).toEqual([{ job: "wash-operator", missing: ["carwash:update", "carwash:cash"] }]);
|
||||
// Re-apply = the union; then nothing is behind.
|
||||
const fixed = (await app.inject({
|
||||
method: "PUT", url: `/api/roles/${r.id}`, headers: hdrs(a),
|
||||
payload: { permissions: [...has, "carwash:update", "carwash:cash"] },
|
||||
})).json();
|
||||
const has2 = new Set<string>(fixed.permissions);
|
||||
expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared";
|
||||
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
|
||||
// Role management (admin). Roles are DATA: an admin composes a role from the
|
||||
@@ -18,14 +19,30 @@ import { softDelete } from "../recycle-bin.js";
|
||||
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
|
||||
// only put permissions they ALREADY hold onto a role. An admin (full set) is
|
||||
// unrestricted, which is the intended behaviour.
|
||||
//
|
||||
// EVERY role edit is SIGNED on the ledger as a `config_change` (setting `role.<id>`,
|
||||
// value/prev = the role's name + permissions + jobs, operator = who) — a role edit is a
|
||||
// privilege change, and under this threat model the only setting an admin could alter
|
||||
// without a trace. A role also REMEMBERS the manifest JOBS it was composed from
|
||||
// (role_jobs) so a later release that grows a job's bundle can be surfaced and
|
||||
// re-applied — the grid is never expanded silently (venue-modules.md §Permissions matrix).
|
||||
|
||||
interface RoleBody {
|
||||
name: string;
|
||||
permissions: string[];
|
||||
jobs?: string[];
|
||||
}
|
||||
interface UpdateBody {
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
jobs?: string[];
|
||||
}
|
||||
|
||||
/** What a signed role change records (before/after). */
|
||||
interface RoleShape {
|
||||
name: string;
|
||||
permissions: Permission[];
|
||||
jobs: string[];
|
||||
}
|
||||
|
||||
const VALID = new Set<string>(PERMISSIONS);
|
||||
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
|
||||
return { ok: true, perms: [...out] };
|
||||
}
|
||||
|
||||
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
/** Validate + dedupe a requested job list against the registry's job presets. */
|
||||
function cleanJobs(input: unknown): { ok: true; jobs: string[] } | { ok: false; bad: string } {
|
||||
if (input == null) return { ok: true, jobs: [] };
|
||||
if (!Array.isArray(input)) return { ok: false, bad: "jobs must be an array" };
|
||||
const out = new Set<string>();
|
||||
for (const j of input) {
|
||||
if (typeof j !== "string" || !jobById(j)) return { ok: false, bad: `unknown job: ${String(j)}` };
|
||||
out.add(j);
|
||||
}
|
||||
return { ok: true, jobs: [...out] };
|
||||
}
|
||||
|
||||
export async function roleRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog): Promise<void> {
|
||||
const readGuard = requirePermission("role:read");
|
||||
const createGuard = requirePermission("role:create");
|
||||
const updateGuard = requirePermission("role:update");
|
||||
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
name: role.name,
|
||||
builtin: role.builtin === 1,
|
||||
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
|
||||
jobs: jobsOf(roleId),
|
||||
userCount,
|
||||
};
|
||||
}
|
||||
|
||||
function jobsOf(roleId: string): string[] {
|
||||
return db.select({ jobId: roleJobs.jobId }).from(roleJobs).where(eq(roleJobs.roleId, roleId)).all().map((r) => r.jobId).sort();
|
||||
}
|
||||
|
||||
/** The role as the ledger records it (sorted so two identical shapes compare equal). */
|
||||
function shapeOf(roleId: string): RoleShape | null {
|
||||
const v = roleView(roleId);
|
||||
if (!v) return null;
|
||||
return { name: v.name, permissions: [...v.permissions].sort() as Permission[], jobs: v.jobs };
|
||||
}
|
||||
|
||||
/** Replace a role's remembered jobs. */
|
||||
function setJobs(roleId: string, jobs: string[]): void {
|
||||
db.delete(roleJobs).where(eq(roleJobs.roleId, roleId)).run();
|
||||
for (const jobId of jobs) db.insert(roleJobs).values({ roleId, jobId }).run();
|
||||
}
|
||||
|
||||
/** Sign a role change. `prev` null = created; `value` null = deleted. Skipped when
|
||||
* nothing changed (a no-op resave leaves no trace, like the site-config flips). */
|
||||
async function signRoleChange(req: { user?: { username?: string } }, roleId: string, prev: RoleShape | null, value: RoleShape | null): Promise<void> {
|
||||
if (JSON.stringify(prev) === JSON.stringify(value)) return;
|
||||
await eventLog?.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
payload: { setting: `role.${roleId}`, value, prev, operator: req.user?.username ?? "unknown" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace a role's permission rows with `perms` (in a single pass). */
|
||||
function setPermissions(roleId: string, perms: Permission[]): void {
|
||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
|
||||
@@ -104,13 +162,17 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
const cleaned = cleanPermissions(req.body?.permissions ?? []);
|
||||
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
||||
const jobs = cleanJobs(req.body?.jobs);
|
||||
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
|
||||
const over = escalates(req.user.roleId, cleaned.perms);
|
||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||
|
||||
const id = randomUUID();
|
||||
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
||||
setPermissions(id, cleaned.perms);
|
||||
setJobs(id, jobs.jobs);
|
||||
bumpPermsCache();
|
||||
await signRoleChange(req, id, null, shapeOf(id));
|
||||
return reply.code(201).send(roleView(id));
|
||||
});
|
||||
|
||||
@@ -125,6 +187,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
if (role.builtin === 1) {
|
||||
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
|
||||
}
|
||||
const prev = shapeOf(id);
|
||||
|
||||
if (req.body?.name != null) {
|
||||
const name = req.body.name.trim();
|
||||
@@ -140,7 +203,13 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||
setPermissions(id, cleaned.perms);
|
||||
}
|
||||
if (req.body?.jobs != null) {
|
||||
const jobs = cleanJobs(req.body.jobs);
|
||||
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
|
||||
setJobs(id, jobs.jobs);
|
||||
}
|
||||
bumpPermsCache();
|
||||
await signRoleChange(req, id, prev, shapeOf(id));
|
||||
return roleView(id);
|
||||
},
|
||||
);
|
||||
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
if (holders > 0) {
|
||||
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
||||
}
|
||||
const prev = shapeOf(id);
|
||||
softDelete(db, "role", id, req.user.sub);
|
||||
bumpPermsCache();
|
||||
await signRoleChange(req, id, prev, null);
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -117,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// RBAC administration: compose roles (role:*) + manage users (user:*). The
|
||||
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
|
||||
await userRoutes(app, db);
|
||||
await roleRoutes(app, db);
|
||||
|
||||
// Vision (ANPR) client — built early so the device monitor can include the vision
|
||||
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
|
||||
@@ -144,6 +143,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||
await setupRoutes(app, db, visionClient, eventLog);
|
||||
await roleRoutes(app, db, eventLog);
|
||||
|
||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
|
||||
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
||||
import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } from "@parking/shared";
|
||||
import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
|
||||
import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
@@ -71,12 +71,19 @@ export interface ShiftSummary {
|
||||
readonly subscriptionSalesMinor: number;
|
||||
readonly subscriptionWindowMinor: number;
|
||||
readonly discountTotalMinor: number;
|
||||
readonly chargesByModuleMinor: ChargesByModule;
|
||||
readonly openingFloatMinor: number;
|
||||
readonly cashAddedMinor: number;
|
||||
readonly cashRemovedMinor: number;
|
||||
readonly expectedDrawerMinor: number;
|
||||
}
|
||||
|
||||
/** Module money folded into this till's payments as `chargeLines`, by owning module —
|
||||
* a wash paid on the parking ticket lands here as `{ carwash: <minor> }`. Only modules
|
||||
* that actually charged in the window appear. Cash+card already contain it; it is
|
||||
* broken OUT of the ticket bucket so "Bileta" is parking money only. */
|
||||
export type ChargesByModule = Partial<Record<ModuleId, number>>;
|
||||
|
||||
export interface ShiftReport {
|
||||
readonly till: TillId;
|
||||
readonly operator: string;
|
||||
@@ -98,6 +105,8 @@ export interface ShiftReport {
|
||||
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
|
||||
* cash/card figures above are already NET of it). See validation-discounts.md. */
|
||||
readonly discountTotalMinor: number;
|
||||
/** Module charges settled on this till's payments (a booth-paid wash), by module. */
|
||||
readonly chargesByModuleMinor: ChargesByModule;
|
||||
// --- Drawer (physical cash till; carries across shifts) ---
|
||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||
readonly openingFloatMinor: number;
|
||||
@@ -142,6 +151,15 @@ export class InvalidCashMovementError extends Error {
|
||||
|
||||
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
|
||||
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
|
||||
/** The takings line a till's OWN money prints under (the booth sells tickets; the wash
|
||||
* desk sells washes) and the label a module's charge gets when it rides another
|
||||
* till's ticket ("Lavazh (në biletë)"). Printed slips are Albanian (i18n.md). */
|
||||
const TILL_TAKINGS_LABEL: Record<TillId, string> = { booth: "Bileta", carwash: "Lavazh" };
|
||||
const MODULE_PRINT_LABEL: Partial<Record<ModuleId, string>> = { carwash: "Lavazh", validation: "Validime" };
|
||||
/** Which printer a till's slips (Z-report, vouchers) want. The wash desk falls back to
|
||||
* the booth printer when it has none of its own (orderForRole); the booth never falls
|
||||
* back to the desk. See wiki/concepts/printer-roles-failover.md. */
|
||||
const TILL_PRINTER_ROLE: Record<TillId, PrinterRole> = { booth: "booth-receipt", carwash: "wash-desk" };
|
||||
|
||||
export class ShiftService {
|
||||
readonly #db: Db;
|
||||
@@ -248,6 +266,7 @@ export class ShiftService {
|
||||
subscriptionSalesMinor?: number;
|
||||
subscriptionWindowMinor?: number;
|
||||
discountTotalMinor?: number;
|
||||
chargesByModuleMinor?: ChargesByModule;
|
||||
openingFloatMinor?: number;
|
||||
cashAddedMinor?: number;
|
||||
cashRemovedMinor?: number;
|
||||
@@ -283,6 +302,8 @@ export class ShiftService {
|
||||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
||||
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
|
||||
discountTotalMinor: pl.discountTotalMinor ?? 0,
|
||||
// Module charges on the ticket (added 2026-09-06). Old reports lack it → none.
|
||||
chargesByModuleMinor: pl.chargesByModuleMinor ?? {},
|
||||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||
@@ -558,8 +579,8 @@ export class ShiftService {
|
||||
.from(ledgerEvents)
|
||||
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
|
||||
// parking payment's amount already, as chargeLines). Both fold into the cash/card
|
||||
// tender totals so the expected drawer is right; a separate wash bucket on the
|
||||
// Z-report is a follow-up (venue-modules.md).
|
||||
// tender totals so the expected drawer is right; the booth-paid wash is then
|
||||
// broken OUT of the ticket bucket into chargesByModuleMinor (see below).
|
||||
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
|
||||
.all()
|
||||
.filter(
|
||||
@@ -578,11 +599,16 @@ export class ShiftService {
|
||||
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
|
||||
// tender totals are already NET; this is the "given away" figure beside them.
|
||||
let discountTotalMinor = 0;
|
||||
// Module charges folded into this till's payments (chargeLines on a booth payment),
|
||||
// summed by owning module. Part of cash/card; NOT ticket money.
|
||||
const chargesByModuleMinor: ChargesByModule = {};
|
||||
let chargesTotalMinor = 0;
|
||||
let currency: string | null = null;
|
||||
for (const p of payments) {
|
||||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||
subscriptionSale?: boolean;
|
||||
subscriptionWindowCharge?: boolean;
|
||||
chargeLines?: ChargeLine[];
|
||||
};
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
if (pl.tender === "card") cardTotalMinor += amt;
|
||||
@@ -591,10 +617,17 @@ export class ShiftService {
|
||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||
// (else → transient ticket; derived below as total − subscription)
|
||||
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||
for (const l of pl.chargeLines ?? []) {
|
||||
if (typeof l.amountMinor !== "number" || !l.module) continue;
|
||||
chargesByModuleMinor[l.module] = (chargesByModuleMinor[l.module] ?? 0) + l.amountMinor;
|
||||
chargesTotalMinor += l.amountMinor;
|
||||
}
|
||||
if (pl.currency) currency = pl.currency;
|
||||
}
|
||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
|
||||
// Ticket = what is left once subscriber money and module charges are taken out:
|
||||
// ticket + subscriptions + Σcharges = cash + card, always.
|
||||
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor - chargesTotalMinor;
|
||||
|
||||
// --- Drawer figures ---
|
||||
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||
@@ -649,6 +682,7 @@ export class ShiftService {
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
chargesByModuleMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -689,6 +723,7 @@ export class ShiftService {
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
chargesByModuleMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -713,6 +748,8 @@ export class ShiftService {
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
// Only when a module charged in the window (older slips/payloads stay identical).
|
||||
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -729,14 +766,9 @@ export class ShiftService {
|
||||
return { ...report, printed };
|
||||
}
|
||||
|
||||
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||||
* is the record — a failed print doesn't undo the close). */
|
||||
/** Print the Z-report on the till's printer (best-effort; the signed event is the
|
||||
* record — a failed print doesn't undo the close). */
|
||||
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
|
||||
const printer = await this.#boothPrinter();
|
||||
if (!printer) {
|
||||
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
|
||||
return false;
|
||||
}
|
||||
const cur = r.currency ?? "";
|
||||
const money = (m: number) => (m / 100).toFixed(2);
|
||||
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
||||
@@ -754,11 +786,23 @@ export class ShiftService {
|
||||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||||
"",
|
||||
"-- Arkëtime sipas burimit --",
|
||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
// The booth prints its three classic lines (byte-identical to before tills); a
|
||||
// module's till prints its own takings under its own name — it sells no tickets
|
||||
// and no subscriptions.
|
||||
...(r.till === BOOTH_TILL
|
||||
? [
|
||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
]
|
||||
: [`${TILL_TAKINGS_LABEL[r.till]}: ${money(r.ticketTotalMinor)} ${cur}`]),
|
||||
// Module money that rode this till's tickets (a booth-paid wash) — its own line,
|
||||
// only when any was taken, so the operator sees parking and wash money apart.
|
||||
...Object.entries(r.chargesByModuleMinor)
|
||||
.filter(([, v]) => (v ?? 0) > 0)
|
||||
.map(([m, v]) => `${MODULE_PRINT_LABEL[m as ModuleId] ?? m} (në biletë): ${money(v ?? 0)} ${cur}`),
|
||||
// Merchant-validation leakage — printed only when the shift actually gave any
|
||||
// (older slips stay byte-identical). The takings above are already NET of it.
|
||||
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
|
||||
@@ -770,13 +814,7 @@ export class ShiftService {
|
||||
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
|
||||
}
|
||||
|
||||
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
|
||||
@@ -792,11 +830,6 @@ export class ShiftService {
|
||||
at: string;
|
||||
till: TillId;
|
||||
}): Promise<boolean> {
|
||||
const printer = await this.#boothPrinter();
|
||||
if (!printer) {
|
||||
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
const cur = v.currency ?? "";
|
||||
const money = (m: number) => (m / 100).toFixed(2);
|
||||
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
||||
@@ -810,27 +843,57 @@ export class ShiftService {
|
||||
"",
|
||||
`Regjistroi: ${v.operator}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title, lines });
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
|
||||
}
|
||||
|
||||
/** First enabled booth-receipt printer, or any enabled printer. */
|
||||
async #boothPrinter(): Promise<PrinterDevice | null> {
|
||||
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||
const enabled = rows.filter((r) => r.enabled);
|
||||
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
|
||||
if (!booth) return null;
|
||||
const driver = registry.get(booth.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(booth.config as never) as PrinterDevice;
|
||||
} catch {
|
||||
return null;
|
||||
/** Print a till's slip on its printer with failover (wash desk → booth printer;
|
||||
* see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
|
||||
* record — every failure is logged and reported as "not printed", never thrown.
|
||||
* Legacy fallback: a site whose only printer carries no booth role (one unit,
|
||||
* configured as the entry dispenser) still prints its slips on it, as before. */
|
||||
async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
|
||||
const printers = this.#loadPrinters();
|
||||
const want = TILL_PRINTER_ROLE[till];
|
||||
let ordered = orderForRole(printers, want);
|
||||
if (ordered.length === 0 && till === BOOTH_TILL) ordered = printers.slice(0, 1);
|
||||
if (ordered.length === 0) {
|
||||
this.#logger.warn(`no ${want} printer — ${what} not printed (event is recorded)`);
|
||||
return false;
|
||||
}
|
||||
const attempts: string[] = [];
|
||||
for (const p of ordered) {
|
||||
try {
|
||||
await job(p.device);
|
||||
if (p.role !== want) this.#logger.info(`${what} printed on ${p.id} (${p.role}; no ${want} printer reachable)`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
attempts.push(`${p.id} (${(err as Error).message})`);
|
||||
}
|
||||
}
|
||||
this.#logger.warn(`${what} print failed on every candidate: ${attempts.join(", ")} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Every enabled printer as a live instance (role + rank from its saved config). */
|
||||
#loadPrinters(): PrinterInstance[] {
|
||||
const rows = this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||
const out: PrinterInstance[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) continue;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
role: printerRoleOf(cfg),
|
||||
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||
device: driver.create(cfg as never) as PrinterDevice,
|
||||
});
|
||||
} catch {
|
||||
// skip a printer whose config won't build
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,22 +167,42 @@ 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;
|
||||
// Boxes are kept as FRACTIONS of the analysed frame (the stored snapshot is a
|
||||
// downscaled copy — see reencodeForStorage), so the wash's review crop can cut the
|
||||
// vehicle out of whatever copy survives and blur the plate inside it.
|
||||
const frame = await frameSize(shot.bytes);
|
||||
const norm = (b: { x1: number; y1: number; x2: number; y2: number } | null | undefined) =>
|
||||
b && frame
|
||||
? {
|
||||
x1: clamp01(b.x1 / frame.w), y1: clamp01(b.y1 / frame.h),
|
||||
x2: clamp01(b.x2 / frame.w), y2: clamp01(b.y2 / frame.h),
|
||||
}
|
||||
: null;
|
||||
// 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 vehicleBox = norm(result.vehicle?.bbox);
|
||||
const vehicle = result.vehicle
|
||||
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence, ...(vehicleBox ? { vehicleBox } : {}) }
|
||||
: {};
|
||||
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
|
||||
const plateBox = plate ? norm(result.plate?.bbox) : null;
|
||||
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, ...(plateBox ? { plateBox } : {}) }
|
||||
: {}),
|
||||
...vehicle,
|
||||
modelVersion: result.modelVersion,
|
||||
snapshotId,
|
||||
source: "entry-exit-snapshot",
|
||||
@@ -190,7 +210,18 @@ 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 (vehicleBox) {
|
||||
deviceEvents.emitVehicleRead({
|
||||
identity,
|
||||
direction,
|
||||
read: { bodyType: result.vehicle.bodyType, confidence: result.vehicle.confidence, snapshotId, box: vehicleBox, plateBox },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!plate) return;
|
||||
logger.info(`anpr plate '${plate}' (${result.plate!.confidence.toFixed(3)}) for ${identity}`);
|
||||
// The session's entry/exit event already shipped without this (async) plate — tell the
|
||||
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
|
||||
deviceEvents.emitPlateRecognized({ identity, plate, direction });
|
||||
@@ -206,6 +237,20 @@ async function recognizePlate(
|
||||
}
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
/** Pixel size of the analysed frame (JPEG header only — cheap). Null when unreadable. */
|
||||
async function frameSize(bytes: Buffer): Promise<{ w: number; h: number } | null> {
|
||||
try {
|
||||
const m = await sharp(bytes, { failOn: "none" }).metadata();
|
||||
return m.width && m.height ? { w: m.width, h: m.height } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
||||
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
||||
* re-pressing within minutes; a long window would flag legit re-visits. */
|
||||
|
||||
@@ -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,21 @@ 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 vehicle's box in frame pixels, when the stage found one. */
|
||||
readonly bbox?: PlateBBox | null;
|
||||
}
|
||||
|
||||
/** 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; bbox?: PlateBBox | null } | null;
|
||||
readonly low_confidence: boolean;
|
||||
readonly model_version: string;
|
||||
readonly took_ms: number;
|
||||
@@ -61,6 +72,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 +137,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)), bbox: v.bbox ?? null }
|
||||
: 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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
**/__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
out/
|
||||
.env
|
||||
@@ -0,0 +1,12 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Model weights (fetched at deploy / first run, never committed — can be large + license-scoped)
|
||||
out/
|
||||
|
||||
*.onnx
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,49 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Parking TRAINER image: the phase-B body-type classifier. Build CONTEXT is apps/trainer
|
||||
# (self-contained Python package). Runs on the reviewer's host (art-docker-station) beside
|
||||
# the collector, never on a booth: by default it SERVES the job API the collector's Training
|
||||
# section drives (`serve`); the same image runs the CLI one-off (`train`, `inspect`, …). It
|
||||
# reads the wash collector's volume (collector.sqlite + crops/) and writes versioned model
|
||||
# folders. CPU-only PyTorch — the host has no usable GPU and a few thousand crops train in
|
||||
# minutes/an hour on four Xeon cores.
|
||||
# See wiki/decisions/bodytype-classifier-training.md.
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
ENV UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml uv.lock .python-version ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-install-project --no-dev --extra train
|
||||
|
||||
COPY trainer/ ./trainer/
|
||||
COPY README.md ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --extra train
|
||||
|
||||
# Pre-warm the ImageNet backbone weights INTO the image so a run needs no network (the
|
||||
# host has one, but a job that fetches at run time is a job that fails at 2 am). Best-effort:
|
||||
# without network at build time torchvision fetches lazily on the first run.
|
||||
ENV TORCH_HOME=/app/torch-home
|
||||
RUN uv run python -c "import torchvision.models as m; m.resnet18(weights=m.ResNet18_Weights.IMAGENET1K_V1); m.mobilenet_v3_small(weights=m.MobileNet_V3_Small_Weights.IMAGENET1K_V1)" \
|
||||
|| echo "[build] backbone weights not pre-warmed (no network) — fetched on first run"
|
||||
|
||||
RUN useradd --system --create-home --uid 999 trainer \
|
||||
&& mkdir -p /data /out && chown -R trainer:trainer /app /out
|
||||
USER trainer
|
||||
|
||||
ENV TRAINER_DATA_DIR=/data \
|
||||
TRAINER_OUT_DIR=/out \
|
||||
TRAINER_PORT=8091
|
||||
VOLUME ["/out"]
|
||||
EXPOSE 8091
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8091/health').status==200 else 1)" || exit 1
|
||||
ENTRYPOINT ["uv", "run", "--no-sync", "parking-trainer"]
|
||||
CMD ["serve"]
|
||||
@@ -0,0 +1,36 @@
|
||||
# parking-trainer
|
||||
|
||||
The phase-B **body-type classifier** job. Reads the wash collector's volume
|
||||
(`collector.sqlite` + `crops/`), trains a classifier on the reviewer's labels, and writes a
|
||||
versioned model folder the vision image bakes in — or refuses when validation is below the
|
||||
floor. Design and decisions: `wiki/decisions/bodytype-classifier-training.md`.
|
||||
|
||||
```
|
||||
parking-trainer inspect --data /data # what a run would train on
|
||||
parking-trainer train --data /data --out /out # features mode (minutes)
|
||||
parking-trainer train --mode finetune --epochs 12 ... # full fine-tune (about an hour on 4 cores)
|
||||
parking-trainer evaluate --model /out/<version>/bodytype.onnx --data /data
|
||||
parking-trainer publish /out/<version> --url https://git.infra.msai.al/api/packages/mca/generic/parking-bodytype
|
||||
```
|
||||
|
||||
Exit codes: `0` model written · `2` not enough labels · `3` below the floor (report written,
|
||||
no model) · `1` other.
|
||||
|
||||
A passing run writes `<out>/<version>/`:
|
||||
|
||||
| file | what |
|
||||
| --- | --- |
|
||||
| `bodytype.onnx` | the classifier; input `image` = RGB float32 0–255 `[N,3,S,S]`, output `logits` `[N,K]`; normalisation is inside the graph |
|
||||
| `bodytype.json` | sidecar: version, class list (in vocabulary order), input size, crop margin, backbone, mode, label counts, validation metrics |
|
||||
| `report.md` | the human report: accuracy, per-class recall/precision, confusion matrix, dropped classes, loss weights |
|
||||
| `metrics.json` | the same numbers, machine-readable |
|
||||
|
||||
On the reviewer's host the image runs `serve` as the `trainer` service of the
|
||||
`wash-collector` stack: a job API (`/health`, `/readiness`, `/versions`, `/jobs`) on the compose
|
||||
network that the collector's **Training section** (`/review`) drives — readiness, Train /
|
||||
Evaluate / Publish, reports and logs. Jobs run as subprocesses of the CLI, one at a time; state
|
||||
and logs persist under `/out/jobs/`. The CLI stays for debugging:
|
||||
`docker compose -f docker-compose.collector.yml exec trainer parking-trainer inspect`.
|
||||
|
||||
Local dev: `uv sync --extra train` (CPU torch, ~200 MB), `uv run pytest -q`. The test suite
|
||||
runs without the extra (torch tests skip), matching CI.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@parking/trainer",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"//": "Thin shim so this Python job is a node in the Turbo task graph (NOT a JS package — deps are managed by uv/pyproject.toml). It is a one-off job image, never a booth service: see wiki/decisions/bodytype-classifier-training.md.",
|
||||
"scripts": {
|
||||
"lint": "uv run ruff check .",
|
||||
"format": "uv run ruff format .",
|
||||
"typecheck": "uv run mypy trainer",
|
||||
"test": "uv run pytest -q",
|
||||
"build": "echo 'no build step (Python job; see Dockerfile)'"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
[project]
|
||||
name = "parking-trainer"
|
||||
version = "0.0.0"
|
||||
description = "Phase-B body-type classifier trainer: reviewer labels + crops off the wash collector's volume → an ONNX classifier the vision image bakes in."
|
||||
requires-python = ">=3.10,<4.0"
|
||||
# Core deps are LIGHT on purpose (same rule as the vision service): `inspect`, `evaluate`
|
||||
# and the data/report code run with only these, so `uv sync` and the test suite work
|
||||
# in CI without the PyTorch stack. Training itself needs the `train` extra.
|
||||
# See wiki/decisions/bodytype-classifier-training.md.
|
||||
dependencies = [
|
||||
"numpy>=1.26",
|
||||
# OpenCV does the decode + resize on BOTH sides (trainer and vision service): same
|
||||
# library, same interpolation, same pixels — the preprocessing contract (preprocess.py).
|
||||
"opencv-python-headless>=4.10",
|
||||
"onnxruntime>=1.19",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
parking-trainer = "trainer.cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# The training stack. CPU-only PyTorch (the reviewer's host has no usable GPU — the
|
||||
# decision is recorded in the wiki page above): resolved from PyTorch's CPU wheel index,
|
||||
# ~200 MB instead of the ~5 GB CUDA build. Install with: uv sync --extra train
|
||||
# torch / torchvision are BSD-3; the ImageNet backbone weights ship under the same
|
||||
# licence (the licence rule applies to weights as much as code).
|
||||
train = [
|
||||
"torch>=2.4",
|
||||
"torchvision>=0.19",
|
||||
"onnx>=1.16",
|
||||
"onnxscript>=0.3", # the torch.export-based ONNX exporter (MIT)
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.8",
|
||||
"pytest>=8.3",
|
||||
"mypy>=1.13",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
# Pick the CPU wheels for torch/torchvision from PyTorch's own index; everything else
|
||||
# from PyPI. `explicit = true` keeps the index from shadowing PyPI for other packages.
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
explicit = true
|
||||
|
||||
[tool.uv.sources]
|
||||
torch = [{ index = "pytorch-cpu" }]
|
||||
torchvision = [{ index = "pytorch-cpu" }]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 110
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["trainer"]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""A synthetic collector volume: the collector's `items` table (same DDL as apps/collector
|
||||
src/db.ts) + JPEG crops. Classes are told apart by COLOUR so even a random-init backbone's
|
||||
features separate them — the tests check the plumbing (split, floor, export, sidecar),
|
||||
not accuracy on real cars."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
DDL = """
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY, booth TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'wash',
|
||||
order_ref TEXT NOT NULL, at TEXT NOT NULL, operator_ref TEXT NOT NULL DEFAULT '',
|
||||
operator_category_id TEXT NOT NULL DEFAULT '', operator_category_name TEXT NOT NULL DEFAULT '',
|
||||
operator_classes TEXT NOT NULL DEFAULT '[]', service TEXT NOT NULL, vision_class TEXT NOT NULL,
|
||||
vision_confidence REAL NOT NULL, vision_category_id TEXT, downgraded INTEGER NOT NULL DEFAULT 0,
|
||||
image_width INTEGER NOT NULL, image_height INTEGER NOT NULL, plate_blurred INTEGER NOT NULL,
|
||||
image_path TEXT NOT NULL, received_at TEXT NOT NULL, review_label TEXT, reviewed_at TEXT, reviewer TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
COLOURS = {"sedan": (200, 40, 40), "suv": (40, 200, 40), "van": (40, 40, 200), "truck": (200, 200, 40)}
|
||||
|
||||
|
||||
def write_jpeg(path: Path, colour: tuple[int, int, int], rng: np.random.Generator) -> None:
|
||||
import cv2
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
h, w = int(rng.integers(120, 200)), int(rng.integers(160, 260))
|
||||
img = np.empty((h, w, 3), np.uint8)
|
||||
img[:] = colour[::-1] # BGR
|
||||
noise = rng.integers(-20, 20, size=img.shape, dtype=np.int16)
|
||||
img = np.clip(img.astype(np.int16) + noise, 0, 255).astype(np.uint8)
|
||||
cv2.imwrite(str(path), img, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collector_dir(tmp_path: Path) -> Path:
|
||||
"""40 labelled crops per class for sedan/suv/van, 5 for truck (below the minimum), a few
|
||||
unusable, a few pending, one labelled row whose file is missing."""
|
||||
rng = np.random.default_rng(1)
|
||||
con = sqlite3.connect(tmp_path / "collector.sqlite")
|
||||
con.executescript(DDL)
|
||||
t0 = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
n = 0
|
||||
|
||||
def add(label: str | None, reviewed: bool, kind: str = "wash", missing: bool = False) -> None:
|
||||
nonlocal n
|
||||
n += 1
|
||||
item = f"item-{n:04d}"
|
||||
rel = f"crops/booth-2/{item}.jpg"
|
||||
colour = COLOURS.get(label or "sedan", (128, 128, 128))
|
||||
if not missing:
|
||||
write_jpeg(tmp_path / rel, colour, rng)
|
||||
at = (t0 + timedelta(minutes=10 * n)).isoformat().replace("+00:00", "Z")
|
||||
reviewed_at = (
|
||||
(t0 + timedelta(days=1, minutes=n)).isoformat().replace("+00:00", "Z") if reviewed else None
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO items (id, booth, kind, order_ref, at, service, vision_class, vision_confidence, "
|
||||
"image_width, image_height, plate_blurred, image_path, received_at, review_label, "
|
||||
"reviewed_at, reviewer) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
item,
|
||||
"booth-2",
|
||||
kind,
|
||||
"o",
|
||||
at,
|
||||
"wash",
|
||||
"car" if label != "truck" else "truck",
|
||||
0.9,
|
||||
200,
|
||||
150,
|
||||
1,
|
||||
rel,
|
||||
at,
|
||||
label if reviewed else None,
|
||||
reviewed_at,
|
||||
"reviewer" if reviewed else None,
|
||||
),
|
||||
)
|
||||
|
||||
# Interleaved in time so every class exists on both sides of the time split.
|
||||
for i in range(40):
|
||||
for label in ("sedan", "suv", "van"):
|
||||
add(label, True)
|
||||
if i % 8 == 0:
|
||||
add("truck", True)
|
||||
add("unusable", True)
|
||||
add("unusable", True)
|
||||
add("sedan", True, missing=True)
|
||||
for _ in range(6):
|
||||
add(None, False, kind="entry")
|
||||
con.commit()
|
||||
con.close()
|
||||
return tmp_path
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Data rules, torch-free: labels, the time split, thin classes, weights, the report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from trainer.cli import main
|
||||
from trainer.data import (
|
||||
class_weights,
|
||||
load_labelled,
|
||||
load_reviewed_since,
|
||||
load_unlabelled,
|
||||
make_split,
|
||||
summarise,
|
||||
)
|
||||
from trainer.preprocess import CROP_MARGIN, Sidecar, load_input
|
||||
from trainer.report import compute_metrics, render_report
|
||||
|
||||
|
||||
def test_loads_only_reviewed_usable_rows_with_a_crop_on_disk(collector_dir: Path) -> None:
|
||||
samples, missing = load_labelled(collector_dir)
|
||||
assert missing == 1 # the labelled row whose file is gone
|
||||
assert len(samples) == 125 # 3×40 + 5 trucks; unusable and pending excluded
|
||||
assert all(s.path.is_file() for s in samples)
|
||||
assert {s.label for s in samples} == {"sedan", "suv", "van", "truck"}
|
||||
assert summarise(samples)["byClass"] == {"sedan": 40, "suv": 40, "van": 40, "truck": 5}
|
||||
assert len(load_unlabelled(collector_dir)) == 6
|
||||
assert len(load_reviewed_since(collector_dir, "2026-09-02T00:00:00Z")) == 125
|
||||
assert load_reviewed_since(collector_dir, "2030-01-01T00:00:00Z") == []
|
||||
|
||||
|
||||
def test_split_is_by_time_and_drops_thin_classes(collector_dir: Path) -> None:
|
||||
samples, _ = load_labelled(collector_dir)
|
||||
split = make_split(samples, val_fraction=0.2, min_per_class=20)
|
||||
assert split.classes == ("sedan", "suv", "van") # canonical order, truck dropped
|
||||
assert split.dropped == {"truck": 5}
|
||||
assert len(split.train) + len(split.val) == 120
|
||||
assert len(split.val) == 24
|
||||
assert max(s.at for s in split.train) < min(s.at for s in split.val) # newest = validation
|
||||
assert all(v > 0 for v in split.counts("val").values())
|
||||
|
||||
|
||||
def test_class_weights_lean_against_imbalance_but_gently(collector_dir: Path) -> None:
|
||||
samples, _ = load_labelled(collector_dir)
|
||||
vans = [s for s in samples if s.label == "van"]
|
||||
keep = set(vans[::10]) # 4 of 40 vans survive
|
||||
split = make_split([s for s in samples if s.label != "van" or s in keep], 0.2, 3)
|
||||
w = dict(zip(split.classes, class_weights(split), strict=True))
|
||||
assert w["van"] > w["sedan"] > 0 # the rare class weighs more
|
||||
assert w["van"] / w["sedan"] < 4 # but not the full inverse ratio (damped)
|
||||
assert abs(sum(w.values()) / len(w) - 1.0) < 1e-9
|
||||
|
||||
|
||||
def test_metrics_and_report() -> None:
|
||||
classes = ("sedan", "suv")
|
||||
m = compute_metrics(classes, [0, 0, 1, 1], [0, 1, 1, 1], camera=["car"] * 4)
|
||||
assert m.accuracy == 0.75
|
||||
assert m.per_class["sedan"].recall == 0.5 and m.per_class["suv"].precision == 2 / 3
|
||||
assert m.confusion == [[1, 1], [0, 2]]
|
||||
assert m.camera_agreement == 0.0
|
||||
text = render_report(
|
||||
version="v1",
|
||||
trained_at="t",
|
||||
mode="features",
|
||||
backbone="resnet18",
|
||||
epochs=3,
|
||||
classes=classes,
|
||||
train_counts={"sedan": 10, "suv": 8},
|
||||
val_counts={"sedan": 2, "suv": 2},
|
||||
dropped={"truck": 2},
|
||||
missing_files=1,
|
||||
weights=[0.9, 1.1],
|
||||
metrics=m,
|
||||
min_accuracy=0.85,
|
||||
written=False,
|
||||
)
|
||||
assert "MODEL NOT WRITTEN" in text and "| **sedan** | 1 | 1 |" in text and "truck (2)" in text
|
||||
|
||||
|
||||
def test_preprocess_contract(tmp_path: Path, collector_dir: Path) -> None:
|
||||
samples, _ = load_labelled(collector_dir)
|
||||
x = load_input(samples[0].path, 32)
|
||||
assert x.shape == (3, 32, 32) and x.dtype.name == "float32" and 0 <= x.min() and x.max() <= 255
|
||||
assert x[0].mean() > x[2].mean() # a sedan crop is red: RGB order, not BGR
|
||||
assert load_input(tmp_path / "nope.jpg", 32) is None
|
||||
side = Sidecar(version="v1", classes=["sedan", "suv"])
|
||||
side.write(tmp_path / "s.json")
|
||||
back = Sidecar.read(tmp_path / "s.json")
|
||||
assert back == side and back.crop_margin == CROP_MARGIN == 0.08 and back.normalization == "in-graph"
|
||||
|
||||
|
||||
def test_inspect_prints_the_run_shape(collector_dir: Path, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
assert main(["inspect", "--data", str(collector_dir)]) == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["ready"] is True and out["run"]["classes"] == ["sedan", "suv", "van"]
|
||||
assert out["run"]["dropped"] == {"truck": 5} and out["missingCrops"] == 1
|
||||
assert main(["inspect", "--data", str(collector_dir), "--min-per-class", "100"]) == 2
|
||||
@@ -0,0 +1,111 @@
|
||||
"""The job API: readiness, one job at a time, subprocess jobs with persisted logs, versions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from trainer.server import Handler, Jobs, readiness, versions, wait_idle
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(collector_dir: Path, tmp_path: Path): # type: ignore[no-untyped-def]
|
||||
out = tmp_path / "out"
|
||||
Handler.jobs = Jobs(collector_dir, out, "https://example.invalid/pkg", "tok")
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
t = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
t.start()
|
||||
base = f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
|
||||
def call(method: str, path: str, body: dict | None = None): # type: ignore[no-untyped-def]
|
||||
req = urllib.request.Request(base + path, method=method)
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, data=data, timeout=10) as r:
|
||||
raw = r.read()
|
||||
return r.status, (
|
||||
json.loads(raw) if r.headers.get_content_type() == "application/json" else raw.decode()
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read() or b"{}")
|
||||
|
||||
yield call, out
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_readiness_and_empty_versions(api) -> None: # type: ignore[no-untyped-def]
|
||||
call, _ = api
|
||||
code, r = call("GET", "/readiness")
|
||||
assert code == 200 and r["ready"] is True and r["run"]["classes"] == ["sedan", "suv", "van"]
|
||||
assert r["defaults"]["minAccuracy"] == 0.85 and "finetune" in r["modes"]
|
||||
assert call("GET", "/versions") == (200, {"versions": []})
|
||||
assert call("GET", "/health")[1]["busy"] is False
|
||||
assert readiness(Path("/nonexistent"))["ready"] is False
|
||||
|
||||
|
||||
def test_evaluate_job_runs_as_a_subprocess_and_is_recorded(api) -> None: # type: ignore[no-untyped-def]
|
||||
call, out = api
|
||||
code, job = call("POST", "/jobs", {"kind": "evaluate", "version": "nope"})
|
||||
assert code == 202 and job["status"] == "running" and job["kind"] == "evaluate"
|
||||
wait_idle(Handler.jobs)
|
||||
code, j = call("GET", f"/jobs/{job['id']}")
|
||||
assert code == 200 and j["status"] == "failed" and j["exitCode"] == 1
|
||||
assert "evaluate --data" in j["log"] and "nope" in j["log"]
|
||||
assert (out / "jobs" / f"{job['id']}.json").is_file() and (out / "jobs" / f"{job['id']}.log").is_file()
|
||||
code, lst = call("GET", "/jobs")
|
||||
assert code == 200 and lst["jobs"][0]["id"] == job["id"] and lst["current"] is None
|
||||
|
||||
|
||||
def test_bad_requests(api) -> None: # type: ignore[no-untyped-def]
|
||||
call, _ = api
|
||||
assert call("POST", "/jobs", {"kind": "nuke"})[0] == 400
|
||||
assert call("POST", "/jobs", {"kind": "train", "mode": "magic"})[0] == 400
|
||||
assert call("POST", "/jobs", {"kind": "evaluate", "version": "../etc"})[0] == 400
|
||||
assert call("POST", "/jobs", {"kind": "publish", "version": "v1", "url": "ftp://x"})[0] == 400
|
||||
assert call("GET", "/versions/../x/report")[0] == 400
|
||||
assert call("GET", "/versions/v9/report")[0] == 404
|
||||
assert call("GET", "/jobs/nope")[0] == 404
|
||||
assert call("GET", "/nothing")[0] == 404
|
||||
|
||||
|
||||
def test_train_job_then_versions_and_report(api) -> None: # type: ignore[no-untyped-def]
|
||||
pytest.importorskip("torch")
|
||||
call, out = api
|
||||
body = {"kind": "train", "mode": "features", "minAccuracy": 0.0, "epochs": 100, "version": "vapi"}
|
||||
# The test-only flags are not offered by the API; inject them via the CLI args the runner builds.
|
||||
orig = Jobs._argv
|
||||
|
||||
def patched(self, kind, a): # type: ignore[no-untyped-def]
|
||||
argv = orig(self, kind, a)
|
||||
return argv + ["--no-pretrained", "--input-size", "64", "--no-cache"] if kind == "train" else argv
|
||||
|
||||
Jobs._argv = patched # type: ignore[method-assign]
|
||||
try:
|
||||
code, job = call("POST", "/jobs", body)
|
||||
assert code == 202
|
||||
assert call("POST", "/jobs", {"kind": "evaluate", "version": "vapi"})[0] == 409 # one at a time
|
||||
wait_idle(Handler.jobs, 120)
|
||||
finally:
|
||||
Jobs._argv = orig # type: ignore[method-assign]
|
||||
code, j = call("GET", f"/jobs/{job['id']}")
|
||||
assert j["status"] == "done" and "MODEL WRITTEN" in j["log"]
|
||||
code, v = call("GET", "/versions")
|
||||
assert code == 200 and v["versions"][0]["version"] == "vapi" and v["versions"][0]["written"] is True
|
||||
assert v["versions"][0]["classes"] == ["sedan", "suv", "van"] and v["versions"][0]["accuracy"] >= 0.9
|
||||
code, report = call("GET", "/versions/vapi/report")
|
||||
assert code == 200 and report.startswith("# Body-type classifier vapi")
|
||||
assert versions(out)[0]["floor"] == 0.0
|
||||
# evaluate on the written model now succeeds
|
||||
code, job2 = call("POST", "/jobs", {"kind": "evaluate", "version": "vapi"})
|
||||
wait_idle(Handler.jobs)
|
||||
assert call("GET", f"/jobs/{job2['id']}")[1]["status"] == "done"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""The training job end to end on the synthetic volume — needs the `train` extra (torch);
|
||||
skipped where it is not installed (CI syncs without it, like the vision service)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
torch = pytest.importorskip("torch")
|
||||
|
||||
from trainer.cli import main # noqa: E402
|
||||
from trainer.infer import OnnxClassifier # noqa: E402
|
||||
from trainer.preprocess import Sidecar # noqa: E402
|
||||
|
||||
COMMON = ["--no-pretrained", "--input-size", "64", "--no-cache", "--seed", "3"]
|
||||
|
||||
|
||||
def test_features_run_writes_model_sidecar_report_and_evaluates(
|
||||
collector_dir: Path, tmp_path: Path, capsys
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
out = tmp_path / "out"
|
||||
rc = main(
|
||||
[
|
||||
"train",
|
||||
"--data",
|
||||
str(collector_dir),
|
||||
"--out",
|
||||
str(out),
|
||||
"--version",
|
||||
"vtest",
|
||||
"--mode",
|
||||
"features",
|
||||
"--epochs",
|
||||
"150",
|
||||
"--min-accuracy",
|
||||
"0.0",
|
||||
*COMMON,
|
||||
]
|
||||
)
|
||||
assert rc == 0
|
||||
d = out / "vtest"
|
||||
assert {p.name for p in d.iterdir()} == {"bodytype.onnx", "bodytype.json", "report.md", "metrics.json"}
|
||||
side = Sidecar.read(d / "bodytype.json")
|
||||
assert side.classes == ["sedan", "suv", "van"] and side.input_size == 64 and side.mode == "features"
|
||||
assert side.labels == {"train": 96, "val": 24} and side.metrics["floor"] == 0.0
|
||||
metrics = json.loads((d / "metrics.json").read_text())
|
||||
assert metrics["n"] == 24 and metrics["onnx_agreement"] == 1.0
|
||||
# Colour-coded classes: even a random backbone's pooled features separate them.
|
||||
assert metrics["accuracy"] >= 0.9
|
||||
report = (d / "report.md").read_text()
|
||||
assert (
|
||||
"MODEL WRITTEN" in report
|
||||
and "truck (5)" in report
|
||||
and "crop is missing on disk (skipped): 1" in report
|
||||
)
|
||||
|
||||
# The exported graph takes raw 0–255 RGB and answers by itself.
|
||||
clf = OnnxClassifier(d / "bodytype.onnx")
|
||||
probs, kept = clf.predict_files(
|
||||
[s for s in sorted((collector_dir / "crops" / "booth-2").glob("*.jpg"))][:6]
|
||||
)
|
||||
assert probs.shape == (6, 3) and kept == [0, 1, 2, 3, 4, 5]
|
||||
assert np.allclose(probs.sum(axis=1), 1.0, atol=1e-4)
|
||||
|
||||
# evaluate: labels reviewed after training (none — the fixture's reviews predate it) and the
|
||||
# unlabelled pile (6 entry samples).
|
||||
capsys.readouterr()
|
||||
assert main(["evaluate", "--data", str(collector_dir), "--model", str(d / "bodytype.onnx")]) == 0
|
||||
res = json.loads(capsys.readouterr().out)
|
||||
assert res["model"] == "vtest" and res["reviewedSince"] is None
|
||||
assert res["unlabelled"]["n"] == 6 and sum(res["unlabelled"]["predicted"].values()) == 6
|
||||
assert (
|
||||
main(
|
||||
[
|
||||
"evaluate",
|
||||
"--data",
|
||||
str(collector_dir),
|
||||
"--model",
|
||||
str(d / "bodytype.onnx"),
|
||||
"--since",
|
||||
"2026-09-01T00:00:00Z",
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
res2 = json.loads(capsys.readouterr().out)
|
||||
assert res2["reviewedSince"]["n"] == 125 - 5 # trucks are not a class the model knows
|
||||
|
||||
|
||||
def test_below_the_floor_writes_the_report_but_no_model(collector_dir: Path, tmp_path: Path) -> None:
|
||||
out = tmp_path / "out"
|
||||
rc = main(
|
||||
[
|
||||
"train",
|
||||
"--data",
|
||||
str(collector_dir),
|
||||
"--out",
|
||||
str(out),
|
||||
"--version",
|
||||
"vlow",
|
||||
"--mode",
|
||||
"features",
|
||||
"--epochs",
|
||||
"5",
|
||||
"--min-accuracy",
|
||||
"1.01",
|
||||
*COMMON,
|
||||
]
|
||||
)
|
||||
assert rc == 3
|
||||
d = out / "vlow"
|
||||
assert {p.name for p in d.iterdir()} == {"report.md", "metrics.json"}
|
||||
assert "MODEL NOT WRITTEN" in (d / "report.md").read_text()
|
||||
|
||||
|
||||
def test_not_enough_labels_is_exit_2(collector_dir: Path, tmp_path: Path) -> None:
|
||||
out = tmp_path / "out"
|
||||
rc = main(["train", "--data", str(collector_dir), "--out", str(out), "--min-per-class", "100", *COMMON])
|
||||
assert rc == 2
|
||||
assert not out.exists()
|
||||
|
||||
|
||||
def test_finetune_runs_and_uses_the_feature_cache(collector_dir: Path, tmp_path: Path) -> None:
|
||||
out = tmp_path / "out"
|
||||
args = [
|
||||
"train",
|
||||
"--data",
|
||||
str(collector_dir),
|
||||
"--out",
|
||||
str(out),
|
||||
"--mode",
|
||||
"finetune",
|
||||
"--backbone",
|
||||
"mobilenet_v3_small",
|
||||
"--epochs",
|
||||
"1",
|
||||
"--batch",
|
||||
"16",
|
||||
"--min-accuracy",
|
||||
"0.0",
|
||||
"--no-pretrained",
|
||||
"--input-size",
|
||||
"64",
|
||||
"--seed",
|
||||
"3",
|
||||
]
|
||||
assert main([*args, "--version", "vft"]) == 0
|
||||
cache = out / "cache" / "features-mobilenet_v3_small-64.npz"
|
||||
assert cache.exists()
|
||||
z = np.load(cache)
|
||||
assert len(z["ids"]) == 96 and z["feats"].shape == (96, 576)
|
||||
assert Sidecar.read(out / "vft" / "bodytype.json").mode == "finetune"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""parking-trainer — the phase-B body-type classifier job.
|
||||
|
||||
Reads the wash collector's SQLite + crops straight off its volume, splits by TIME, trains a
|
||||
small classifier on a pretrained backbone, and writes the ONNX model + sidecar + report —
|
||||
or refuses to write the model when validation is below the owner's floor.
|
||||
See wiki/decisions/bodytype-classifier-training.md.
|
||||
"""
|
||||
@@ -0,0 +1,414 @@
|
||||
"""parking-trainer — inspect / train / evaluate / publish.
|
||||
|
||||
parking-trainer inspect --data /data
|
||||
parking-trainer train --data /data --out /out [--mode features|finetune] [--min-accuracy 0.85]
|
||||
parking-trainer evaluate --model /out/<version>/bodytype.onnx --data /data
|
||||
parking-trainer publish /out/<version> --url https://<gitea>/api/packages/<owner>/generic/parking-bodytype
|
||||
|
||||
Exit codes: 0 ok · 2 not enough labels · 3 trained but below the floor (report written, model
|
||||
NOT written) · 1 anything else.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .data import (
|
||||
VEHICLE_CLASSES,
|
||||
class_weights,
|
||||
load_labelled,
|
||||
load_reviewed_since,
|
||||
load_unlabelled,
|
||||
make_split,
|
||||
suggested_epochs,
|
||||
summarise,
|
||||
)
|
||||
from .preprocess import CROP_MARGIN, Sidecar
|
||||
from .report import compute_metrics, render_report
|
||||
|
||||
MODEL_FILE = "bodytype.onnx"
|
||||
SIDECAR_FILE = "bodytype.json"
|
||||
REPORT_FILE = "report.md"
|
||||
METRICS_FILE = "metrics.json"
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[trainer] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# inspect
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_inspect(a: argparse.Namespace) -> int:
|
||||
samples, missing = load_labelled(a.data)
|
||||
split = make_split(samples, a.val_fraction, a.min_per_class)
|
||||
out = {
|
||||
"labelled": summarise(samples),
|
||||
"missingCrops": missing,
|
||||
"run": {
|
||||
"classes": list(split.classes),
|
||||
"train": split.counts("train"),
|
||||
"val": split.counts("val"),
|
||||
"dropped": split.dropped,
|
||||
"minPerClass": a.min_per_class,
|
||||
"valFraction": a.val_fraction,
|
||||
},
|
||||
"ready": len(split.classes) >= 2,
|
||||
}
|
||||
print(json.dumps(out, indent=2))
|
||||
return 0 if out["ready"] else 2
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# train
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_train(a: argparse.Namespace) -> int:
|
||||
try:
|
||||
from . import model as M
|
||||
except ImportError as exc: # torch missing
|
||||
_log(f"the training stack is not installed ({exc}); install with: uv sync --extra train")
|
||||
return 1
|
||||
import numpy as np
|
||||
|
||||
samples, missing = load_labelled(a.data)
|
||||
split = make_split(samples, a.val_fraction, a.min_per_class)
|
||||
if len(split.classes) < 2:
|
||||
_log(
|
||||
f"not enough labels: {len(samples)} usable, classes with >= {a.min_per_class}: "
|
||||
f"{list(split.classes)} (dropped {split.dropped}); nothing to train"
|
||||
)
|
||||
return 2
|
||||
version = a.version or datetime.now(timezone.utc).strftime("v%Y%m%d-%H%M")
|
||||
out_dir = a.out / version
|
||||
epochs = a.epochs or suggested_epochs(len(split.train), a.mode)
|
||||
weights = class_weights(split)
|
||||
idx = split.class_index
|
||||
_log(
|
||||
f"{version}: {a.mode} on {a.backbone}, classes {list(split.classes)}, "
|
||||
f"{len(split.train)} train / {len(split.val)} val, {epochs} epochs"
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
x_train, train = M.load_images(split.train, a.input_size)
|
||||
x_val, val = M.load_images(split.val, a.input_size)
|
||||
y_train = [idx[s.label] for s in train]
|
||||
y_val = [idx[s.label] for s in val]
|
||||
_log(f"decoded {len(train)} + {len(val)} crops in {time.perf_counter() - t0:.1f}s")
|
||||
if len(val) == 0 or len(set(y_train)) < 2:
|
||||
_log("not enough decodable crops on both sides of the split")
|
||||
return 2
|
||||
|
||||
backbone = M.build_backbone(a.backbone, pretrained=not a.no_pretrained)
|
||||
cache = (
|
||||
None if a.no_cache else M.FeatureCache(a.out / "cache" / f"features-{a.backbone}-{a.input_size}.npz")
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
f_train = M.features_for(backbone, train, x_train, cache)
|
||||
_log(
|
||||
f"features for {len(train)} train crops in {time.perf_counter() - t0:.1f}s "
|
||||
f"(cache: {cache.path if cache else 'off'})"
|
||||
)
|
||||
head_epochs = epochs if a.mode == "features" else max(30, epochs * 5)
|
||||
head = M.train_head(f_train, y_train, len(split.classes), weights, head_epochs, seed=a.seed)
|
||||
net = M.Classifier.make(backbone, head)
|
||||
|
||||
if a.mode == "finetune":
|
||||
t0 = time.perf_counter()
|
||||
net = M.finetune(
|
||||
net, x_train, y_train, weights, epochs, batch=a.batch, lr=a.lr, seed=a.seed, log=_log
|
||||
)
|
||||
_log(f"fine-tuned in {(time.perf_counter() - t0) / 60:.1f} min")
|
||||
|
||||
logits = M.predict_logits(net, x_val)
|
||||
y_pred = logits.argmax(axis=1).tolist()
|
||||
metrics = compute_metrics(split.classes, y_val, y_pred, camera=[s.vision_class for s in val])
|
||||
|
||||
# Export and check the graph gives the same answers as the torch model.
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_model = out_dir / (MODEL_FILE + ".tmp")
|
||||
M.export_onnx(net, a.input_size, tmp_model)
|
||||
from .infer import OnnxClassifier
|
||||
|
||||
sidecar = Sidecar(
|
||||
version=version,
|
||||
classes=list(split.classes),
|
||||
input_size=a.input_size,
|
||||
backbone=a.backbone,
|
||||
mode=a.mode,
|
||||
trained_at=_now(),
|
||||
labels={"train": len(train), "val": len(val)},
|
||||
)
|
||||
tmp_side = out_dir / (SIDECAR_FILE + ".tmp")
|
||||
sidecar.write(tmp_side)
|
||||
onnx_pred = OnnxClassifier(tmp_model, tmp_side).predict_inputs(x_val.astype(np.float32)).argmax(axis=1)
|
||||
metrics.onnx_agreement = float((onnx_pred == np.array(y_pred)).mean()) if len(y_pred) else None
|
||||
sidecar.metrics = {
|
||||
"accuracy": metrics.accuracy,
|
||||
"macroRecall": metrics.macro_recall,
|
||||
"perClass": {c: m.__dict__ for c, m in metrics.per_class.items()},
|
||||
"floor": a.min_accuracy,
|
||||
}
|
||||
|
||||
written = metrics.accuracy >= a.min_accuracy and (metrics.onnx_agreement or 0.0) >= 0.99
|
||||
notes = []
|
||||
if metrics.onnx_agreement is not None and metrics.onnx_agreement < 0.99:
|
||||
notes.append(
|
||||
f"ONNX export disagrees with the torch model ({metrics.onnx_agreement:.3f}); model withheld"
|
||||
)
|
||||
report = render_report(
|
||||
version=version,
|
||||
trained_at=sidecar.trained_at,
|
||||
mode=a.mode,
|
||||
backbone=a.backbone,
|
||||
epochs=epochs,
|
||||
classes=split.classes,
|
||||
train_counts=split.counts("train"),
|
||||
val_counts=split.counts("val"),
|
||||
dropped=split.dropped,
|
||||
missing_files=missing,
|
||||
weights=weights,
|
||||
metrics=metrics,
|
||||
min_accuracy=a.min_accuracy,
|
||||
written=written,
|
||||
notes=notes,
|
||||
)
|
||||
(out_dir / REPORT_FILE).write_text(report)
|
||||
(out_dir / METRICS_FILE).write_text(json.dumps(metrics.to_dict(), indent=2) + "\n")
|
||||
if written:
|
||||
sidecar.write(out_dir / SIDECAR_FILE)
|
||||
tmp_model.replace(out_dir / MODEL_FILE)
|
||||
tmp_side.unlink(missing_ok=True)
|
||||
_log(
|
||||
f"MODEL WRITTEN: {out_dir / MODEL_FILE} "
|
||||
f"(accuracy {metrics.accuracy:.3f} >= floor {a.min_accuracy})"
|
||||
)
|
||||
else:
|
||||
tmp_model.unlink(missing_ok=True)
|
||||
tmp_side.unlink(missing_ok=True)
|
||||
_log(
|
||||
f"MODEL NOT WRITTEN: accuracy {metrics.accuracy:.3f} < floor {a.min_accuracy}; "
|
||||
f"see {out_dir / REPORT_FILE}"
|
||||
)
|
||||
print(report)
|
||||
return 0 if written else 3
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# evaluate — an existing model against labels that arrived AFTER it was trained, and its
|
||||
# view of the unlabelled pile (the ongoing accuracy check without labelling everything)
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_evaluate(a: argparse.Namespace) -> int:
|
||||
from .infer import OnnxClassifier
|
||||
|
||||
clf = OnnxClassifier(a.model)
|
||||
since = a.since or clf.sidecar.trained_at
|
||||
classes = clf.classes
|
||||
result: dict[str, object] = {"model": clf.sidecar.version, "classes": classes, "since": since}
|
||||
|
||||
reviewed = [s for s in load_reviewed_since(a.data, since) if s.label in classes]
|
||||
if reviewed:
|
||||
probs, kept = clf.predict_files([s.path for s in reviewed])
|
||||
rows = [reviewed[i] for i in kept]
|
||||
y_true = [classes.index(s.label) for s in rows]
|
||||
y_pred = probs.argmax(axis=1).tolist()
|
||||
m = compute_metrics(classes, y_true, y_pred, camera=[s.vision_class for s in rows])
|
||||
result["reviewedSince"] = m.to_dict()
|
||||
else:
|
||||
result["reviewedSince"] = None
|
||||
|
||||
pending = load_unlabelled(a.data, a.limit)
|
||||
if pending:
|
||||
probs, kept = clf.predict_files([s.path for s in pending])
|
||||
rows = [pending[i] for i in kept]
|
||||
pred = probs.argmax(axis=1)
|
||||
conf = probs.max(axis=1)
|
||||
hist = {c: int((pred == i).sum()) for i, c in enumerate(classes)}
|
||||
result["unlabelled"] = {
|
||||
"n": len(rows),
|
||||
"predicted": hist,
|
||||
"meanConfidence": float(conf.mean()) if len(rows) else None,
|
||||
"belowHalf": int((conf < 0.5).sum()),
|
||||
"agreesWithDetector": float(
|
||||
sum(1 for p, s in zip(pred, rows, strict=True) if classes[int(p)] == s.vision_class)
|
||||
/ len(rows)
|
||||
)
|
||||
if rows
|
||||
else None,
|
||||
}
|
||||
else:
|
||||
result["unlabelled"] = None
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# publish — the versioned files to a Gitea generic package (weights are not code, they
|
||||
# do not live in git; the vision image fetches them by URL at build)
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_publish(a: argparse.Namespace) -> int:
|
||||
d: Path = a.dir
|
||||
files = [d / MODEL_FILE, d / SIDECAR_FILE, d / REPORT_FILE, d / METRICS_FILE]
|
||||
for f in files[:2]:
|
||||
if not f.exists():
|
||||
_log(f"{f} missing — nothing to publish (a run below the floor writes no model)")
|
||||
return 1
|
||||
version = Sidecar.read(d / SIDECAR_FILE).version
|
||||
if not a.url:
|
||||
_log("no publish url: pass --url or set TRAINER_PUBLISH_URL")
|
||||
return 1
|
||||
token = a.token or os.environ.get("TRAINER_PUBLISH_TOKEN", "")
|
||||
if not token:
|
||||
_log("no token: pass --token or set TRAINER_PUBLISH_TOKEN")
|
||||
return 1
|
||||
base = a.url.rstrip("/") + "/" + version
|
||||
for f in files:
|
||||
if not f.exists():
|
||||
continue
|
||||
req = urllib.request.Request(f"{base}/{f.name}", data=f.read_bytes(), method="PUT")
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
_log(f"PUT {base}/{f.name} → {r.status}")
|
||||
_log(f"published {version}; pin it in apps/vision/models/bodytype.version and rebuild the vision image")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_serve(a: argparse.Namespace) -> int:
|
||||
from .server import serve
|
||||
|
||||
serve(
|
||||
a.data,
|
||||
a.out,
|
||||
a.host,
|
||||
a.port,
|
||||
os.environ.get("TRAINER_PUBLISH_URL", ""),
|
||||
os.environ.get("TRAINER_PUBLISH_TOKEN", ""),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="parking-trainer", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
def data_args(sp: argparse.ArgumentParser) -> None:
|
||||
sp.add_argument(
|
||||
"--data",
|
||||
type=Path,
|
||||
default=Path(os.environ.get("TRAINER_DATA_DIR", "/data")),
|
||||
help="collector volume (collector.sqlite + crops/)",
|
||||
)
|
||||
|
||||
def split_args(sp: argparse.ArgumentParser) -> None:
|
||||
sp.add_argument(
|
||||
"--min-per-class",
|
||||
type=int,
|
||||
default=20,
|
||||
help="classes with fewer reviewed crops are dropped from the run",
|
||||
)
|
||||
sp.add_argument(
|
||||
"--val-fraction", type=float, default=0.2, help="newest fraction held out for validation"
|
||||
)
|
||||
|
||||
i = sub.add_parser("inspect", help="what a run would train on")
|
||||
data_args(i)
|
||||
split_args(i)
|
||||
i.set_defaults(fn=cmd_inspect)
|
||||
|
||||
t = sub.add_parser("train", help="train, evaluate, export (or refuse)")
|
||||
data_args(t)
|
||||
split_args(t)
|
||||
t.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=Path(os.environ.get("TRAINER_OUT_DIR", "/out")),
|
||||
help="output root; a <version>/ folder is created under it",
|
||||
)
|
||||
t.add_argument("--mode", choices=["features", "finetune"], default="features")
|
||||
t.add_argument(
|
||||
"--backbone", choices=["resnet18", "mobilenet_v3_small", "efficientnet_b0"], default="resnet18"
|
||||
)
|
||||
t.add_argument("--epochs", type=int, default=0, help="0 = pick from the data size")
|
||||
t.add_argument("--batch", type=int, default=32)
|
||||
t.add_argument("--lr", type=float, default=1e-4, help="fine-tune learning rate")
|
||||
t.add_argument("--input-size", type=int, default=224)
|
||||
t.add_argument(
|
||||
"--min-accuracy", type=float, default=0.85, help="validation floor below which NO model is written"
|
||||
)
|
||||
t.add_argument("--version", default="", help="model version (default v<date>-<time>)")
|
||||
t.add_argument("--seed", type=int, default=7)
|
||||
t.add_argument(
|
||||
"--no-pretrained", action="store_true", help="random init (tests only — never for a real run)"
|
||||
)
|
||||
t.add_argument("--no-cache", action="store_true", help="do not read/write the feature cache")
|
||||
t.set_defaults(fn=cmd_train)
|
||||
|
||||
e = sub.add_parser(
|
||||
"evaluate", help="an existing model vs labels reviewed after it was trained + the unlabelled pile"
|
||||
)
|
||||
data_args(e)
|
||||
e.add_argument(
|
||||
"--model", type=Path, required=True, help="path to bodytype.onnx (sidecar .json beside it)"
|
||||
)
|
||||
e.add_argument("--since", default="", help="ISO time; default = the model's trained_at")
|
||||
e.add_argument(
|
||||
"--limit", type=int, default=2000, help="how many unlabelled crops to score (newest first)"
|
||||
)
|
||||
e.set_defaults(fn=cmd_evaluate)
|
||||
|
||||
u = sub.add_parser("publish", help="PUT a version folder to a Gitea generic package")
|
||||
u.add_argument("dir", type=Path, help="the <version>/ folder a passing run wrote")
|
||||
u.add_argument(
|
||||
"--url",
|
||||
default=os.environ.get("TRAINER_PUBLISH_URL", ""),
|
||||
help="https://<gitea>/api/packages/<owner>/generic/parking-bodytype (or TRAINER_PUBLISH_URL)",
|
||||
)
|
||||
u.add_argument("--token", default="", help="Gitea token with package:write (or TRAINER_PUBLISH_TOKEN)")
|
||||
u.set_defaults(fn=cmd_publish)
|
||||
|
||||
s = sub.add_parser("serve", help="the job API the collector's Training section talks to")
|
||||
data_args(s)
|
||||
s.add_argument("--out", type=Path, default=Path(os.environ.get("TRAINER_OUT_DIR", "/out")))
|
||||
s.add_argument("--host", default=os.environ.get("TRAINER_HOST", "0.0.0.0"))
|
||||
s.add_argument("--port", type=int, default=int(os.environ.get("TRAINER_PORT", "8091")))
|
||||
s.set_defaults(fn=cmd_serve)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
a = build_parser().parse_args(argv)
|
||||
try:
|
||||
return int(a.fn(a))
|
||||
except FileNotFoundError as exc:
|
||||
_log(str(exc))
|
||||
return 1
|
||||
|
||||
|
||||
__all__ = ["main", "build_parser", "VEHICLE_CLASSES", "CROP_MARGIN"]
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,179 @@
|
||||
"""The training set: reviewed, usable rows off the collector's SQLite, and how they are
|
||||
split and weighed. Pure Python + sqlite3 — no torch, so `inspect` and the tests run light.
|
||||
|
||||
Rules (wiki/decisions/bodytype-classifier-training.md):
|
||||
- Only rows a REVIEWER labelled count; the operator's pick and the camera's class are
|
||||
never labels. `unusable` rows are dropped.
|
||||
- Split by TIME (`at` = when the vehicle was seen): validation = the newest slice, so
|
||||
the number reflects tomorrow's traffic rather than a random shuffle of the same days.
|
||||
- Classes with too few labels are dropped from the run (and reported), never trained
|
||||
on a handful of examples that would make the softmax confidently wrong.
|
||||
- Class imbalance is weighed in the loss (inverse frequency, damped) and reported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# The shared vocabulary (packages/shared VEHICLE_CLASSES) — the only labels a reviewer can
|
||||
# give, and the only classes a model may emit. Order is the canonical one; the model's own
|
||||
# class list (sidecar) is the subset it trained on, in this order.
|
||||
VEHICLE_CLASSES: tuple[str, ...] = (
|
||||
"car",
|
||||
"sedan",
|
||||
"hatchback",
|
||||
"suv",
|
||||
"minivan",
|
||||
"pickup",
|
||||
"van",
|
||||
"truck",
|
||||
"bus",
|
||||
"motorcycle",
|
||||
)
|
||||
|
||||
DB_FILE = "collector.sqlite"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sample:
|
||||
item: str
|
||||
booth: str
|
||||
kind: str # wash | entry
|
||||
at: str # ISO-8601, when the vehicle was seen (the split key)
|
||||
label: str # the reviewer's class
|
||||
vision_class: str # what the detector said (for the report, never a label)
|
||||
path: Path # the crop on disk
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Split:
|
||||
classes: tuple[str, ...]
|
||||
train: list[Sample]
|
||||
val: list[Sample]
|
||||
dropped: dict[str, int] # class → count, below the per-class minimum
|
||||
missing_files: int # labelled rows whose crop is not on disk
|
||||
|
||||
@property
|
||||
def class_index(self) -> dict[str, int]:
|
||||
return {c: i for i, c in enumerate(self.classes)}
|
||||
|
||||
def counts(self, part: str) -> dict[str, int]:
|
||||
rows = self.train if part == "train" else self.val
|
||||
c = Counter(s.label for s in rows)
|
||||
return {k: c.get(k, 0) for k in self.classes}
|
||||
|
||||
|
||||
_SELECT = "SELECT id, booth, kind, at, review_label, vision_class, image_path FROM items "
|
||||
|
||||
|
||||
def _rows(
|
||||
data_dir: Path, where: str, params: tuple[object, ...], db_file: Path | None = None
|
||||
) -> list[Sample]:
|
||||
db = db_file or data_dir / DB_FILE
|
||||
if not db.exists():
|
||||
raise FileNotFoundError(f"collector database not found: {db}")
|
||||
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
try:
|
||||
rows = con.execute(_SELECT + where, params).fetchall()
|
||||
finally:
|
||||
con.close()
|
||||
out: list[Sample] = []
|
||||
for item, booth, kind, at, label, vision_class, image_path in rows:
|
||||
out.append(Sample(item, booth, kind, at, label or "", vision_class, data_dir / image_path))
|
||||
return out
|
||||
|
||||
|
||||
def load_labelled(data_dir: Path, db_file: Path | None = None) -> tuple[list[Sample], int]:
|
||||
"""Every reviewed, usable row with its crop path resolved. Returns (samples, missing)
|
||||
where `missing` counts rows whose crop file is gone (pruned/moved) — skipped."""
|
||||
rows = _rows(
|
||||
data_dir,
|
||||
"WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY at, id",
|
||||
(),
|
||||
db_file,
|
||||
)
|
||||
out: list[Sample] = []
|
||||
missing = 0
|
||||
for s in rows:
|
||||
if s.label not in VEHICLE_CLASSES:
|
||||
continue # a label outside the vocabulary can only be a future/foreign row
|
||||
if not s.path.is_file():
|
||||
missing += 1
|
||||
continue
|
||||
out.append(s)
|
||||
return out, missing
|
||||
|
||||
|
||||
def load_reviewed_since(data_dir: Path, since: str, db_file: Path | None = None) -> list[Sample]:
|
||||
"""Usable labels whose REVIEW happened after `since` (ISO) — a clean held-out check for a
|
||||
model trained before then. Rows whose crop is gone are skipped."""
|
||||
rows = _rows(
|
||||
data_dir,
|
||||
"WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' AND reviewed_at > ? "
|
||||
"ORDER BY reviewed_at, id",
|
||||
(since,),
|
||||
db_file,
|
||||
)
|
||||
return [s for s in rows if s.label in VEHICLE_CLASSES and s.path.is_file()]
|
||||
|
||||
|
||||
def load_unlabelled(data_dir: Path, limit: int = 2000, db_file: Path | None = None) -> list[Sample]:
|
||||
"""The pending pile, newest first — what the model would say about traffic nobody has
|
||||
labelled (its class histogram and detector agreement are the cheap drift check)."""
|
||||
rows = _rows(data_dir, "WHERE reviewed_at IS NULL ORDER BY received_at DESC LIMIT ?", (limit,), db_file)
|
||||
return [s for s in rows if s.path.is_file()]
|
||||
|
||||
|
||||
def make_split(samples: list[Sample], val_fraction: float = 0.2, min_per_class: int = 20) -> Split:
|
||||
"""Drop thin classes, then cut by time: the newest `val_fraction` is validation."""
|
||||
if not 0.0 < val_fraction < 1.0:
|
||||
raise ValueError("val_fraction must be in (0, 1)")
|
||||
counts = Counter(s.label for s in samples)
|
||||
kept = tuple(c for c in VEHICLE_CLASSES if counts.get(c, 0) >= min_per_class)
|
||||
dropped = {c: n for c, n in counts.items() if c not in kept}
|
||||
rows = sorted((s for s in samples if s.label in kept), key=lambda s: (s.at, s.item))
|
||||
n_val = int(round(len(rows) * val_fraction))
|
||||
if rows and n_val == 0:
|
||||
n_val = 1
|
||||
cut = len(rows) - n_val
|
||||
return Split(classes=kept, train=rows[:cut], val=rows[cut:], dropped=dropped, missing_files=0)
|
||||
|
||||
|
||||
def class_weights(split: Split, damping: float = 0.5) -> list[float]:
|
||||
"""Inverse-frequency weights for the loss, damped by `damping` (0.5 = square root, so a
|
||||
1:9 imbalance becomes 1:3 rather than 1:9 — full inverse weights over-correct on small
|
||||
sets). Normalised to mean 1 so the learning rate keeps its meaning."""
|
||||
counts = split.counts("train")
|
||||
total = sum(counts.values())
|
||||
k = len(split.classes)
|
||||
raw = [(total / (k * max(1, counts[c]))) ** damping for c in split.classes]
|
||||
mean = sum(raw) / max(1, len(raw))
|
||||
return [w / mean for w in raw]
|
||||
|
||||
|
||||
def summarise(samples: list[Sample]) -> dict[str, object]:
|
||||
"""What `inspect` prints: per-class counts, per-booth counts, time range."""
|
||||
by_class = Counter(s.label for s in samples)
|
||||
by_booth = Counter(s.booth for s in samples)
|
||||
by_kind = Counter(s.kind for s in samples)
|
||||
ats = sorted(s.at for s in samples)
|
||||
return {
|
||||
"total": len(samples),
|
||||
"byClass": {c: by_class.get(c, 0) for c in VEHICLE_CLASSES if by_class.get(c, 0)},
|
||||
"byBooth": dict(sorted(by_booth.items())),
|
||||
"byKind": dict(sorted(by_kind.items())),
|
||||
"from": ats[0] if ats else None,
|
||||
"to": ats[-1] if ats else None,
|
||||
}
|
||||
|
||||
|
||||
def suggested_epochs(n_train: int, mode: str) -> int:
|
||||
"""A sane default when the owner gives none: enough passes for a small set, fewer as
|
||||
it grows. Feature-extraction heads converge fast; fine-tunes need more but cost more."""
|
||||
if mode == "features":
|
||||
return int(min(200, max(30, 4000 / max(1, n_train) * 10)))
|
||||
return int(min(30, max(8, math.ceil(3000 / max(1, n_train)) * 4)))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Run an exported classifier (ONNX + sidecar) — torch-free. Used by `evaluate` and by
|
||||
the tests; the vision service carries its own, equivalent, reader (vehicle.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .preprocess import Sidecar, load_input, softmax
|
||||
|
||||
|
||||
class OnnxClassifier:
|
||||
def __init__(self, model_path: Path, sidecar_path: Path | None = None) -> None:
|
||||
import onnxruntime as ort
|
||||
|
||||
self.model_path = Path(model_path)
|
||||
self.sidecar = Sidecar.read(sidecar_path or self.model_path.with_suffix(".json"))
|
||||
opts = ort.SessionOptions()
|
||||
opts.intra_op_num_threads = 2
|
||||
self._session = ort.InferenceSession(
|
||||
str(self.model_path), sess_options=opts, providers=["CPUExecutionProvider"]
|
||||
)
|
||||
self._input = self._session.get_inputs()[0].name
|
||||
|
||||
@property
|
||||
def classes(self) -> list[str]:
|
||||
return list(self.sidecar.classes)
|
||||
|
||||
def predict_inputs(self, x: Any, batch: int = 64) -> Any:
|
||||
"""[N,3,S,S] float32 → probabilities [N,K]."""
|
||||
import numpy as np
|
||||
|
||||
outs = []
|
||||
for i in range(0, len(x), batch):
|
||||
logits = self._session.run(None, {self._input: x[i : i + batch]})[0]
|
||||
outs.append(softmax(logits))
|
||||
return np.concatenate(outs, axis=0) if outs else np.zeros((0, len(self.classes)), np.float32)
|
||||
|
||||
def predict_files(self, paths: list[Path], batch: int = 64) -> tuple[Any, list[int]]:
|
||||
"""Decode + classify crop files. Returns (probs, indices of paths that decoded)."""
|
||||
import numpy as np
|
||||
|
||||
xs, kept = [], []
|
||||
for i, p in enumerate(paths):
|
||||
x = load_input(p, self.sidecar.input_size)
|
||||
if x is not None:
|
||||
xs.append(x)
|
||||
kept.append(i)
|
||||
if not xs:
|
||||
return np.zeros((0, len(self.classes)), np.float32), []
|
||||
return self.predict_inputs(np.stack(xs), batch), kept
|
||||
@@ -0,0 +1,305 @@
|
||||
"""The network and the two ways of training it. Imports torch — only `train` reaches here;
|
||||
everything else in the package stays torch-free (the `train` extra is heavy).
|
||||
|
||||
Backbone: a small ImageNet-pretrained torchvision model (BSD-3, weights included), used as
|
||||
a feature extractor; head: one linear layer over its pooled features.
|
||||
|
||||
- "features" mode: the backbone is FROZEN. Every crop goes through it once, the vectors
|
||||
are cached on disk (keyed by item id), and only the head is trained — minutes for a few
|
||||
thousand crops, seconds to retrain when new labels arrive. Expected to carry most of
|
||||
the accuracy on frontal gate views.
|
||||
- "finetune" mode: warm-starts the head the same way, then unfreezes everything and trains
|
||||
end to end with light augmentation. Roughly an hour on four Xeon cores for a few
|
||||
thousand crops with a mobile-sized backbone; the step when the cheap mode plateaus.
|
||||
|
||||
The exported ONNX graph takes raw RGB 0–255 float pixels and normalises INSIDE
|
||||
(see preprocess.py), so the vision service cannot get the constants wrong.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .data import Sample
|
||||
from .preprocess import IMAGENET_MEAN, IMAGENET_STD, load_input
|
||||
|
||||
BACKBONES: dict[str, int] = {"resnet18": 512, "mobilenet_v3_small": 576, "efficientnet_b0": 1280}
|
||||
|
||||
|
||||
def _torch() -> Any:
|
||||
import torch
|
||||
|
||||
torch.set_num_threads(max(1, os.cpu_count() or 1))
|
||||
return torch
|
||||
|
||||
|
||||
def build_backbone(name: str, pretrained: bool = True) -> Any:
|
||||
"""torchvision model with its classifier removed → pooled feature vector."""
|
||||
torch = _torch()
|
||||
import torchvision.models as tvm
|
||||
|
||||
if name not in BACKBONES:
|
||||
raise ValueError(f"unknown backbone {name!r} (choose from {', '.join(BACKBONES)})")
|
||||
if name == "resnet18":
|
||||
m = tvm.resnet18(weights=tvm.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None)
|
||||
m.fc = torch.nn.Identity()
|
||||
elif name == "mobilenet_v3_small":
|
||||
m = tvm.mobilenet_v3_small(
|
||||
weights=tvm.MobileNet_V3_Small_Weights.IMAGENET1K_V1 if pretrained else None
|
||||
)
|
||||
m.classifier = torch.nn.Identity()
|
||||
else:
|
||||
m = tvm.efficientnet_b0(weights=tvm.EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None)
|
||||
m.classifier = torch.nn.Identity()
|
||||
return m
|
||||
|
||||
|
||||
class Classifier: # a factory, not a Module subclass at import time (torch is lazy)
|
||||
@staticmethod
|
||||
def make(backbone: Any, head: Any) -> Any:
|
||||
torch = _torch()
|
||||
|
||||
class _Net(torch.nn.Module): # type: ignore[misc,name-defined]
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.backbone = backbone
|
||||
self.head = head
|
||||
self.register_buffer("mean", torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1))
|
||||
self.register_buffer("std", torch.tensor(IMAGENET_STD).view(1, 3, 1, 1))
|
||||
|
||||
def forward(self, x: Any) -> Any:
|
||||
x = (x / 255.0 - self.mean) / self.std
|
||||
return self.head(self.backbone(x))
|
||||
|
||||
return _Net()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# Images in memory (uint8 — a few thousand 224² crops is a few hundred MB; float32 would
|
||||
# be four times that)
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_images(samples: list[Sample], input_size: int) -> tuple[Any, list[Sample]]:
|
||||
"""Decode + resize every crop once. Returns (uint8 [N,3,S,S], the samples that decoded)."""
|
||||
import numpy as np
|
||||
|
||||
xs, kept = [], []
|
||||
for s in samples:
|
||||
x = load_input(s.path, input_size)
|
||||
if x is None:
|
||||
continue
|
||||
xs.append(x.astype(np.uint8))
|
||||
kept.append(s)
|
||||
if not xs:
|
||||
return np.zeros((0, 3, input_size, input_size), np.uint8), []
|
||||
return np.stack(xs), kept
|
||||
|
||||
|
||||
def _batches(n: int, batch: int) -> list[slice]:
|
||||
return [slice(i, min(n, i + batch)) for i in range(0, n, batch)]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# Feature extraction (+ on-disk cache) and the head
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureCache:
|
||||
"""`<cache_dir>/features-<backbone>-<size>.npz`: item ids + vectors. Retraining the head
|
||||
after new labels arrive only runs the backbone on the NEW crops."""
|
||||
|
||||
path: Path
|
||||
|
||||
def load(self) -> dict[str, Any]:
|
||||
import numpy as np
|
||||
|
||||
if not self.path.exists():
|
||||
return {}
|
||||
z = np.load(self.path, allow_pickle=False)
|
||||
return dict(zip(z["ids"].tolist(), z["feats"], strict=True))
|
||||
|
||||
def save(self, table: dict[str, Any]) -> None:
|
||||
import numpy as np
|
||||
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ids = np.array(list(table), dtype=str)
|
||||
feats = np.stack(list(table.values())) if table else np.zeros((0, 0), np.float32)
|
||||
np.savez(self.path, ids=ids, feats=feats)
|
||||
|
||||
|
||||
def extract_features(backbone: Any, x_u8: Any, batch: int = 64) -> Any:
|
||||
"""Frozen forward pass → [N,D] float32 (normalisation applied here, as in the graph)."""
|
||||
torch = _torch()
|
||||
import numpy as np
|
||||
|
||||
net = Classifier.make(backbone, torch.nn.Identity()).eval()
|
||||
out = []
|
||||
with torch.no_grad():
|
||||
for sl in _batches(len(x_u8), batch):
|
||||
xb = torch.from_numpy(x_u8[sl]).float()
|
||||
out.append(net(xb).numpy())
|
||||
return np.concatenate(out, axis=0) if out else np.zeros((0, 0), np.float32)
|
||||
|
||||
|
||||
def features_for(
|
||||
backbone: Any, samples: list[Sample], x_u8: Any, cache: FeatureCache | None, batch: int = 64
|
||||
) -> Any:
|
||||
"""Feature vectors for `samples` (aligned with x_u8), from the cache where present."""
|
||||
import numpy as np
|
||||
|
||||
table = cache.load() if cache else {}
|
||||
todo = [i for i, s in enumerate(samples) if s.item not in table]
|
||||
if todo:
|
||||
fresh = extract_features(backbone, x_u8[todo], batch)
|
||||
for i, f in zip(todo, fresh, strict=True):
|
||||
table[samples[i].item] = f.astype(np.float32)
|
||||
if cache:
|
||||
cache.save(table)
|
||||
return np.stack([table[s.item] for s in samples]) if samples else np.zeros((0, 0), np.float32)
|
||||
|
||||
|
||||
def train_head(
|
||||
feats: Any,
|
||||
y: list[int],
|
||||
n_classes: int,
|
||||
weights: list[float],
|
||||
epochs: int,
|
||||
lr: float = 1e-3,
|
||||
seed: int = 7,
|
||||
) -> Any:
|
||||
"""Multinomial logistic regression on cached features (full batch, Adam, weighted CE)."""
|
||||
torch = _torch()
|
||||
torch.manual_seed(seed)
|
||||
f = torch.from_numpy(feats).float()
|
||||
t = torch.tensor(y, dtype=torch.long)
|
||||
head = torch.nn.Linear(f.shape[1], n_classes)
|
||||
opt = torch.optim.Adam(head.parameters(), lr=lr, weight_decay=1e-4)
|
||||
loss_fn = torch.nn.CrossEntropyLoss(weight=torch.tensor(weights, dtype=torch.float32))
|
||||
head.train()
|
||||
for _ in range(epochs):
|
||||
opt.zero_grad()
|
||||
loss = loss_fn(head(f), t)
|
||||
loss.backward()
|
||||
opt.step()
|
||||
return head.eval()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# Full fine-tune
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _augment(xb: Any) -> Any:
|
||||
"""Light, label-preserving augmentation on a float batch [B,3,S,S] (0–255): horizontal
|
||||
flip (a gate view mirrored is still the same body type), a mild random zoom, and
|
||||
brightness/contrast jitter (dusk, headlights, wet tarmac)."""
|
||||
torch = _torch()
|
||||
b, _, s, _ = xb.shape
|
||||
flip = torch.rand(b) < 0.5
|
||||
xb = torch.where(flip.view(b, 1, 1, 1), xb.flip(-1), xb)
|
||||
# zoom: crop a random 85–100 % window and resize back
|
||||
out = torch.empty_like(xb)
|
||||
for i in range(b):
|
||||
frac = float(torch.empty(1).uniform_(0.85, 1.0))
|
||||
w = max(8, int(s * frac))
|
||||
x0 = int(torch.randint(0, s - w + 1, (1,)))
|
||||
y0 = int(torch.randint(0, s - w + 1, (1,)))
|
||||
crop = xb[i : i + 1, :, y0 : y0 + w, x0 : x0 + w]
|
||||
out[i : i + 1] = torch.nn.functional.interpolate(
|
||||
crop, size=(s, s), mode="bilinear", align_corners=False
|
||||
)
|
||||
bright = torch.empty(b, 1, 1, 1).uniform_(-25, 25)
|
||||
contrast = torch.empty(b, 1, 1, 1).uniform_(0.8, 1.2)
|
||||
mean = out.mean(dim=(1, 2, 3), keepdim=True)
|
||||
out = (out - mean) * contrast + mean + bright
|
||||
return out.clamp_(0, 255)
|
||||
|
||||
|
||||
def finetune(
|
||||
model: Any,
|
||||
x_u8: Any,
|
||||
y: list[int],
|
||||
weights: list[float],
|
||||
epochs: int,
|
||||
batch: int = 32,
|
||||
lr: float = 1e-4,
|
||||
seed: int = 7,
|
||||
log: Any = None,
|
||||
) -> Any:
|
||||
torch = _torch()
|
||||
import numpy as np
|
||||
|
||||
torch.manual_seed(seed)
|
||||
rng = np.random.default_rng(seed)
|
||||
t = torch.tensor(y, dtype=torch.long)
|
||||
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-2)
|
||||
steps = epochs * max(1, (len(y) + batch - 1) // batch)
|
||||
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=max(1, steps), pct_start=0.15)
|
||||
loss_fn = torch.nn.CrossEntropyLoss(weight=torch.tensor(weights, dtype=torch.float32))
|
||||
for epoch in range(epochs):
|
||||
model.train()
|
||||
order = rng.permutation(len(y))
|
||||
total = 0.0
|
||||
for sl in _batches(len(y), batch):
|
||||
idx = order[sl]
|
||||
xb = _augment(torch.from_numpy(x_u8[idx]).float())
|
||||
opt.zero_grad()
|
||||
loss = loss_fn(model(xb), t[idx])
|
||||
loss.backward()
|
||||
opt.step()
|
||||
sched.step()
|
||||
total += float(loss.detach()) * len(idx)
|
||||
if log:
|
||||
log(f"epoch {epoch + 1}/{epochs} loss {total / max(1, len(y)):.4f}")
|
||||
return model.eval()
|
||||
|
||||
|
||||
def predict_logits(model: Any, x_u8: Any, batch: int = 64) -> Any:
|
||||
torch = _torch()
|
||||
import numpy as np
|
||||
|
||||
model.eval()
|
||||
out = []
|
||||
with torch.no_grad():
|
||||
for sl in _batches(len(x_u8), batch):
|
||||
out.append(model(torch.from_numpy(x_u8[sl]).float()).numpy())
|
||||
return np.concatenate(out, axis=0) if out else np.zeros((0, 0), np.float32)
|
||||
|
||||
|
||||
def export_onnx(model: Any, input_size: int, path: Path) -> None:
|
||||
"""Export with the current (torch.export-based) exporter; fall back to the legacy
|
||||
TorchScript one where the new path is unavailable or trips over an op. Whichever wrote
|
||||
the graph, the job then checks it against the torch model (onnx_agreement) before the
|
||||
file is kept."""
|
||||
torch = _torch()
|
||||
|
||||
model.eval()
|
||||
dummy = torch.zeros(1, 3, input_size, input_size)
|
||||
names: dict[str, Any] = dict(input_names=["image"], output_names=["logits"])
|
||||
try:
|
||||
torch.onnx.export(
|
||||
model,
|
||||
(dummy,),
|
||||
str(path),
|
||||
dynamo=True,
|
||||
dynamic_shapes={"x": {0: "batch"}},
|
||||
opset_version=18,
|
||||
external_data=False, # ONE file: the vision image bakes bodytype.onnx + .json, nothing else
|
||||
verbose=False,
|
||||
**names,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - any failure → the legacy exporter
|
||||
torch.onnx.export(
|
||||
model,
|
||||
dummy,
|
||||
str(path),
|
||||
dynamo=False,
|
||||
dynamic_axes={"image": {0: "batch"}, "logits": {0: "batch"}},
|
||||
opset_version=17,
|
||||
**names,
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Crop → model input. THE CONTRACT between the trainer and the vision service's classifier
|
||||
stage: what the network sees at training time must be exactly what it sees on the booth.
|
||||
|
||||
The trainer does not share code with the vision service (different packages, different
|
||||
images), so the contract is DATA: every constant here is written into the model's sidecar
|
||||
(`bodytype.json`) and the vision side reads and applies them from there — nothing is
|
||||
assumed on either side. Both use OpenCV with the same interpolation so the pixels match.
|
||||
|
||||
- input: the collector's crop (the detector's vehicle box + margin, plate blurred), or on
|
||||
the booth the same cut made live from the frame (vehicle.py mirrors `makeReviewCrop`).
|
||||
- resize: squash to input_size × input_size with INTER_AREA (the crop IS the vehicle; no
|
||||
centre-crop that would lose a bumper or a roofline — the shape is the signal).
|
||||
- colour: RGB, float32, 0–255. Normalisation (/255, ImageNet mean/std) lives INSIDE the
|
||||
ONNX graph, so a consumer feeds raw pixels and cannot get the constants wrong.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SIDECAR_FORMAT = "parking-bodytype/1"
|
||||
IMAGENET_MEAN = (0.485, 0.456, 0.406)
|
||||
IMAGENET_STD = (0.229, 0.224, 0.225)
|
||||
CROP_MARGIN = 0.08 # must equal CROP_MARGIN in apps/server review-outbox.ts
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sidecar:
|
||||
"""`bodytype.json` beside `bodytype.onnx`."""
|
||||
|
||||
version: str
|
||||
classes: list[str]
|
||||
input_size: int = 224
|
||||
color: str = "rgb"
|
||||
resize: str = "area"
|
||||
crop_margin: float = CROP_MARGIN
|
||||
normalization: str = "in-graph" # the ONNX divides by 255 and applies mean/std itself
|
||||
mean: list[float] = field(default_factory=lambda: list(IMAGENET_MEAN))
|
||||
std: list[float] = field(default_factory=lambda: list(IMAGENET_STD))
|
||||
backbone: str = ""
|
||||
mode: str = ""
|
||||
trained_at: str = ""
|
||||
labels: dict[str, int] = field(default_factory=dict) # train / val counts
|
||||
metrics: dict[str, Any] = field(default_factory=dict) # accuracy, macro, per-class
|
||||
format: str = SIDECAR_FORMAT
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
path.write_text(json.dumps(asdict(self), indent=2) + "\n")
|
||||
|
||||
@classmethod
|
||||
def read(cls, path: Path) -> Sidecar:
|
||||
d = json.loads(path.read_text())
|
||||
if d.get("format") != SIDECAR_FORMAT:
|
||||
raise ValueError(f"{path}: unknown sidecar format {d.get('format')!r}")
|
||||
known = {f for f in cls.__dataclass_fields__}
|
||||
return cls(**{k: v for k, v in d.items() if k in known})
|
||||
|
||||
|
||||
def load_input(path: Path, input_size: int) -> Any:
|
||||
"""Decode a crop and produce the network input: RGB float32 CHW, 0–255, squashed to
|
||||
input_size. Returns None when the file cannot be decoded."""
|
||||
import cv2
|
||||
|
||||
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
return None
|
||||
return array_to_input(img, input_size)
|
||||
|
||||
|
||||
def array_to_input(bgr: Any, input_size: int) -> Any:
|
||||
"""BGR uint8 HWC (OpenCV's native) → RGB float32 CHW 0–255 at input_size."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
resized = cv2.resize(bgr, (input_size, input_size), interpolation=cv2.INTER_AREA)
|
||||
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
|
||||
return np.ascontiguousarray(rgb.transpose(2, 0, 1).astype(np.float32))
|
||||
|
||||
|
||||
def softmax(logits: Any) -> Any:
|
||||
import numpy as np
|
||||
|
||||
z = logits - logits.max(axis=-1, keepdims=True)
|
||||
e = np.exp(z)
|
||||
return e / e.sum(axis=-1, keepdims=True)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Metrics + the human-readable report. The owner reads this BEFORE anything ships; the
|
||||
floor decision is made on these numbers. Pure Python, no torch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassMetrics:
|
||||
support: int
|
||||
recall: float # of the true members, how many the model caught
|
||||
precision: float # of the model's picks, how many were right
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metrics:
|
||||
n: int
|
||||
accuracy: float
|
||||
macro_recall: float
|
||||
per_class: dict[str, ClassMetrics]
|
||||
confusion: list[list[int]] # rows = true class, cols = predicted, in `classes` order
|
||||
camera_agreement: float | None = None # how often the model equals the detector's class
|
||||
onnx_agreement: float | None = None # exported graph vs the torch model, argmax
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def compute_metrics(
|
||||
classes: tuple[str, ...] | list[str],
|
||||
y_true: list[int],
|
||||
y_pred: list[int],
|
||||
camera: list[str] | None = None,
|
||||
) -> Metrics:
|
||||
k = len(classes)
|
||||
conf = [[0] * k for _ in range(k)]
|
||||
for t, p in zip(y_true, y_pred, strict=True):
|
||||
conf[t][p] += 1
|
||||
per: dict[str, ClassMetrics] = {}
|
||||
recalls: list[float] = []
|
||||
for i, c in enumerate(classes):
|
||||
support = sum(conf[i])
|
||||
tp = conf[i][i]
|
||||
picked = sum(conf[r][i] for r in range(k))
|
||||
recall = tp / support if support else 0.0
|
||||
precision = tp / picked if picked else 0.0
|
||||
per[c] = ClassMetrics(support=support, recall=recall, precision=precision)
|
||||
if support:
|
||||
recalls.append(recall)
|
||||
n = len(y_true)
|
||||
acc = sum(1 for t, p in zip(y_true, y_pred, strict=True) if t == p) / n if n else 0.0
|
||||
agree = None
|
||||
if camera is not None and n:
|
||||
agree = sum(1 for p, cam in zip(y_pred, camera, strict=True) if classes[p] == cam) / n
|
||||
return Metrics(
|
||||
n=n,
|
||||
accuracy=acc,
|
||||
macro_recall=sum(recalls) / len(recalls) if recalls else 0.0,
|
||||
per_class=per,
|
||||
confusion=conf,
|
||||
camera_agreement=agree,
|
||||
)
|
||||
|
||||
|
||||
def _pct(x: float | None) -> str:
|
||||
return "—" if x is None else f"{x * 100:.1f} %"
|
||||
|
||||
|
||||
def render_report(
|
||||
*,
|
||||
version: str,
|
||||
trained_at: str,
|
||||
mode: str,
|
||||
backbone: str,
|
||||
epochs: int,
|
||||
classes: tuple[str, ...] | list[str],
|
||||
train_counts: dict[str, int],
|
||||
val_counts: dict[str, int],
|
||||
dropped: dict[str, int],
|
||||
missing_files: int,
|
||||
weights: list[float],
|
||||
metrics: Metrics,
|
||||
min_accuracy: float,
|
||||
written: bool,
|
||||
notes: list[str] | None = None,
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
verdict = "MODEL WRITTEN" if written else "MODEL NOT WRITTEN — below the floor"
|
||||
lines.append(f"# Body-type classifier {version}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"**{verdict}** · validation accuracy {_pct(metrics.accuracy)} vs floor {_pct(min_accuracy)}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"- trained: {trained_at}")
|
||||
lines.append(f"- mode: {mode} · backbone: {backbone} · epochs: {epochs}")
|
||||
lines.append(f"- classes ({len(classes)}): {', '.join(classes)}")
|
||||
lines.append(
|
||||
f"- labels: {sum(train_counts.values())} train · {sum(val_counts.values())} validation "
|
||||
"(validation = the NEWEST slice, by time seen)"
|
||||
)
|
||||
if dropped:
|
||||
lines.append(
|
||||
"- dropped (too few labels this run): "
|
||||
+ ", ".join(f"{c} ({n})" for c, n in sorted(dropped.items()))
|
||||
)
|
||||
if missing_files:
|
||||
lines.append(f"- labelled rows whose crop is missing on disk (skipped): {missing_files}")
|
||||
lines.append("")
|
||||
lines.append("## Validation")
|
||||
lines.append("")
|
||||
lines.append(f"- accuracy: {_pct(metrics.accuracy)} on {metrics.n} crops")
|
||||
lines.append(f"- macro recall (each class counted equally): {_pct(metrics.macro_recall)}")
|
||||
if metrics.camera_agreement is not None:
|
||||
lines.append(
|
||||
f"- agrees with the detector's coarse class: {_pct(metrics.camera_agreement)} "
|
||||
"(informational — the detector only knows car/truck/bus/motorcycle)"
|
||||
)
|
||||
if metrics.onnx_agreement is not None:
|
||||
lines.append(
|
||||
f"- exported ONNX matches the trained model on validation: {_pct(metrics.onnx_agreement)}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("| class | train | val | recall | precision | loss weight |")
|
||||
lines.append("|---|---:|---:|---:|---:|---:|")
|
||||
for c, w in zip(classes, weights, strict=True):
|
||||
m = metrics.per_class[c]
|
||||
lines.append(
|
||||
f"| {c} | {train_counts.get(c, 0)} | {m.support} | {_pct(m.recall) if m.support else '—'} | "
|
||||
f"{_pct(m.precision) if m.support else '—'} | {w:.2f} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Confusion (rows = reviewer's label, columns = model)")
|
||||
lines.append("")
|
||||
lines.append("| | " + " | ".join(classes) + " |")
|
||||
lines.append("|---|" + "---:|" * len(classes))
|
||||
for c, row in zip(classes, metrics.confusion, strict=True):
|
||||
lines.append(f"| **{c}** | " + " | ".join(str(n) for n in row) + " |")
|
||||
lines.append("")
|
||||
if notes:
|
||||
lines.append("## Notes")
|
||||
lines.append("")
|
||||
lines.extend(f"- {n}" for n in notes)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"The flag on the booth records, it never bills: even a model that passes the floor is "
|
||||
"advisory (the site threshold gates the flag)."
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,382 @@
|
||||
"""`parking-trainer serve` — the job API behind the collector's Training section.
|
||||
|
||||
A tiny stdlib HTTP server (no framework, no extra deps) on the compose-internal network,
|
||||
never published: the collector proxies to it behind the reviewer's login. One job at a
|
||||
time; each job is the CLI run as a SUBPROCESS (`python -m trainer.cli …`) with its output
|
||||
captured to a log file — torch's memory goes away with the process, and a crashing job
|
||||
cannot take the service down. Job state + logs persist under `<out>/jobs/` so a restart
|
||||
still shows history.
|
||||
|
||||
GET /health {ok, busy, version}
|
||||
GET /readiness what `inspect` prints (+ the defaults the UI offers)
|
||||
GET /versions every <out>/<version>/ folder: written?, metrics, sidecar
|
||||
GET /versions/<v>/report report.md (text/markdown)
|
||||
GET /jobs recent jobs, newest first
|
||||
GET /jobs/<id> one job incl. the log tail
|
||||
POST /jobs {kind: train|evaluate|publish, …args} → 202 {id} | 409 busy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .cli import METRICS_FILE, MODEL_FILE, REPORT_FILE, SIDECAR_FILE
|
||||
from .data import load_labelled, make_split, summarise
|
||||
from .preprocess import Sidecar
|
||||
|
||||
_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
BACKBONES = ("resnet18", "mobilenet_v3_small", "efficientnet_b0")
|
||||
MODES = ("features", "finetune")
|
||||
LOG_TAIL_BYTES = 16_000
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class Jobs:
|
||||
"""The single-slot job runner. `start` refuses while one runs."""
|
||||
|
||||
def __init__(self, data_dir: Path, out_dir: Path, publish_url: str, publish_token: str) -> None:
|
||||
self.data_dir = data_dir
|
||||
self.out_dir = out_dir
|
||||
self.publish_url = publish_url
|
||||
self.publish_token = publish_token
|
||||
self.jobs_dir = out_dir / "jobs"
|
||||
self.jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._current: dict[str, Any] | None = None
|
||||
self._proc: subprocess.Popen[bytes] | None = None
|
||||
|
||||
# ---- state -----------------------------------------------------------------------
|
||||
def _write(self, job: dict[str, Any]) -> None:
|
||||
(self.jobs_dir / f"{job['id']}.json").write_text(json.dumps(job, indent=2))
|
||||
|
||||
def _read(self, job_id: str) -> dict[str, Any] | None:
|
||||
p = self.jobs_dir / f"{job_id}.json"
|
||||
if not p.is_file():
|
||||
return None
|
||||
return json.loads(p.read_text()) # type: ignore[no-any-return]
|
||||
|
||||
def log_tail(self, job_id: str) -> str:
|
||||
p = self.jobs_dir / f"{job_id}.log"
|
||||
if not p.is_file():
|
||||
return ""
|
||||
size = p.stat().st_size
|
||||
with p.open("rb") as f:
|
||||
if size > LOG_TAIL_BYTES:
|
||||
f.seek(size - LOG_TAIL_BYTES)
|
||||
return f.read().decode("utf-8", "replace")
|
||||
|
||||
@property
|
||||
def busy(self) -> bool:
|
||||
return self._current is not None
|
||||
|
||||
def current(self) -> dict[str, Any] | None:
|
||||
return dict(self._current) if self._current else None
|
||||
|
||||
def get(self, job_id: str) -> dict[str, Any] | None:
|
||||
if self._current and self._current["id"] == job_id:
|
||||
job = dict(self._current)
|
||||
else:
|
||||
job = self._read(job_id) or {}
|
||||
if not job:
|
||||
return None
|
||||
job["log"] = self.log_tail(job_id)
|
||||
return job
|
||||
|
||||
def recent(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
files = sorted(self.jobs_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
out = []
|
||||
for p in files[:limit]:
|
||||
try:
|
||||
out.append(json.loads(p.read_text()))
|
||||
except ValueError:
|
||||
continue
|
||||
if self._current and all(j["id"] != self._current["id"] for j in out):
|
||||
out.insert(0, dict(self._current))
|
||||
out.sort(key=lambda j: j.get("startedAt", ""), reverse=True)
|
||||
return out
|
||||
|
||||
# ---- args → CLI ------------------------------------------------------------------
|
||||
def _argv(self, kind: str, a: dict[str, Any]) -> list[str]:
|
||||
base = [sys.executable, "-m", "trainer.cli"]
|
||||
if kind == "train":
|
||||
mode = a.get("mode", "features")
|
||||
backbone = a.get("backbone", "resnet18")
|
||||
if mode not in MODES or backbone not in BACKBONES:
|
||||
raise ValueError("bad mode/backbone")
|
||||
floor = float(a.get("minAccuracy", 0.85))
|
||||
min_per = int(a.get("minPerClass", 20))
|
||||
epochs = int(a.get("epochs", 0))
|
||||
if not 0.0 <= floor <= 1.0 or min_per < 1 or epochs < 0:
|
||||
raise ValueError("bad numbers")
|
||||
argv = base + [
|
||||
"train",
|
||||
"--data",
|
||||
str(self.data_dir),
|
||||
"--out",
|
||||
str(self.out_dir),
|
||||
"--mode",
|
||||
mode,
|
||||
"--backbone",
|
||||
backbone,
|
||||
"--min-accuracy",
|
||||
str(floor),
|
||||
"--min-per-class",
|
||||
str(min_per),
|
||||
"--epochs",
|
||||
str(epochs),
|
||||
]
|
||||
if a.get("version"):
|
||||
argv += ["--version", self._version(a["version"])]
|
||||
return argv
|
||||
if kind == "evaluate":
|
||||
v = self._version(a.get("version", ""))
|
||||
return base + [
|
||||
"evaluate",
|
||||
"--data",
|
||||
str(self.data_dir),
|
||||
"--model",
|
||||
str(self.out_dir / v / MODEL_FILE),
|
||||
]
|
||||
if kind == "publish":
|
||||
v = self._version(a.get("version", ""))
|
||||
url = str(a.get("url") or self.publish_url)
|
||||
if not url.startswith("https://") and not url.startswith("http://"):
|
||||
raise ValueError("bad publish url")
|
||||
return base + ["publish", str(self.out_dir / v), "--url", url]
|
||||
raise ValueError("kind must be train, evaluate or publish")
|
||||
|
||||
@staticmethod
|
||||
def _version(v: Any) -> str:
|
||||
if not isinstance(v, str) or not _VERSION_RE.match(v) or v in ("cache", "jobs"):
|
||||
raise ValueError("bad version")
|
||||
return v
|
||||
|
||||
# ---- run -------------------------------------------------------------------------
|
||||
def start(self, kind: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
argv = self._argv(kind, args)
|
||||
with self._lock:
|
||||
if self._current is not None:
|
||||
raise RuntimeError("busy")
|
||||
job_id = f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||
job: dict[str, Any] = {
|
||||
"id": job_id,
|
||||
"kind": kind,
|
||||
"args": {k: v for k, v in args.items() if k != "token"},
|
||||
"status": "running",
|
||||
"startedAt": _now(),
|
||||
"finishedAt": None,
|
||||
"exitCode": None,
|
||||
}
|
||||
env = dict(os.environ)
|
||||
if kind == "publish":
|
||||
env["TRAINER_PUBLISH_TOKEN"] = self.publish_token
|
||||
log = (self.jobs_dir / f"{job_id}.log").open("wb")
|
||||
log.write(f"$ {' '.join(argv[3:])}\n".encode())
|
||||
log.flush()
|
||||
self._proc = subprocess.Popen(argv, stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||
self._current = job
|
||||
self._write(job)
|
||||
threading.Thread(target=self._wait, args=(job, log), daemon=True).start()
|
||||
return dict(job)
|
||||
|
||||
def _wait(self, job: dict[str, Any], log: Any) -> None:
|
||||
assert self._proc is not None
|
||||
code = self._proc.wait()
|
||||
log.close()
|
||||
with self._lock:
|
||||
job["exitCode"] = code
|
||||
job["finishedAt"] = _now()
|
||||
job["status"] = "done" if code == 0 else ("refused" if code in (2, 3) else "failed")
|
||||
self._write(job)
|
||||
self._current = None
|
||||
self._proc = None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def readiness(data_dir: Path, min_per_class: int = 20, val_fraction: float = 0.2) -> dict[str, Any]:
|
||||
try:
|
||||
samples, missing = load_labelled(data_dir)
|
||||
except FileNotFoundError:
|
||||
return {
|
||||
"ready": False,
|
||||
"labelled": {"total": 0, "byClass": {}},
|
||||
"missingCrops": 0,
|
||||
"error": "no collector database yet",
|
||||
}
|
||||
split = make_split(samples, val_fraction, min_per_class)
|
||||
return {
|
||||
"ready": len(split.classes) >= 2,
|
||||
"labelled": summarise(samples),
|
||||
"missingCrops": missing,
|
||||
"run": {
|
||||
"classes": list(split.classes),
|
||||
"train": split.counts("train"),
|
||||
"val": split.counts("val"),
|
||||
"dropped": split.dropped,
|
||||
"minPerClass": min_per_class,
|
||||
"valFraction": val_fraction,
|
||||
},
|
||||
"defaults": {
|
||||
"mode": "features",
|
||||
"backbone": "resnet18",
|
||||
"minAccuracy": 0.85,
|
||||
"minPerClass": min_per_class,
|
||||
},
|
||||
"modes": list(MODES),
|
||||
"backbones": list(BACKBONES),
|
||||
}
|
||||
|
||||
|
||||
def versions(out_dir: Path) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
if not out_dir.is_dir():
|
||||
return out
|
||||
for d in sorted(out_dir.iterdir(), key=lambda p: p.name, reverse=True):
|
||||
if not d.is_dir() or d.name in ("cache", "jobs"):
|
||||
continue
|
||||
if not (d / REPORT_FILE).is_file() and not (d / METRICS_FILE).is_file():
|
||||
continue
|
||||
entry: dict[str, Any] = {
|
||||
"version": d.name,
|
||||
"written": (d / MODEL_FILE).is_file() and (d / SIDECAR_FILE).is_file(),
|
||||
"hasReport": (d / REPORT_FILE).is_file(),
|
||||
"modifiedAt": datetime.fromtimestamp(d.stat().st_mtime, tz=timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat(),
|
||||
}
|
||||
try:
|
||||
m = json.loads((d / METRICS_FILE).read_text())
|
||||
entry["accuracy"] = m.get("accuracy")
|
||||
entry["macroRecall"] = m.get("macro_recall")
|
||||
entry["n"] = m.get("n")
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if entry["written"]:
|
||||
try:
|
||||
side = Sidecar.read(d / SIDECAR_FILE)
|
||||
entry["classes"] = side.classes
|
||||
entry["mode"] = side.mode
|
||||
entry["backbone"] = side.backbone
|
||||
entry["trainedAt"] = side.trained_at
|
||||
entry["labels"] = side.labels
|
||||
entry["floor"] = side.metrics.get("floor")
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
jobs: Jobs # set on the class by serve()
|
||||
server_version = "parking-trainer"
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None: # quieter than the default
|
||||
sys.stderr.write(f"[trainer.serve] {self.address_string()} {fmt % args}\n")
|
||||
|
||||
def _json(self, code: int, body: Any) -> None:
|
||||
raw = json.dumps(body).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def _text(self, code: int, body: str, ctype: str = "text/markdown; charset=utf-8") -> None:
|
||||
raw = body.encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
path = self.path.split("?", 1)[0]
|
||||
j = self.jobs
|
||||
if path == "/health":
|
||||
self._json(200, {"ok": True, "busy": j.busy, "version": "parking-trainer"})
|
||||
elif path == "/readiness":
|
||||
self._json(200, readiness(j.data_dir))
|
||||
elif path == "/versions":
|
||||
self._json(200, {"versions": versions(j.out_dir)})
|
||||
elif path.startswith("/versions/") and path.endswith("/report"):
|
||||
v = path[len("/versions/") : -len("/report")]
|
||||
try:
|
||||
p = j.out_dir / Jobs._version(v) / REPORT_FILE
|
||||
except ValueError:
|
||||
self._json(400, {"error": "bad version"})
|
||||
return
|
||||
if not p.is_file():
|
||||
self._json(404, {"error": "no report"})
|
||||
else:
|
||||
self._text(200, p.read_text())
|
||||
elif path == "/jobs":
|
||||
self._json(200, {"jobs": j.recent(), "current": j.current()})
|
||||
elif path.startswith("/jobs/"):
|
||||
job = j.get(path[len("/jobs/") :])
|
||||
self._json(200, job) if job else self._json(404, {"error": "no such job"})
|
||||
else:
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if self.path.split("?", 1)[0] != "/jobs":
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
if n > 64_000:
|
||||
self._json(413, {"error": "too large"})
|
||||
return
|
||||
try:
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError("object expected")
|
||||
except ValueError as exc:
|
||||
self._json(400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
kind = str(body.pop("kind", ""))
|
||||
try:
|
||||
job = self.jobs.start(kind, body)
|
||||
except ValueError as exc:
|
||||
self._json(400, {"error": str(exc)})
|
||||
return
|
||||
except RuntimeError:
|
||||
self._json(409, {"error": "a job is already running", "current": self.jobs.current()})
|
||||
return
|
||||
self._json(202, job)
|
||||
|
||||
|
||||
def serve(data_dir: Path, out_dir: Path, host: str, port: int, publish_url: str, publish_token: str) -> None:
|
||||
Handler.jobs = Jobs(data_dir, out_dir, publish_url, publish_token)
|
||||
httpd = ThreadingHTTPServer((host, port), Handler)
|
||||
sys.stderr.write(f"[trainer.serve] listening on {host}:{port}, data {data_dir}, out {out_dir}\n")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def wait_idle(jobs: Jobs, timeout: float = 60.0) -> None:
|
||||
"""Test helper: block until no job runs."""
|
||||
t0 = time.monotonic()
|
||||
while jobs.busy and time.monotonic() - t0 < timeout:
|
||||
time.sleep(0.1)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1444
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
.venv/
|
||||
**/__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.env
|
||||
# weights are fetched inside the build (yolox) or pinned by models/bodytype.version
|
||||
models/*
|
||||
!models/bodytype.version
|
||||
@@ -20,3 +20,20 @@ VISION_OCR_MODEL=cct-xs-v2-global-model
|
||||
# Confidence floor — a best plate below this is flagged low_confidence so the Node side
|
||||
# treats it as advisory and falls back to the ticket path. Keep in sync with the server.
|
||||
VISION_MIN_CONFIDENCE=0.5
|
||||
|
||||
# Vehicle stage (phase A): a YOLOX ONNX graph (Apache-2.0) run on the same frame after the
|
||||
# plate read; fills /analyze `vehicle.body_type` (car/truck/bus/motorcycle) + confidence for
|
||||
# the Car Wash desk's category suggestion. Unset = off. The Docker image bakes the weights
|
||||
# at /app/models/yolox_s.onnx; locally: curl the release file into apps/vision/models/.
|
||||
# https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_s.onnx
|
||||
# VISION_VEHICLE_MODEL_PATH=models/yolox_s.onnx
|
||||
# VISION_VEHICLE_INPUT_SIZE=640
|
||||
# VISION_VEHICLE_MIN_CONFIDENCE=0.4
|
||||
|
||||
# Phase B: the body-type classifier (sedan/hatchback/suv/… on the detector's crop), trained
|
||||
# by apps/trainer on the reviewer's labels. The Docker image bakes it at
|
||||
# /app/models/bodytype.onnx (+ .json sidecar) when models/bodytype.version pins a published
|
||||
# version; locally copy a trainer output folder's two files into apps/vision/models/.
|
||||
# Path set but no file = stage off (the normal state before the first model).
|
||||
# VISION_VEHICLE_CLASSIFIER_PATH=models/bodytype.onnx
|
||||
# VISION_VEHICLE_CLASSIFIER_MIN_CONFIDENCE=0.6
|
||||
|
||||
@@ -7,5 +7,6 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
|
||||
# Model weights (fetched at deploy / first run, never committed — can be large + license-scoped)
|
||||
models/
|
||||
models/*
|
||||
!models/bodytype.version
|
||||
*.onnx
|
||||
|
||||
+29
-2
@@ -14,7 +14,7 @@ ENV UV_LINK_MODE=copy \
|
||||
|
||||
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
||||
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
|
||||
@@ -26,6 +26,31 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# ---- project source ----
|
||||
COPY vision_service/ ./vision_service/
|
||||
COPY README.md ./
|
||||
# Vehicle stage weights (phase A): YOLOX-S, Apache-2.0, ~36 MB, baked into the image so the
|
||||
# air-gapped appliance never fetches at runtime and no operator-writable path holds a model
|
||||
# (vision-service-hardening.md). Best-effort at build: without network the stage stays off.
|
||||
ARG YOLOX_URL=https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_s.onnx
|
||||
RUN mkdir -p /app/models \
|
||||
&& (curl -fsSL -o /app/models/yolox_s.onnx "$YOLOX_URL" \
|
||||
|| (echo "[build] yolox weights not fetched (no network) — vehicle stage off" && rm -f /app/models/yolox_s.onnx))
|
||||
# Phase B body-type classifier (apps/trainer output, published to the Gitea generic package
|
||||
# registry — weights are not code, they never live in git). models/bodytype.version PINS the
|
||||
# version this image carries: empty = no classifier (phase B off). A pinned version that
|
||||
# cannot be fetched FAILS the build — the image must carry what git says it carries. The
|
||||
# registry may need auth: pass a BuildKit secret `bodytype_auth` holding "user:token".
|
||||
ARG BODYTYPE_BASE_URL=https://git.infra.msai.al/api/packages/mca/generic/parking-bodytype
|
||||
COPY models/bodytype.version ./models/bodytype.version
|
||||
RUN --mount=type=secret,id=bodytype_auth \
|
||||
v="$(tr -d '[:space:]' < /app/models/bodytype.version)"; \
|
||||
if [ -n "$v" ]; then \
|
||||
cfg=/tmp/curl.cfg; : > "$cfg"; \
|
||||
[ -f /run/secrets/bodytype_auth ] && printf 'user = "%s"\n' "$(cat /run/secrets/bodytype_auth)" > "$cfg"; \
|
||||
curl -fsSL -K "$cfg" -o /app/models/bodytype.onnx "$BODYTYPE_BASE_URL/$v/bodytype.onnx" \
|
||||
&& curl -fsSL -K "$cfg" -o /app/models/bodytype.json "$BODYTYPE_BASE_URL/$v/bodytype.json" \
|
||||
&& echo "[build] bodytype classifier $v baked" \
|
||||
|| { echo "[build] bodytype classifier $v could not be fetched"; rm -f "$cfg"; exit 1; }; \
|
||||
rm -f "$cfg"; \
|
||||
else echo "[build] no bodytype version pinned — phase B off"; fi
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --extra alpr
|
||||
|
||||
@@ -49,7 +74,9 @@ RUN uv run python -c "from fast_alpr import ALPR; ALPR()" \
|
||||
# Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
|
||||
ENV VISION_RECOGNIZER=stub \
|
||||
VISION_HOST=0.0.0.0 \
|
||||
VISION_PORT=8089
|
||||
VISION_PORT=8089 \
|
||||
VISION_VEHICLE_MODEL_PATH=/app/models/yolox_s.onnx \
|
||||
VISION_VEHICLE_CLASSIFIER_PATH=/app/models/bodytype.onnx
|
||||
EXPOSE 8089
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
|
||||
|
||||
@@ -35,6 +35,9 @@ dev = [
|
||||
"pytest>=8.3",
|
||||
"httpx>=0.27", # FastAPI TestClient transport
|
||||
"mypy>=1.13",
|
||||
# The vehicle stage's post-processing tests run on synthetic tensors without the model
|
||||
# stack (CI syncs WITHOUT the alpr extra); the service itself imports numpy lazily.
|
||||
"numpy>=1.26",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Vehicle stage (phase A) — pure post-processing on synthetic tensors, and the
|
||||
recognizer composition over the stub with a fake detector. No weights needed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from vision_service.schemas import BBox, VehicleResult
|
||||
from vision_service.vehicle import (
|
||||
COCO_VEHICLE_CLASSES,
|
||||
Detection,
|
||||
decode,
|
||||
letterbox,
|
||||
nms,
|
||||
pick_vehicle,
|
||||
vehicles_from_output,
|
||||
)
|
||||
|
||||
SIZE = 64 # tiny "model" input: grids 8x8 + 4x4 + 2x2 = 84 rows
|
||||
ROWS = (SIZE // 8) ** 2 + (SIZE // 16) ** 2 + (SIZE // 32) ** 2
|
||||
|
||||
|
||||
def raw_output(hits: list[tuple[int, int, int, float, float, float, float]]) -> np.ndarray:
|
||||
"""Build a YOLOX-style raw tensor [ROWS, 85] with the given (row, coco_class, _, obj,
|
||||
cls_score, log_w, log_h) hits; everything else is background."""
|
||||
raw = np.zeros((ROWS, 85), dtype=np.float32)
|
||||
raw[:, 2:4] = -10.0 # exp → ~0 size for background rows
|
||||
for row, cls, _, obj, score, lw, lh in hits:
|
||||
raw[row, 0:2] = 0.5 # centre of its grid cell
|
||||
raw[row, 2] = lw
|
||||
raw[row, 3] = lh
|
||||
raw[row, 4] = obj
|
||||
raw[row, 5 + cls] = score
|
||||
return raw
|
||||
|
||||
|
||||
def test_decode_maps_grid_offsets_and_log_sizes_to_pixels() -> None:
|
||||
raw = raw_output([(0, 2, 0, 1.0, 1.0, np.log(2.0), np.log(3.0))])
|
||||
dec = decode(raw, SIZE)
|
||||
# Row 0 = stride-8 grid cell (0,0): centre (0.5+0)*8 = 4, size exp(log 2)*8 = 16 / 24.
|
||||
assert dec[0, :4].tolist() == [4.0, 4.0, 16.0, 24.0]
|
||||
# Last row = stride-32 cell (1,1): centre (0.5+1)*32 = 48.
|
||||
raw2 = raw_output([(ROWS - 1, 7, 0, 1.0, 1.0, 0.0, 0.0)])
|
||||
dec2 = decode(raw2, SIZE)
|
||||
assert dec2[ROWS - 1, :4].tolist() == [48.0, 48.0, 32.0, 32.0]
|
||||
|
||||
|
||||
def test_vehicles_only_above_floor_mapped_to_vocabulary_and_scaled_back() -> None:
|
||||
raw = raw_output(
|
||||
[
|
||||
(0, 2, 0, 0.9, 0.9, np.log(2.0), np.log(2.0)), # car, score .81
|
||||
(1, 0, 0, 0.99, 0.99, np.log(2.0), np.log(2.0)), # person → ignored
|
||||
(2, 7, 0, 0.5, 0.5, np.log(2.0), np.log(2.0)), # truck, score .25 → below floor
|
||||
]
|
||||
)
|
||||
found = vehicles_from_output(raw, SIZE, scale=0.5, min_confidence=0.4)
|
||||
assert [d.body_type for d in found] == ["car"]
|
||||
assert round(found[0].confidence, 2) == 0.81
|
||||
# Box 16px wide in the letterboxed input → 32px in the original (scale 0.5).
|
||||
assert round(found[0].x2 - found[0].x1) == 32
|
||||
assert set(COCO_VEHICLE_CLASSES.values()) == {"car", "motorcycle", "bus", "truck"}
|
||||
|
||||
|
||||
def test_nms_keeps_the_best_of_overlapping_boxes() -> None:
|
||||
boxes = np.array([[0, 0, 10, 10], [1, 1, 11, 11], [50, 50, 60, 60]], dtype=np.float32)
|
||||
scores = np.array([0.5, 0.9, 0.7], dtype=np.float32)
|
||||
assert sorted(nms(boxes, scores, 0.45)) == [1, 2]
|
||||
|
||||
|
||||
def test_pick_prefers_the_box_holding_the_plate_else_the_largest() -> None:
|
||||
near = Detection("car", 0.9, 0, 0, 100, 100)
|
||||
far = Detection("truck", 0.8, 200, 200, 400, 400) # larger
|
||||
inside = Detection("car", 0.7, 10, 10, 60, 60) # tighter box also holding the plate
|
||||
assert pick_vehicle([near, far], None) is far
|
||||
assert pick_vehicle([near, far], BBox(x1=20, y1=20, x2=30, y2=30)) is near
|
||||
assert pick_vehicle([near, far, inside], BBox(x1=20, y1=20, x2=30, y2=30)) is inside
|
||||
assert pick_vehicle([near, far], BBox(x1=900, y1=900, x2=910, y2=910)) is far # plate outside every box
|
||||
assert pick_vehicle([], None) is None
|
||||
|
||||
|
||||
def test_letterbox_keeps_aspect_and_pads_with_114() -> None:
|
||||
pytest.importorskip("cv2") # letterbox resizes with OpenCV — only present with the alpr extra
|
||||
frame = np.zeros((30, 60, 3), dtype=np.uint8)
|
||||
tensor, scale = letterbox(frame, 64)
|
||||
assert tensor.shape == (1, 3, 64, 64) and tensor.dtype == np.float32
|
||||
assert abs(scale - 64 / 60) < 1e-9
|
||||
assert tensor[0, 0, 63, 63] == 114.0 # padding
|
||||
assert tensor[0, 0, 0, 0] == 0.0 # image
|
||||
|
||||
|
||||
class FakeDetector:
|
||||
model_version = "fake-vehicle"
|
||||
|
||||
def __init__(self, result: VehicleResult | None) -> None:
|
||||
self.result = result
|
||||
self.calls: list[BBox | None] = []
|
||||
|
||||
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
|
||||
self.calls.append(plate)
|
||||
return self.result
|
||||
|
||||
|
||||
def test_composition_fills_vehicle_over_the_stub_and_survives_a_failing_stage() -> None:
|
||||
from vision_service.recognizer import StubRecognizer, WithVehicle
|
||||
from vision_service.settings import Settings
|
||||
|
||||
det = FakeDetector(VehicleResult(body_type="truck", confidence=0.77))
|
||||
rec = WithVehicle(StubRecognizer(Settings()), det)
|
||||
res = rec.analyze(b"jpeg-bytes")
|
||||
assert res.plate is None
|
||||
assert res.vehicle == VehicleResult(body_type="truck", confidence=0.77)
|
||||
assert res.model_version == "stub-0+fake-vehicle"
|
||||
assert det.calls == [None]
|
||||
|
||||
class Boom:
|
||||
model_version = "boom"
|
||||
|
||||
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
|
||||
raise RuntimeError("no model")
|
||||
|
||||
rec2 = WithVehicle(StubRecognizer(Settings()), Boom())
|
||||
res2 = rec2.analyze(b"jpeg-bytes")
|
||||
assert res2.vehicle is None
|
||||
assert rec2.ready is True
|
||||
assert "vehicle: RuntimeError: no model" in (rec2.error or "")
|
||||
|
||||
|
||||
def test_app_reports_a_missing_model_file_and_keeps_serving() -> None:
|
||||
from vision_service.app import app
|
||||
|
||||
os.environ["VISION_VEHICLE_MODEL_PATH"] = "/nonexistent/yolox.onnx"
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
health = client.get("/health").json()
|
||||
assert health["ready"] is True # the plate stage (stub) is fine
|
||||
assert "vehicle:" in (health["detail"] or "")
|
||||
res = client.post("/analyze", content=b"x", headers={"content-type": "application/octet-stream"})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["vehicle"] is None
|
||||
finally:
|
||||
os.environ.pop("VISION_VEHICLE_MODEL_PATH", None)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# Phase B: the classifier stage over the detector
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_crop_vehicle_adds_the_margin_clamps_and_blurs_the_plate() -> None:
|
||||
cv2 = pytest.importorskip("cv2")
|
||||
from vision_service.vehicle import crop_vehicle
|
||||
|
||||
frame = np.zeros((100, 200, 3), dtype=np.uint8)
|
||||
frame[40:50, 90:110] = (0, 255, 0) # a green "plate"
|
||||
box = BBox(x1=50, y1=20, x2=150, y2=80) # 100×60 → 8 % margin = 8 / 5 px
|
||||
crop = crop_vehicle(frame, box, None, 0.08)
|
||||
assert crop.shape == (70, 116, 3)
|
||||
edge = crop_vehicle(frame, BBox(x1=0, y1=0, x2=100, y2=60), None, 0.08)
|
||||
assert edge.shape == (65, 108, 3) # clamped at the frame's top-left
|
||||
assert crop_vehicle(frame, BBox(x1=10, y1=10, x2=12, y2=12), None, 0.08) is None
|
||||
blurred = crop_vehicle(frame, box, BBox(x1=90, y1=40, x2=110, y2=50), 0.08)
|
||||
strip = blurred[40 - 20 + 5 : 50 - 20 + 5, 90 - 50 + 8 : 110 - 50 + 8, 1] # plate strip, green channel
|
||||
assert strip.mean() < 200 and crop[20 + 5 : 30 + 5, 40 + 8 : 60 + 8, 1].mean() == 255
|
||||
assert cv2 is not None
|
||||
|
||||
|
||||
class FakeClassifier:
|
||||
ready = True
|
||||
error = None
|
||||
min_confidence = 0.6
|
||||
model_version = "bodytype:vfake"
|
||||
|
||||
def __init__(self, classes: list[str], answer: tuple[str, float] | None) -> None:
|
||||
self.classes = classes
|
||||
self.answer = answer
|
||||
self.calls = 0
|
||||
|
||||
def classify(self, frame, box, plate): # type: ignore[no-untyped-def]
|
||||
self.calls += 1
|
||||
if isinstance(self.answer, Exception):
|
||||
raise self.answer
|
||||
return self.answer
|
||||
|
||||
|
||||
class FrameDetector:
|
||||
"""A detector that answers on decoded frames (like YOLOX) with a fixed result."""
|
||||
|
||||
model_version = "det"
|
||||
ready = True
|
||||
error = None
|
||||
|
||||
def __init__(self, result: VehicleResult | None) -> None:
|
||||
self.result = result
|
||||
|
||||
def detect_frame(self, frame, plate): # type: ignore[no-untyped-def]
|
||||
return self.result
|
||||
|
||||
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
|
||||
raise AssertionError("the refined stage should share the decoded frame")
|
||||
|
||||
|
||||
def _jpeg() -> bytes:
|
||||
cv2 = pytest.importorskip("cv2")
|
||||
ok, buf = cv2.imencode(".jpg", np.zeros((60, 80, 3), dtype=np.uint8))
|
||||
assert ok
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def test_refined_detector_replaces_car_when_confident_else_keeps_the_detector() -> None:
|
||||
from vision_service.vehicle import RefinedVehicleDetector
|
||||
|
||||
car = VehicleResult(body_type="car", confidence=0.85, bbox=BBox(x1=10, y1=10, x2=70, y2=50))
|
||||
sure = FakeClassifier(["sedan", "suv"], ("suv", 0.91))
|
||||
res = RefinedVehicleDetector(FrameDetector(car), sure).detect(_jpeg(), None)
|
||||
assert (
|
||||
res is not None and res.body_type == "suv" and res.confidence == 0.91 and res.detector_class == "car"
|
||||
)
|
||||
assert res.bbox == car.bbox
|
||||
|
||||
unsure = FakeClassifier(["sedan", "suv"], ("suv", 0.4))
|
||||
res2 = RefinedVehicleDetector(FrameDetector(car), unsure).detect(_jpeg(), None)
|
||||
assert (
|
||||
res2 is not None
|
||||
and res2.body_type == "car"
|
||||
and res2.confidence == 0.85
|
||||
and res2.detector_class == "car"
|
||||
)
|
||||
|
||||
# A class the classifier never trained on is left alone (its softmax means nothing there).
|
||||
bus = VehicleResult(body_type="bus", confidence=0.9, bbox=car.bbox)
|
||||
skip = FakeClassifier(["sedan", "suv"], ("suv", 0.99))
|
||||
res3 = RefinedVehicleDetector(FrameDetector(bus), skip).detect(_jpeg(), None)
|
||||
assert res3 == bus and skip.calls == 0
|
||||
# …unless it was: a classifier that knows trucks may override a truck.
|
||||
knows = FakeClassifier(["sedan", "truck", "van"], ("van", 0.8))
|
||||
truck = VehicleResult(body_type="truck", confidence=0.7, bbox=car.bbox)
|
||||
res4 = RefinedVehicleDetector(FrameDetector(truck), knows).detect(_jpeg(), None)
|
||||
assert res4 is not None and res4.body_type == "van" and res4.detector_class == "truck"
|
||||
|
||||
|
||||
def test_refined_detector_survives_a_broken_classifier_and_reports_it() -> None:
|
||||
from vision_service.vehicle import RefinedVehicleDetector
|
||||
|
||||
car = VehicleResult(body_type="car", confidence=0.85, bbox=BBox(x1=10, y1=10, x2=70, y2=50))
|
||||
boom = FakeClassifier(["sedan"], RuntimeError("bad graph")) # type: ignore[arg-type]
|
||||
ref = RefinedVehicleDetector(FrameDetector(car), boom)
|
||||
assert ref.detect(_jpeg(), None) == car
|
||||
assert ref.error == "classifier: RuntimeError: bad graph"
|
||||
assert ref.ready is True and ref.model_version == "det+bodytype:vfake"
|
||||
# No box, or a detector that found nothing → nothing to classify.
|
||||
assert RefinedVehicleDetector(FrameDetector(None), boom).detect(_jpeg(), None) is None
|
||||
boxless = VehicleResult(body_type="car", confidence=0.85)
|
||||
assert RefinedVehicleDetector(FrameDetector(boxless), boom).detect(_jpeg(), None) == boxless
|
||||
|
||||
|
||||
def test_classifier_without_files_is_not_ready_and_the_factory_skips_a_missing_model(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
from vision_service.recognizer import WithVehicle, build_recognizer
|
||||
from vision_service.settings import Settings
|
||||
from vision_service.vehicle import BodyTypeClassifier, RefinedVehicleDetector
|
||||
|
||||
clf = BodyTypeClassifier(str(tmp_path / "bodytype.onnx"))
|
||||
assert clf.ready is False and "FileNotFoundError" in (clf.error or "")
|
||||
(tmp_path / "bodytype.json").write_text('{"format": "other"}')
|
||||
assert "unknown sidecar format" in (BodyTypeClassifier(str(tmp_path / "bodytype.onnx")).error or "")
|
||||
|
||||
# A path with no file = the normal pre-model state: phase A only, no error in health.
|
||||
s = Settings(
|
||||
vehicle_model_path="/nonexistent/yolox.onnx", vehicle_classifier_path=str(tmp_path / "none.onnx")
|
||||
)
|
||||
rec = build_recognizer(s)
|
||||
assert isinstance(rec, WithVehicle)
|
||||
assert not isinstance(rec._detector, RefinedVehicleDetector) # noqa: SLF001
|
||||
assert "classifier" not in (rec.error or "")
|
||||
Generated
+3
@@ -760,6 +760,8 @@ alpr = [
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "mypy" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
@@ -779,6 +781,7 @@ provides-extras = ["alpr"]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "mypy", specifier = ">=1.13" },
|
||||
{ name = "numpy", specifier = ">=1.26" },
|
||||
{ name = "pytest", specifier = ">=8.3" },
|
||||
{ name = "ruff", specifier = ">=0.8" },
|
||||
]
|
||||
|
||||
@@ -14,11 +14,16 @@ Adding a recognizer (e.g. a fine-tuned YOLO + PaddleOCR) = a new class here, no
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from .schemas import AnalyzeResponse, BBox, PlateResult
|
||||
from .settings import Settings
|
||||
from .vehicle import BodyTypeClassifier, RefinedVehicleDetector, VehicleDetector, YoloxVehicleDetector
|
||||
|
||||
log = logging.getLogger("vision")
|
||||
|
||||
|
||||
class Recognizer(Protocol):
|
||||
@@ -165,10 +170,70 @@ class FastAlprRecognizer:
|
||||
)
|
||||
|
||||
|
||||
class WithVehicle:
|
||||
"""Composition: any plate recognizer + the vehicle stage. Runs the plate stage first
|
||||
(its box picks WHICH vehicle), then fills `vehicle`. A failing vehicle stage is
|
||||
logged into `error` and yields null — it must never cost the plate read."""
|
||||
|
||||
def __init__(self, inner: Recognizer, detector: VehicleDetector) -> None:
|
||||
self._inner = inner
|
||||
self._detector = detector
|
||||
self.vehicle_error: str | None = None
|
||||
|
||||
@property
|
||||
def model_version(self) -> str:
|
||||
return f"{self._inner.model_version}+{self._detector.model_version}"
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return bool(self._inner.ready)
|
||||
|
||||
@property
|
||||
def error(self) -> str | None:
|
||||
inner = getattr(self._inner, "error", None)
|
||||
det = getattr(self._detector, "error", None) or self.vehicle_error
|
||||
parts = [p for p in (inner, f"vehicle: {det}" if det else None) if p]
|
||||
return "; ".join(parts) if parts else None
|
||||
|
||||
def analyze(self, image_bytes: bytes) -> AnalyzeResponse:
|
||||
started = time.perf_counter()
|
||||
res = self._inner.analyze(image_bytes)
|
||||
try:
|
||||
vehicle = self._detector.detect(image_bytes, res.plate.bbox if res.plate else None)
|
||||
except Exception as exc: # noqa: BLE001 - advisory stage, never fatal
|
||||
self.vehicle_error = f"{type(exc).__name__}: {exc}"
|
||||
vehicle = None
|
||||
took_ms = (time.perf_counter() - started) * 1000.0
|
||||
return res.model_copy(
|
||||
update={"vehicle": vehicle, "model_version": self.model_version, "took_ms": took_ms}
|
||||
)
|
||||
|
||||
|
||||
def build_recognizer(settings: Settings) -> Recognizer:
|
||||
"""Factory: pick the recognizer from settings. Falls back to the stub if the real
|
||||
one can't load, so the service always comes up (with ready=False surfaced)."""
|
||||
one can't load, so the service always comes up (with ready=False surfaced). The
|
||||
vehicle stage wraps whichever recognizer runs when a model path is configured."""
|
||||
rec: Recognizer
|
||||
if settings.recognizer == "fast_alpr":
|
||||
rec = FastAlprRecognizer(settings)
|
||||
return rec
|
||||
return StubRecognizer(settings)
|
||||
else:
|
||||
rec = StubRecognizer(settings)
|
||||
if settings.vehicle_model_path:
|
||||
detector: VehicleDetector = YoloxVehicleDetector(
|
||||
settings.vehicle_model_path,
|
||||
input_size=settings.vehicle_input_size,
|
||||
min_confidence=settings.vehicle_min_confidence,
|
||||
)
|
||||
if settings.vehicle_classifier_path:
|
||||
if Path(settings.vehicle_classifier_path).is_file():
|
||||
detector = RefinedVehicleDetector(
|
||||
detector,
|
||||
BodyTypeClassifier(
|
||||
settings.vehicle_classifier_path,
|
||||
min_confidence=settings.vehicle_classifier_min_confidence,
|
||||
),
|
||||
)
|
||||
else:
|
||||
log.info("no body-type classifier at %s — phase B off", settings.vehicle_classifier_path)
|
||||
return WithVehicle(rec, detector)
|
||||
return rec
|
||||
|
||||
@@ -31,10 +31,23 @@ 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)
|
||||
# The vehicle's box in frame pixels — the crop a reviewer sees / a classifier eats.
|
||||
bbox: BBox | None = None
|
||||
# Phase B: the detector's coarse class when the body-type classifier ran on this crop
|
||||
# (body_type is then the classifier's answer if confident, else the detector's).
|
||||
detector_class: str | None = None
|
||||
make: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
@@ -32,6 +32,24 @@ class Settings(BaseSettings):
|
||||
# Node side can fall back to the ticket path rather than trust it.
|
||||
min_confidence: float = 0.5
|
||||
|
||||
# Vehicle stage (phase A — venue-modules.md §Vehicle category from vision): a YOLOX
|
||||
# ONNX graph (Apache-2.0) run beside the plate recognizer. Unset = stage off (the
|
||||
# response's `vehicle` stays null). Bake the file into the image (models/), never a
|
||||
# path an operator can write (vision-service-hardening.md).
|
||||
vehicle_model_path: str | None = None
|
||||
vehicle_input_size: int = 640
|
||||
# Detection score floor for a vehicle box to count at all (the Node side applies the
|
||||
# site's own, stricter threshold before it FLAGS anything).
|
||||
vehicle_min_confidence: float = 0.4
|
||||
|
||||
# Phase B — the body-type classifier on the detector's crop (bodytype.onnx + its .json
|
||||
# sidecar, produced by apps/trainer, baked into the image when
|
||||
# models/bodytype.version pins a published version). Path set but NO file = the normal
|
||||
# state before the first model ships: the stage is simply off (logged, not an error).
|
||||
vehicle_classifier_path: str | None = None
|
||||
# Below this probability the classifier's answer is dropped and the detector's stands.
|
||||
vehicle_classifier_min_confidence: float = 0.6
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Vehicle stage: a COCO object detector beside the plate recognizer (Job 2, phase A).
|
||||
|
||||
Answers "what KIND of vehicle is in this entry frame?" for the Car Wash desk's category
|
||||
suggestion (wiki/decisions/venue-modules.md §Vehicle category from vision). ADVISORY by
|
||||
design: the Node server records it next to the plate, the desk pre-selects the site
|
||||
category it maps to, the operator decides, a confident downgrade is flagged. Nothing is
|
||||
ever gated on it, so a wrong or missing detection costs nothing but a suggestion.
|
||||
|
||||
Model: YOLOX (Megvii, Apache-2.0) as an ONNX graph on the ONNX Runtime the plate stage
|
||||
already uses — the licence rule that keeps Ultralytics (AGPL) out. COCO's vehicle classes
|
||||
are car / motorcycle / bus / truck: enough to tell a van or a truck from a car, NOT enough
|
||||
for SUV vs sedan — that is phase B (a body-type classifier on the pilot's own frames).
|
||||
The detector's vehicle box is also the crop phase B will classify.
|
||||
|
||||
Pure numpy/cv2 pre/post-processing, no torch: letterbox to the model's square input
|
||||
(pad 114, no normalisation — YOLOX's exported graphs take raw 0–255 BGR), decode the
|
||||
stride grids, class-agnostic NMS, map COCO ids to the shared vocabulary, pick ONE
|
||||
vehicle: the one whose box holds the plate (when a plate was read), else the largest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .schemas import BBox, VehicleResult
|
||||
|
||||
# COCO-80 class index → the shared VEHICLE_CLASSES vocabulary (packages/shared).
|
||||
COCO_VEHICLE_CLASSES: dict[int, str] = {2: "car", 3: "motorcycle", 5: "bus", 7: "truck"}
|
||||
|
||||
# YOLOX feature strides; grids are input/stride per level (8400 anchors at 640).
|
||||
_STRIDES = (8, 16, 32)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Detection:
|
||||
body_type: str
|
||||
confidence: float
|
||||
x1: float
|
||||
y1: float
|
||||
x2: float
|
||||
y2: float
|
||||
|
||||
@property
|
||||
def area(self) -> float:
|
||||
return max(0.0, self.x2 - self.x1) * max(0.0, self.y2 - self.y1)
|
||||
|
||||
def contains(self, x: float, y: float) -> bool:
|
||||
return self.x1 <= x <= self.x2 and self.y1 <= y <= self.y2
|
||||
|
||||
|
||||
class VehicleDetector(Protocol):
|
||||
"""What the recognizer composition needs: frame bytes (+ the plate box) → a class."""
|
||||
|
||||
@property
|
||||
def model_version(self) -> str: ...
|
||||
|
||||
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None: ...
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# Pre/post-processing (pure functions — unit-tested on synthetic tensors)
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def letterbox(frame: Any, size: int) -> tuple[Any, float]:
|
||||
"""Resize keeping aspect, pad bottom/right with 114 to size×size. Returns the CHW
|
||||
float32 tensor (batch dim added) and the scale to map boxes back."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
h, w = frame.shape[:2]
|
||||
r = min(size / h, size / w)
|
||||
nh, nw = int(round(h * r)), int(round(w * r))
|
||||
resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR)
|
||||
padded = np.full((size, size, 3), 114, dtype=np.uint8)
|
||||
padded[:nh, :nw] = resized
|
||||
tensor = padded.transpose(2, 0, 1)[None].astype(np.float32)
|
||||
return np.ascontiguousarray(tensor), r
|
||||
|
||||
|
||||
def decode(raw: Any, size: int) -> Any:
|
||||
"""YOLOX raw output [N, 5+classes] (batch squeezed) → same shape with xywh decoded
|
||||
into pixel units of the letterboxed input. Rows are ordered stride 8, 16, 32."""
|
||||
import numpy as np
|
||||
|
||||
out = raw.astype(np.float32).copy()
|
||||
grids = []
|
||||
strides = []
|
||||
for s in _STRIDES:
|
||||
n = size // s
|
||||
ys, xs = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
|
||||
grids.append(np.stack((xs, ys), axis=-1).reshape(-1, 2))
|
||||
strides.append(np.full((n * n, 1), s, dtype=np.float32))
|
||||
grid = np.concatenate(grids, axis=0).astype(np.float32)
|
||||
stride = np.concatenate(strides, axis=0)
|
||||
if out.shape[0] != grid.shape[0]:
|
||||
raise ValueError(f"unexpected output rows {out.shape[0]} for input {size} (want {grid.shape[0]})")
|
||||
out[:, :2] = (out[:, :2] + grid) * stride
|
||||
out[:, 2:4] = np.exp(out[:, 2:4]) * stride
|
||||
return out
|
||||
|
||||
|
||||
def nms(boxes: Any, scores: Any, iou_threshold: float) -> list[int]:
|
||||
"""Greedy class-agnostic non-max suppression over xyxy boxes; returns kept indices."""
|
||||
import numpy as np
|
||||
|
||||
if len(boxes) == 0:
|
||||
return []
|
||||
order = scores.argsort()[::-1]
|
||||
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
|
||||
areas = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None)
|
||||
keep: list[int] = []
|
||||
while order.size > 0:
|
||||
i = int(order[0])
|
||||
keep.append(i)
|
||||
if order.size == 1:
|
||||
break
|
||||
rest = order[1:]
|
||||
xx1 = np.maximum(x1[i], x1[rest])
|
||||
yy1 = np.maximum(y1[i], y1[rest])
|
||||
xx2 = np.minimum(x2[i], x2[rest])
|
||||
yy2 = np.minimum(y2[i], y2[rest])
|
||||
inter = np.clip(xx2 - xx1, 0, None) * np.clip(yy2 - yy1, 0, None)
|
||||
iou = inter / (areas[i] + areas[rest] - inter + 1e-9)
|
||||
order = rest[iou <= iou_threshold]
|
||||
return keep
|
||||
|
||||
|
||||
def vehicles_from_output(
|
||||
raw: Any, size: int, scale: float, min_confidence: float, iou_threshold: float = 0.45
|
||||
) -> list[Detection]:
|
||||
"""Full post-processing: decode → vehicle classes only → confidence floor → NMS →
|
||||
boxes in ORIGINAL frame pixels."""
|
||||
import numpy as np
|
||||
|
||||
dec = decode(raw, size)
|
||||
cls_scores = dec[:, 5:]
|
||||
cls_idx = cls_scores.argmax(axis=1)
|
||||
score = dec[:, 4] * cls_scores[np.arange(len(dec)), cls_idx]
|
||||
wanted = np.isin(cls_idx, list(COCO_VEHICLE_CLASSES)) & (score >= min_confidence)
|
||||
if not wanted.any():
|
||||
return []
|
||||
d = dec[wanted]
|
||||
s = score[wanted]
|
||||
c = cls_idx[wanted]
|
||||
boxes = np.stack(
|
||||
(d[:, 0] - d[:, 2] / 2, d[:, 1] - d[:, 3] / 2, d[:, 0] + d[:, 2] / 2, d[:, 1] + d[:, 3] / 2), axis=1
|
||||
)
|
||||
keep = nms(boxes, s, iou_threshold)
|
||||
out: list[Detection] = []
|
||||
for i in keep:
|
||||
b = boxes[i] / scale
|
||||
out.append(
|
||||
Detection(
|
||||
body_type=COCO_VEHICLE_CLASSES[int(c[i])],
|
||||
confidence=float(s[i]),
|
||||
x1=float(b[0]),
|
||||
y1=float(b[1]),
|
||||
x2=float(b[2]),
|
||||
y2=float(b[3]),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def pick_vehicle(detections: list[Detection], plate: BBox | None) -> Detection | None:
|
||||
"""ONE vehicle per frame: the box holding the plate's centre (the car that was read —
|
||||
a lane frame can show the car behind too), else the largest box (nearest the camera)."""
|
||||
if not detections:
|
||||
return None
|
||||
if plate is not None:
|
||||
cx = (plate.x1 + plate.x2) / 2
|
||||
cy = (plate.y1 + plate.y2) / 2
|
||||
holders = [d for d in detections if d.contains(cx, cy)]
|
||||
if holders:
|
||||
return min(holders, key=lambda d: d.area) # the tightest box around the plate
|
||||
return max(detections, key=lambda d: d.area)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# The ONNX Runtime detector
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class YoloxVehicleDetector:
|
||||
"""YOLOX ONNX on onnxruntime (CPU). Loads once; a load failure is surfaced through
|
||||
`error` and the stage simply yields no vehicle (never breaks the plate path)."""
|
||||
|
||||
def __init__(self, model_path: str, input_size: int = 640, min_confidence: float = 0.4) -> None:
|
||||
self._path = Path(model_path)
|
||||
self._size = input_size
|
||||
self._min_confidence = min_confidence
|
||||
self._session = None
|
||||
self._input_name = "images"
|
||||
self._error: str | None = None
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
|
||||
opts = ort.SessionOptions()
|
||||
opts.intra_op_num_threads = 2 # one frame per entry; leave cores to the lane
|
||||
self._session = ort.InferenceSession(
|
||||
str(self._path), sess_options=opts, providers=["CPUExecutionProvider"]
|
||||
)
|
||||
self._input_name = self._session.get_inputs()[0].name
|
||||
except Exception as exc: # noqa: BLE001 - not-ready, never fatal
|
||||
self._error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
@property
|
||||
def model_version(self) -> str:
|
||||
return f"yolox:{self._path.name}@{self._size}"
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self._session is not None
|
||||
|
||||
@property
|
||||
def error(self) -> str | None:
|
||||
return self._error
|
||||
|
||||
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
|
||||
if self._session is None:
|
||||
return None
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
frame = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||
if frame is None:
|
||||
return None
|
||||
return self.detect_frame(frame, plate)
|
||||
|
||||
def detect_frame(self, frame: Any, plate: BBox | None) -> VehicleResult | None:
|
||||
"""Same as detect() on an already-decoded BGR frame (the classifier stage decodes
|
||||
once and shares it)."""
|
||||
if self._session is None:
|
||||
return None
|
||||
tensor, scale = letterbox(frame, self._size)
|
||||
raw = self._session.run(None, {self._input_name: tensor})[0][0]
|
||||
found = vehicles_from_output(raw, self._size, scale, self._min_confidence)
|
||||
best = pick_vehicle(found, plate)
|
||||
if best is None:
|
||||
return None
|
||||
h, w = frame.shape[:2]
|
||||
box = BBox(
|
||||
x1=max(0, int(best.x1)), y1=max(0, int(best.y1)), x2=min(w, int(best.x2)), y2=min(h, int(best.y2))
|
||||
)
|
||||
return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4), bbox=box)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# Phase B: the body-type classifier on the detector's crop
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
SIDECAR_FORMAT = "parking-bodytype/1"
|
||||
|
||||
|
||||
def crop_vehicle(frame: Any, box: BBox, plate: BBox | None, margin: float) -> Any:
|
||||
"""The detector's box + margin, plate blurred — the SAME cut the collector stores
|
||||
(apps/server review-outbox.ts makeReviewCrop), so the classifier sees at the booth
|
||||
what it was trained on. Returns a BGR array, or None when the box is degenerate."""
|
||||
import cv2
|
||||
|
||||
h, w = frame.shape[:2]
|
||||
mw = round((box.x2 - box.x1) * margin)
|
||||
mh = round((box.y2 - box.y1) * margin)
|
||||
left, top = max(0, box.x1 - mw), max(0, box.y1 - mh)
|
||||
right, bottom = min(w, box.x2 + mw), min(h, box.y2 + mh)
|
||||
if right - left < 8 or bottom - top < 8:
|
||||
return None
|
||||
crop = frame[top:bottom, left:right].copy()
|
||||
if plate is not None:
|
||||
pad = round(max(plate.x2 - plate.x1, plate.y2 - plate.y1) * 0.25)
|
||||
pl, pt = max(0, plate.x1 - pad - left), max(0, plate.y1 - pad - top)
|
||||
pr, pb = min(right - left, plate.x2 + pad - left), min(bottom - top, plate.y2 + pad - top)
|
||||
if pr - pl >= 2 and pb - pt >= 2:
|
||||
sigma = max(6, round((pr - pl) / 6))
|
||||
crop[pt:pb, pl:pr] = cv2.GaussianBlur(crop[pt:pb, pl:pr], (0, 0), sigma)
|
||||
return crop
|
||||
|
||||
|
||||
class BodyTypeClassifier:
|
||||
"""`bodytype.onnx` + its `bodytype.json` sidecar (written by apps/trainer). The sidecar
|
||||
carries the preprocessing contract — class list, input size, crop margin — and the graph
|
||||
normalises internally, so this side only cuts, resizes (INTER_AREA, like the trainer)
|
||||
and feeds raw RGB 0–255. Load failure → `error`, the stage yields nothing."""
|
||||
|
||||
def __init__(self, model_path: str, min_confidence: float = 0.6) -> None:
|
||||
import json
|
||||
|
||||
self._path = Path(model_path)
|
||||
self.min_confidence = min_confidence
|
||||
self._session = None
|
||||
self._input_name = "image"
|
||||
self._error: str | None = None
|
||||
self.classes: list[str] = []
|
||||
self.version = "?"
|
||||
self.input_size = 224
|
||||
self.crop_margin = 0.08
|
||||
try:
|
||||
side = json.loads(self._path.with_suffix(".json").read_text())
|
||||
if side.get("format") != SIDECAR_FORMAT:
|
||||
raise ValueError(f"unknown sidecar format {side.get('format')!r}")
|
||||
self.classes = [str(c) for c in side["classes"]]
|
||||
self.version = str(side.get("version", "?"))
|
||||
self.input_size = int(side.get("input_size", 224))
|
||||
self.crop_margin = float(side.get("crop_margin", 0.08))
|
||||
import onnxruntime as ort
|
||||
|
||||
opts = ort.SessionOptions()
|
||||
opts.intra_op_num_threads = 2
|
||||
self._session = ort.InferenceSession(
|
||||
str(self._path), sess_options=opts, providers=["CPUExecutionProvider"]
|
||||
)
|
||||
self._input_name = self._session.get_inputs()[0].name
|
||||
except Exception as exc: # noqa: BLE001 - not-ready, never fatal
|
||||
self._error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
@property
|
||||
def model_version(self) -> str:
|
||||
return f"bodytype:{self.version}"
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self._session is not None
|
||||
|
||||
@property
|
||||
def error(self) -> str | None:
|
||||
return self._error
|
||||
|
||||
def classify(self, frame: Any, box: BBox, plate: BBox | None) -> tuple[str, float] | None:
|
||||
"""(class, probability) for the vehicle in `box`, or None when nothing could be cut."""
|
||||
if self._session is None:
|
||||
return None
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
crop = crop_vehicle(frame, box, plate, self.crop_margin)
|
||||
if crop is None:
|
||||
return None
|
||||
resized = cv2.resize(crop, (self.input_size, self.input_size), interpolation=cv2.INTER_AREA)
|
||||
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
|
||||
x = np.ascontiguousarray(rgb.transpose(2, 0, 1)[None].astype(np.float32))
|
||||
logits = self._session.run(None, {self._input_name: x})[0][0]
|
||||
z = logits - logits.max()
|
||||
p = np.exp(z) / np.exp(z).sum()
|
||||
i = int(p.argmax())
|
||||
return self.classes[i], float(p[i])
|
||||
|
||||
|
||||
class RefinedVehicleDetector:
|
||||
"""Detector + classifier. The detector finds the vehicle (and picks WHICH one); when its
|
||||
class is `car` — or one the classifier was trained on — the classifier's answer replaces
|
||||
it if confident enough, else the detector's stands. A truck or bus the classifier has
|
||||
never seen is left alone: its softmax on an unknown thing means nothing."""
|
||||
|
||||
def __init__(self, detector: Any, classifier: BodyTypeClassifier) -> None:
|
||||
self._detector = detector
|
||||
self._classifier = classifier
|
||||
self.stage_error: str | None = None
|
||||
|
||||
@property
|
||||
def model_version(self) -> str:
|
||||
return f"{self._detector.model_version}+{self._classifier.model_version}"
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return bool(getattr(self._detector, "ready", True))
|
||||
|
||||
@property
|
||||
def error(self) -> str | None:
|
||||
parts = [
|
||||
getattr(self._detector, "error", None),
|
||||
f"classifier: {self._classifier.error}" if self._classifier.error else None,
|
||||
f"classifier: {self.stage_error}" if self.stage_error else None,
|
||||
]
|
||||
kept = [p for p in parts if p]
|
||||
return "; ".join(kept) if kept else None
|
||||
|
||||
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
frame = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||
if frame is None:
|
||||
return None
|
||||
detect_frame = getattr(self._detector, "detect_frame", None)
|
||||
base: VehicleResult | None = (
|
||||
detect_frame(frame, plate) if detect_frame else self._detector.detect(image_bytes, plate)
|
||||
)
|
||||
if base is None or base.bbox is None or not self._classifier.ready:
|
||||
return base
|
||||
if not (base.body_type == "car" or base.body_type in self._classifier.classes):
|
||||
return base
|
||||
try:
|
||||
out = self._classifier.classify(frame, base.bbox, plate)
|
||||
except Exception as exc: # noqa: BLE001 - advisory stage, never fatal
|
||||
self.stage_error = f"{type(exc).__name__}: {exc}"
|
||||
return base
|
||||
if out is None:
|
||||
return base
|
||||
body_type, confidence = out
|
||||
if confidence < self._classifier.min_confidence:
|
||||
return base.model_copy(update={"detector_class": base.body_type})
|
||||
return base.model_copy(
|
||||
update={
|
||||
"body_type": body_type,
|
||||
"confidence": round(confidence, 4),
|
||||
"detector_class": base.body_type,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def time_detect(
|
||||
detector: VehicleDetector, image_bytes: bytes, plate: BBox | None
|
||||
) -> tuple[VehicleResult | None, float]:
|
||||
"""detect() with wall time in ms (for logs/benchmarks)."""
|
||||
started = time.perf_counter()
|
||||
result = detector.detect(image_bytes, plate)
|
||||
return result, (time.perf_counter() - started) * 1000.0
|
||||
@@ -13,6 +13,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
|
||||
import { ActiveSessions } from "./ActiveSessions.js";
|
||||
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||
import { tillOfEvent } from "@parking/shared";
|
||||
|
||||
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
||||
@@ -246,7 +247,7 @@ export function BoothScreen() {
|
||||
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: [...qk.events, shiftStart ?? "none"],
|
||||
queryFn: () => fetchEvents(100, shiftStart ?? undefined),
|
||||
queryFn: () => fetchEvents(100, shiftStart ?? undefined, undefined, "booth"),
|
||||
enabled: shiftOpen,
|
||||
});
|
||||
|
||||
@@ -275,13 +276,15 @@ export function BoothScreen() {
|
||||
|
||||
// Merge: live events first (newest), then the queried history, de-duped by id —
|
||||
// then clip to the current shift window (the live store spans shifts; the feed
|
||||
// must not show events from before this shift's start). No shift → no feed.
|
||||
// must not show events from before this shift's start) and to the BOOTH till (the
|
||||
// socket also pushes wash-desk events to anyone with carwash:read; they are the wash
|
||||
// shift's activity, not this one's — tillOfEvent). No shift → no feed.
|
||||
const seen = new Set(liveFeed.map((e) => e.id));
|
||||
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||
const merged = [...liveFeed, ...history].slice(0, 200);
|
||||
const scoped =
|
||||
shiftOpen && shiftStart
|
||||
? merged.filter((e) => e.occurredAt >= shiftStart)
|
||||
? merged.filter((e) => e.occurredAt >= shiftStart && tillOfEvent(e.type, e.payload) === "booth")
|
||||
: [];
|
||||
|
||||
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
||||
|
||||
@@ -177,8 +177,8 @@ function StatePanel({ till }: { till: TillId }) {
|
||||
function TodayPanel({ till }: { till: TillId }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({
|
||||
queryKey: ["drawer", "today"],
|
||||
queryFn: () => fetchEvents(1000, startOfToday()),
|
||||
queryKey: ["drawer", "today", till],
|
||||
queryFn: () => fetchEvents(1000, startOfToday(), undefined, till),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { MODULES, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared";
|
||||
import { MODULES, jobsBehind, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared";
|
||||
|
||||
// Role management (admin). Compose a role from the permission grid (a checkbox
|
||||
// matrix of resource × action) and name it; users are then assigned a role. The
|
||||
@@ -27,6 +27,11 @@ import { MODULES, tillsFor, type JobPreset, type ModuleId, type TillId } from "@
|
||||
// fine-tune + enforcement layer. The editor LINTS the result (warnings, never blocks):
|
||||
// "mixes desks" (may open more than one till) and "partial job" (holds a module's read
|
||||
// permission but not the rest of its job — a desk that can look but not act).
|
||||
//
|
||||
// A role REMEMBERS the jobs it follows (chips on at save, or bundles fully present). When
|
||||
// a later release grows a job, the role shows as "behind" it — in the list (with a
|
||||
// one-click re-apply) and in the editor — instead of silently falling short the way the
|
||||
// wash operator's price list did (2026-09-06). Every save is signed on the ledger.
|
||||
|
||||
/** Group "resource:action" permissions by resource for the grid rows. */
|
||||
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
||||
@@ -111,8 +116,17 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
<span className="text-[0.6875rem] text-term-muted">
|
||||
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||||
</span>
|
||||
{behindOf(r).map((b) => (
|
||||
<span key={b.job} className="rounded-term border border-term-amber/60 px-1.5 py-0.5 text-[0.625rem] text-term-amber" title={b.missing.join(", ")}>
|
||||
{t("roles.behind", { job: t(`jobs.${b.job}`) })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canUpdate && !r.builtin && behindOf(r).length > 0 && (
|
||||
<button type="button" className="btn btn-primary btn-sm" title={behindOf(r).flatMap((b) => b.missing).join(", ")}
|
||||
onClick={() => reapply(r, invalidate, onError)}>{t("roles.reapply")}</button>
|
||||
)}
|
||||
{canUpdate && !r.builtin && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
|
||||
)}
|
||||
@@ -133,15 +147,30 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown)
|
||||
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
|
||||
}
|
||||
|
||||
/** The jobs a role follows that have grown past it (this release's bundles). */
|
||||
function behindOf(r: ManagedRole): { job: string; missing: Permission[] }[] {
|
||||
const has = new Set(r.permissions);
|
||||
return jobsBehind(r.jobs ?? [], (p) => has.has(p));
|
||||
}
|
||||
|
||||
/** Re-apply = add what the followed jobs now carry. Nothing is removed; the save is
|
||||
* signed like any other role edit. */
|
||||
async function reapply(r: ManagedRole, ok: () => void, onError: (e: unknown) => void) {
|
||||
const missing = behindOf(r).flatMap((b) => b.missing);
|
||||
try { await updateRole(r.id, { permissions: [...new Set([...r.permissions, ...missing])] }); ok(); } catch (e) { onError(e); }
|
||||
}
|
||||
|
||||
/** The jobs the composer offers: every effective module's, in registry order. */
|
||||
function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] {
|
||||
return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job })));
|
||||
}
|
||||
|
||||
/** Composer lints — warnings about what the admin just composed. */
|
||||
function lintRole(perms: Set<Permission>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
|
||||
function lintRole(perms: Set<Permission>, jobs: Set<string>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
|
||||
const has = (p: Permission) => perms.has(p);
|
||||
const out: { key: string; vars?: Record<string, string> }[] = [];
|
||||
// Behind a job it follows: the bundle grew (a newer release) past what the role holds.
|
||||
for (const b of jobsBehind([...jobs], has)) out.push({ key: "roles.lintJobBehind", vars: { job: b.job, missing: b.missing.join(", ") } });
|
||||
// Mixes desks: may OPEN more than one till.
|
||||
const workable: TillId[] = tillsFor(effective, has, "shift");
|
||||
if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } });
|
||||
@@ -165,11 +194,14 @@ function RoleEditor({
|
||||
grouped: Record<string, Permission[]>;
|
||||
effective: readonly ModuleId[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
||||
onSubmit: (v: { name: string; permissions: Permission[]; jobs: string[] }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(role?.name ?? "");
|
||||
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? []));
|
||||
// The jobs this role follows: what was remembered, plus (at save) any bundle that is
|
||||
// fully present — so a role composed before jobs were remembered picks them up.
|
||||
const [jobIds, setJobIds] = useState<Set<string>>(new Set(role?.jobs ?? []));
|
||||
const toggle = (p: Permission) =>
|
||||
setPerms((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -177,15 +209,24 @@ function RoleEditor({
|
||||
return next;
|
||||
});
|
||||
const jobs = useMemo(() => jobsFor(effective), [effective]);
|
||||
const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
|
||||
const toggleJob = (job: JobPreset) =>
|
||||
const complete = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
|
||||
const jobOn = (job: JobPreset) => jobIds.has(job.id) || complete(job);
|
||||
const toggleJob = (job: JobPreset) => {
|
||||
const on = jobOn(job);
|
||||
setJobIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
on ? next.delete(job.id) : next.add(job.id);
|
||||
return next;
|
||||
});
|
||||
setPerms((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (job.permissions.every((p) => prev.has(p))) for (const p of job.permissions) next.delete(p);
|
||||
if (on) for (const p of job.permissions) next.delete(p);
|
||||
else for (const p of job.permissions) next.add(p);
|
||||
return next;
|
||||
});
|
||||
const lints = useMemo(() => lintRole(perms, effective), [perms, effective]);
|
||||
};
|
||||
const lints = useMemo(() => lintRole(perms, jobIds, effective), [perms, jobIds, effective]);
|
||||
const followed = () => jobs.filter(({ job }) => jobIds.has(job.id) || complete(job)).map(({ job }) => job.id);
|
||||
|
||||
const valid = name.trim().length > 0;
|
||||
|
||||
@@ -244,7 +285,7 @@ function RoleEditor({
|
||||
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}>{t("common.save")}</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms], jobs: followed() })}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js";
|
||||
@@ -161,6 +161,15 @@ function CloseShiftConfirm({
|
||||
<>
|
||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||||
{/* Module money that rode the ticket (a booth-paid wash) — only when any did. */}
|
||||
{Object.entries(x.chargesByModuleMinor ?? {})
|
||||
.filter(([, v]) => (v ?? 0) > 0)
|
||||
.map(([m, v]) => (
|
||||
<Fragment key={m}>
|
||||
<ConfirmFigure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={fmt(v ?? 0)} />
|
||||
<span />
|
||||
</Fragment>
|
||||
))}
|
||||
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||
<span />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -91,6 +91,7 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workabl
|
||||
subscriptionTotalMinor: x.subscriptionTotalMinor,
|
||||
subscriptionSalesMinor: x.subscriptionSalesMinor,
|
||||
subscriptionWindowMinor: x.subscriptionWindowMinor,
|
||||
chargesByModuleMinor: x.chargesByModuleMinor,
|
||||
openingFloatMinor: x.openingFloatMinor,
|
||||
cashAddedMinor: x.cashAddedMinor,
|
||||
cashRemovedMinor: x.cashRemovedMinor,
|
||||
@@ -332,6 +333,24 @@ function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s:
|
||||
);
|
||||
}
|
||||
|
||||
/** One figure per module whose money rode this till's tickets (a booth-paid wash) —
|
||||
* nothing when none did, so booth-only sites see the report they always saw. `spacer`
|
||||
* keeps a 2-column grid's pairs aligned. */
|
||||
function ChargeFigures({ charges, cur, spacer }: { charges?: Partial<Record<string, number>>; cur: string | null; spacer?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = Object.entries(charges ?? {}).filter(([, v]) => (v ?? 0) > 0);
|
||||
return (
|
||||
<>
|
||||
{rows.map(([m, v]) => (
|
||||
<Fragment key={m}>
|
||||
<Figure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={money(v ?? 0, cur)} />
|
||||
{spacer && <span />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftActivityLog({
|
||||
shift,
|
||||
isCurrent,
|
||||
@@ -356,9 +375,10 @@ function ShiftActivityLog({
|
||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||
|
||||
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
|
||||
// Per till: the booth's log has no wash-desk activity in it, and vice versa.
|
||||
const q = useQuery({
|
||||
queryKey: ["shift-events", shift.id, shift.endedAt],
|
||||
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt),
|
||||
queryKey: ["shift-events", shift.id, shift.endedAt, shift.till],
|
||||
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt, shift.till),
|
||||
refetchInterval: isCurrent ? 5000 : false,
|
||||
});
|
||||
const events = q.data?.events ?? [];
|
||||
@@ -389,6 +409,7 @@ function ShiftActivityLog({
|
||||
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} />
|
||||
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||
@@ -446,6 +467,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
<span />
|
||||
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
||||
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
||||
<ChargeFigures charges={report.chargesByModuleMinor} cur={report.currency} spacer />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
||||
@@ -472,6 +494,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} spacer />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||
@@ -513,6 +536,7 @@ function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void })
|
||||
<span />
|
||||
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
||||
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
||||
<ChargeFigures charges={x.chargesByModuleMinor} cur={x.currency} spacer />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
||||
|
||||
+13
-4
@@ -213,6 +213,9 @@ export interface ManagedRole {
|
||||
name: string;
|
||||
builtin: boolean;
|
||||
permissions: Permission[];
|
||||
/** The manifest JOBS this role follows (composed from their chips). A later release
|
||||
* that grows a job shows the role as "behind" it — re-applied with one click. */
|
||||
jobs: string[];
|
||||
userCount: number;
|
||||
}
|
||||
|
||||
@@ -241,10 +244,10 @@ export function deleteUser(id: string): Promise<{ ok: boolean }> {
|
||||
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
|
||||
return apiFetch("/api/roles");
|
||||
}
|
||||
export function createRole(body: { name: string; permissions: Permission[] }): Promise<ManagedRole> {
|
||||
export function createRole(body: { name: string; permissions: Permission[]; jobs?: string[] }): Promise<ManagedRole> {
|
||||
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
|
||||
export function updateRole(id: string, body: { name?: string; permissions?: Permission[]; jobs?: string[] }): Promise<ManagedRole> {
|
||||
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function deleteRole(id: string): Promise<{ ok: boolean }> {
|
||||
@@ -1096,6 +1099,9 @@ export interface ShiftSourceSplit {
|
||||
subscriptionTotalMinor: number;
|
||||
subscriptionSalesMinor: number;
|
||||
subscriptionWindowMinor: number;
|
||||
/** Module money that rode this till's tickets (a booth-paid wash), by module id.
|
||||
* Inside cash+card, OUTSIDE the ticket bucket. Absent on pre-2026-09 reports. */
|
||||
chargesByModuleMinor?: Partial<Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface ShiftReport extends ShiftSourceSplit {
|
||||
@@ -1331,7 +1337,7 @@ export interface DeviceStatus {
|
||||
category: "access" | "reader" | "camera" | "printer" | "vision";
|
||||
/** Role/direction token for the footer label (NOT the vendor) — the client
|
||||
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
|
||||
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||||
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
|
||||
state: "ready" | "degraded" | "offline";
|
||||
detail?: string;
|
||||
checkedAt: string;
|
||||
@@ -1351,15 +1357,18 @@ export type { AppLogRecord };
|
||||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||||
* scopes to events at/after that instant — the booth passes the current shift's
|
||||
* start so the feed shows ONLY this shift's activity. */
|
||||
* start so the feed shows ONLY this shift's activity. `till` keeps one till's activity
|
||||
* (the server applies the shared tillOfEvent rule) — a shift's log is per till. */
|
||||
export function fetchEvents(
|
||||
limit = 100,
|
||||
since?: string,
|
||||
until?: string,
|
||||
till?: TillId,
|
||||
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (since) qs.set("since", since);
|
||||
if (until) qs.set("until", until);
|
||||
if (till) qs.set("till", till);
|
||||
return apiFetch(`/api/events?${qs.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 @@ 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",
|
||||
reviewTitle: "Remote review",
|
||||
reviewOff: "off — no collector configured for this booth",
|
||||
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.",
|
||||
visionClassesHint: "The camera's fixed vocabulary (set in code, not here). Tick the classes this category covers.",
|
||||
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",
|
||||
@@ -232,6 +256,7 @@ export const en: Catalog = {
|
||||
mixed: "entry/exit",
|
||||
lane: "at lane",
|
||||
booth: "at booth",
|
||||
wash: "at wash desk",
|
||||
},
|
||||
state: {
|
||||
ready: "ready",
|
||||
@@ -384,6 +409,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)",
|
||||
@@ -916,6 +942,9 @@ export const en: Catalog = {
|
||||
jobsHint: "A job adds its permissions in one click; fine-tune below. Tap it again to remove them.",
|
||||
lintMixedTills: "This role can open more than one till ({{tills}}) — one person, two drawers. Intended?",
|
||||
lintPartialJob: "Partial \"{{job}}\": missing {{missing}} — this desk can look but not act.",
|
||||
lintJobBehind: "Behind \"{{job}}\": this release added {{missing}} to the job. Tap the job chip off and on to take it, or tick it below.",
|
||||
behind: "behind {{job}}",
|
||||
reapply: "Update to job",
|
||||
},
|
||||
jobs: {
|
||||
"booth-operator": "Booth operator",
|
||||
@@ -960,6 +989,7 @@ export const en: Catalog = {
|
||||
card: "Card:",
|
||||
srcTickets: "Tickets:",
|
||||
srcSubscriptions: "Subscriptions:",
|
||||
srcOnTicket: "{{module}} (on ticket):",
|
||||
srcSubWindow: "out-of-window",
|
||||
drawerSection: "— Drawer —",
|
||||
openingFloat: "Opening cash:",
|
||||
|
||||
@@ -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,17 @@ 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",
|
||||
reviewTitle: "Shqyrtim në distancë",
|
||||
reviewOff: "joaktiv — asnjë mbledhës i konfiguruar për këtë kabinë",
|
||||
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.",
|
||||
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",
|
||||
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",
|
||||
@@ -235,6 +258,7 @@ export const sq = {
|
||||
mixed: "hyrje/dalje",
|
||||
lane: "në korsi",
|
||||
booth: "në kabinë",
|
||||
wash: "në lavazh",
|
||||
},
|
||||
state: {
|
||||
ready: "gati",
|
||||
@@ -388,6 +412,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)",
|
||||
@@ -930,6 +955,9 @@ export const sq = {
|
||||
jobsHint: "Një punë shton lejet e saj me një klik; rregulloji poshtë. Kliko sërish për t'i hequr.",
|
||||
lintMixedTills: "Ky rol mund të hapë më shumë se një arkë ({{tills}}) — një person, dy arka. E qëllimshme?",
|
||||
lintPartialJob: "\"{{job}}\" e pjesshme: mungojnë {{missing}} — kjo tavolinë sheh, por nuk vepron.",
|
||||
lintJobBehind: "Pas \"{{job}}\": ky version i shtoi punës {{missing}}. Hiqe dhe rivendose punën për t'i marrë, ose shënoji poshtë.",
|
||||
behind: "pas {{job}}",
|
||||
reapply: "Përditëso sipas punës",
|
||||
},
|
||||
jobs: {
|
||||
"booth-operator": "Operator kabine",
|
||||
@@ -974,6 +1002,7 @@ export const sq = {
|
||||
card: "Kartë:",
|
||||
srcTickets: "Bileta:",
|
||||
srcSubscriptions: "Abonime:",
|
||||
srcOnTicket: "{{module}} (në biletë):",
|
||||
srcSubWindow: "jashtë orarit",
|
||||
drawerSection: "— Arka —",
|
||||
openingFloat: "Arka fillestare:",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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";
|
||||
import { fetchCarwashReviewStatus, fetchCarwashSettings, saveCarwashSettings, type CarwashReviewStatus, type CarwashSettingsView } from "./api.js";
|
||||
|
||||
// Setup → Car wash: the master data (vehicle categories, services, the category ×
|
||||
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
|
||||
// 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,34 @@ 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). The chips
|
||||
* show the CANONICAL ids (the model's fixed vocabulary, a code constant), never a
|
||||
* translation, so they read as what they are: not site text (user, 2026-09-06). */
|
||||
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 +71,26 @@ 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" title={t("wash.visionClassesHint")}>{t("wash.visionClasses")}</span>
|
||||
{VEHICLE_CLASSES.map((cls) => {
|
||||
const on = (it.visionClasses ?? []).includes(cls);
|
||||
return (
|
||||
<button
|
||||
key={cls}
|
||||
type="button"
|
||||
className={`btn btn-sm font-mono lowercase ${on ? "btn-primary" : "btn-ghost"}`}
|
||||
title={t(`vehicleClass.${cls}`)}
|
||||
onClick={() => toggleClass(i, cls)}
|
||||
>
|
||||
{cls}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
|
||||
+ {addLabel}
|
||||
@@ -73,21 +109,26 @@ 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);
|
||||
const [review, setReview] = useState<CarwashReviewStatus | null>(null);
|
||||
|
||||
function load() {
|
||||
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));
|
||||
fetchCarwashReviewStatus().then(setReview).catch(() => {});
|
||||
fetchValidationPrograms()
|
||||
.then((r) => {
|
||||
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
|
||||
@@ -103,7 +144,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 +174,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);
|
||||
@@ -155,10 +199,13 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
const currency = settings?.currency ?? "";
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||
<section className="card w-full max-w-2xl p-4">
|
||||
// Two columns on a wide screen: the master data (categories with their camera-class
|
||||
// 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">
|
||||
<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 +262,27 @@ 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>
|
||||
{review && (
|
||||
<div className="field">
|
||||
<span className="label">{t("wash.reviewTitle")}</span>
|
||||
<span className="text-[0.75rem] tabular-nums">
|
||||
{review.enabled
|
||||
? t("wash.reviewCounts", { queued: review.queued, sent: review.sent, failed: review.failed })
|
||||
: 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>}
|
||||
</span>
|
||||
<span className="hint">{t("wash.reviewHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -226,7 +294,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
</section>
|
||||
|
||||
{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>
|
||||
<span className="hint">{t("wash.sponsorshipHint")}</span>
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -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,36 @@ 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;
|
||||
}
|
||||
|
||||
/** The review outbox's health (Setup → Car wash). */
|
||||
export interface CarwashReviewStatus {
|
||||
enabled: boolean;
|
||||
boothId: string | null;
|
||||
queued: number;
|
||||
sent: number;
|
||||
failed: number;
|
||||
lastSentAt: 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> {
|
||||
return apiFetch("/api/carwash/review/status");
|
||||
}
|
||||
|
||||
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# The Car Wash REVIEW COLLECTOR — deployed on the REVIEWER's host (art-docker-station), NOT
|
||||
# on a booth. Its own Komodo stack ("wash-collector" in komodo/resources.toml) points at this
|
||||
# file alone, so nothing here reaches a booth and nothing of the booth stack reaches this
|
||||
# host. See wiki/concepts/vision-review-outbox.md.
|
||||
#
|
||||
# Reachability: booths POST to /ingest over the Netbird overlay only. Bind the published
|
||||
# port to the host's OVERLAY address (COLLECTOR_BIND), never 0.0.0.0 on a host that also
|
||||
# has a public interface. The Netbird policy should allow booths → this host:8090 and
|
||||
# nothing else on it.
|
||||
|
||||
services:
|
||||
collector:
|
||||
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-collector:${TAG:-dev}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${COLLECTOR_BIND:-127.0.0.1}:8090:8090"
|
||||
environment:
|
||||
# "<boothId>:<token>" pairs — one per booth, the booth's CARWASH_REVIEW_TOKEN under its
|
||||
# pseudonymous CARWASH_REVIEW_BOOTH_ID. A Komodo secret reference in the stack env.
|
||||
COLLECTOR_BOOTH_TOKENS: ${COLLECTOR_BOOTH_TOKENS:?set COLLECTOR_BOOTH_TOKENS in the stack env}
|
||||
# The single reviewer login (HTTP Basic over the overlay).
|
||||
COLLECTOR_REVIEWER_USER: ${COLLECTOR_REVIEWER_USER:-reviewer}
|
||||
COLLECTOR_REVIEWER_PASS: ${COLLECTOR_REVIEWER_PASS:?set COLLECTOR_REVIEWER_PASS in the stack env}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
# The trainer's job API (sibling service above). Unset = no Training section.
|
||||
COLLECTOR_TRAINER_URL: ${COLLECTOR_TRAINER_URL-http://trainer:8091}
|
||||
volumes:
|
||||
- collector-data:/data
|
||||
|
||||
# Phase B trainer — a small always-on job service beside the collector (CPU-only torch;
|
||||
# idle it is a tiny Python HTTP server, torch loads only when a job runs). It reads the
|
||||
# collector's SQLite + crops off the same volume (read-only) and keeps models, reports and
|
||||
# job logs in its own volume. NOT published: only the collector reaches it, on this compose
|
||||
# network, and the reviewer's login on the collector is the gate. The Training section of
|
||||
# /review is its UI (readiness, Train / Evaluate / Publish, reports, logs).
|
||||
# See wiki/decisions/bodytype-classifier-training.md.
|
||||
trainer:
|
||||
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-trainer:${TAG:-dev}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Where `publish` PUTs a passing model (a Gitea generic package) and the token it uses
|
||||
# (package:write). Only publishing needs the token; training runs without it.
|
||||
TRAINER_PUBLISH_URL: ${TRAINER_PUBLISH_URL:-https://git.infra.msai.al/api/packages/mca/generic/parking-bodytype}
|
||||
TRAINER_PUBLISH_TOKEN: ${TRAINER_PUBLISH_TOKEN:-}
|
||||
volumes:
|
||||
- collector-data:/data:ro
|
||||
- trainer-out:/out
|
||||
|
||||
volumes:
|
||||
collector-data:
|
||||
trainer-out:
|
||||
@@ -28,6 +28,12 @@ services:
|
||||
# off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it
|
||||
# offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md.
|
||||
BACKUP_KEY: ${BACKUP_KEY:-}
|
||||
# Car Wash review outbox: collector URL + per-booth token + pseudonymous booth id, all
|
||||
# three or off. See apps/server/.env.example + wiki/concepts/vision-review-outbox.md.
|
||||
CARWASH_REVIEW_URL: ${CARWASH_REVIEW_URL:-}
|
||||
CARWASH_REVIEW_TOKEN: ${CARWASH_REVIEW_TOKEN:-}
|
||||
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,
|
||||
# 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".
|
||||
@@ -55,6 +61,9 @@ services:
|
||||
environment:
|
||||
# Engine: stub (no models) by default; prod override sets fast_alpr.
|
||||
VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub}
|
||||
# Vehicle stage (Car Wash category suggestion): the image bakes YOLOX-S at this path.
|
||||
# Set the var to an EMPTY value in the stack env to switch the stage off.
|
||||
VISION_VEHICLE_MODEL_PATH: ${VISION_VEHICLE_MODEL_PATH-/app/models/yolox_s.onnx}
|
||||
networks:
|
||||
- parking
|
||||
|
||||
|
||||
+51
-1
@@ -85,12 +85,21 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-2aa1045
|
||||
TAG=stage-dbbb051
|
||||
COOKIE_SECURE=0
|
||||
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
|
||||
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
|
||||
# park-2 pilots the Car Wash module (2026-09-05).
|
||||
MODULES_ENTITLED=parking,carwash
|
||||
# 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
|
||||
# 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_BOOTH_ID=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
|
||||
# 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
|
||||
@@ -100,3 +109,44 @@ JWT_SECRET=[[park_2_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||
BACKUP_KEY=[[park_2_backup_key]]
|
||||
"""
|
||||
|
||||
##############################################################################
|
||||
# Stack — the Car Wash REVIEW COLLECTOR on the reviewer's host (art-docker-station),
|
||||
# NOT a booth. Same repo/branch/TAG promotion as the booths, but its file_paths name
|
||||
# ONLY docker-compose.collector.yml, so nothing booth-side lands here and nothing here
|
||||
# lands on a booth. Booths reach it over the Netbird overlay only (bind to the overlay
|
||||
# address). Its own secrets. See wiki/concepts/vision-review-outbox.md.
|
||||
##############################################################################
|
||||
|
||||
[[stack]]
|
||||
name = "wash-collector"
|
||||
[stack.config]
|
||||
server = "art-docker-station"
|
||||
git_provider = "git.infra.msai.al"
|
||||
git_account = "komodo"
|
||||
repo = "mca/parking_solution"
|
||||
branch = "stage"
|
||||
file_paths = [
|
||||
"docker-compose.collector.yml"
|
||||
]
|
||||
registry_provider = "git.infra.msai.al"
|
||||
registry_account = "komodo"
|
||||
environment = """
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Pinned like the booths: bump to the stage-<sha> that carries the collector.
|
||||
TAG=stage-f7a262a
|
||||
# The host's NETBIRD address (an IP: Docker port bindings take no hostname) — the ingest port
|
||||
# is published on the overlay only. Booths reach it by its Netbird DNS name.
|
||||
COLLECTOR_BIND=100.75.184.156
|
||||
# "<boothId>:<token>" pairs, one per booth. ONE secret per booth, referenced here AND in
|
||||
# that booth's own stack as its CARWASH_REVIEW_TOKEN — one value, two consumers, nothing
|
||||
# to keep in sync, and rotating a booth touches one secret. The booth id is the booth's
|
||||
# pseudonymous CARWASH_REVIEW_BOOTH_ID, never a site name. Add a pair per booth.
|
||||
COLLECTOR_BOOTH_TOKENS=booth-2:[[wash_review_token_booth_2]]
|
||||
# Phase-B trainer (the `trainer` service beside the collector; the Training section of /review
|
||||
# is its UI). Only `publish` needs this: a Gitea token with package:write for the model's generic
|
||||
# package. Uncomment when the first model is to be published.
|
||||
#TRAINER_PUBLISH_TOKEN=[[gitea_package_write_token]]
|
||||
COLLECTOR_REVIEWER_USER=reviewer
|
||||
COLLECTOR_REVIEWER_PASS=[[wash_collector_reviewer_pass]]
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Roles remember the manifest JOBS they were composed from (venue-modules.md §"Permissions
|
||||
-- matrix", move 2) so a role built from a job chip can be flagged and re-applied when a
|
||||
-- later release grows that job's bundle. The permission grid stays the enforcement layer.
|
||||
CREATE TABLE `role_jobs` (
|
||||
`role_id` text NOT NULL,
|
||||
`job_id` text NOT NULL,
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `role_jobs_role_id_job_id_unique` ON `role_jobs` (`role_id`,`job_id`);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Car Wash: advisory vehicle category from vision (venue-modules.md §Vehicle category from
|
||||
-- vision). Categories map the vision vocabulary onto the site's own price categories; an
|
||||
-- order records what the camera saw, the category it suggested and the anomaly signed on a
|
||||
-- downgrade; the config carries the confidence floor. Recorded only — never blocks.
|
||||
ALTER TABLE `carwash_categories` ADD `vision_classes` text DEFAULT '[]' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `carwash_orders` ADD `vision_class` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `carwash_orders` ADD `vision_confidence` real;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `carwash_orders` ADD `vision_category_id` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `carwash_orders` ADD `downgrade_event_id` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `carwash_config` ADD `vision_threshold` real DEFAULT 0.8 NOT NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Car Wash review outbox (wiki/concepts/vision-review-outbox.md): plate-blurred vehicle
|
||||
-- crops + the operator's category choice, queued for a trusted remote reviewer and drained
|
||||
-- one-way over the private overlay. The image is cleared once delivered.
|
||||
CREATE TABLE `carwash_review_outbox` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`order_id` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`status` text DEFAULT 'queued' NOT NULL,
|
||||
`attempts` integer DEFAULT 0 NOT NULL,
|
||||
`next_attempt_at` text,
|
||||
`last_error` text,
|
||||
`sent_at` text,
|
||||
`image` blob,
|
||||
`payload` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `carwash_review_outbox_status_idx` ON `carwash_review_outbox` (`status`,`next_attempt_at`);
|
||||
@@ -204,6 +204,27 @@
|
||||
"when": 1788605000000,
|
||||
"tag": "0028_carwash_config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "6",
|
||||
"when": 1788690000000,
|
||||
"tag": "0029_role_jobs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "6",
|
||||
"when": 1788700000000,
|
||||
"tag": "0030_carwash_vision",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "6",
|
||||
"when": 1788710000000,
|
||||
"tag": "0031_carwash_review_outbox",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { blob, integer, primaryKey, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import { blob, integer, primaryKey, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Schema notes:
|
||||
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
||||
@@ -53,6 +53,24 @@ export const rolePermissions = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
/** The JOBS a role follows (venue-modules.md §"Permissions matrix", move 2): the
|
||||
* manifest job presets the admin composed it from. Remembered so a later release that
|
||||
* grows a job's bundle can be surfaced ("this role is behind the Wash operator job")
|
||||
* and re-applied with one click — never expanded silently at runtime: what a role may
|
||||
* do is always the explicit `role_permissions` grid. */
|
||||
export const roleJobs = sqliteTable(
|
||||
"role_jobs",
|
||||
{
|
||||
roleId: text("role_id")
|
||||
.notNull()
|
||||
.references(() => roles.id),
|
||||
jobId: text("job_id").notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
uniq: unique().on(t.roleId, t.jobId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull().unique(),
|
||||
@@ -635,6 +653,9 @@ export const carwashCategories = sqliteTable("carwash_categories", {
|
||||
name: text("name").notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
/** The vision vocabulary classes this category covers (JSON array of VehicleClass) —
|
||||
* the site's own mapping ("car, sedan → Vetura"). Empty = never suggested by vision. */
|
||||
visionClasses: text("vision_classes", { mode: "json" }).$type<string[]>().notNull().default(sql`'[]'`),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
@@ -705,6 +726,13 @@ export const carwashOrders = sqliteTable("carwash_orders", {
|
||||
voidAt: text("void_at"),
|
||||
voidBy: text("void_by"),
|
||||
voidReason: text("void_reason"),
|
||||
// Vision, advisory (venue-modules.md §Vehicle category): what the camera saw at entry,
|
||||
// the category the site mapping suggested, and the `anomaly` signed when the operator
|
||||
// chose a cheaper category above the confidence threshold. Never a tariff input.
|
||||
visionClass: text("vision_class"),
|
||||
visionConfidence: real("vision_confidence"),
|
||||
visionCategoryId: text("vision_category_id"),
|
||||
downgradeEventId: text("downgrade_event_id"),
|
||||
});
|
||||
|
||||
/** Module-level settings singleton (id = 1). `payAt`: where wash money is taken at this
|
||||
@@ -712,10 +740,32 @@ export const carwashOrders = sqliteTable("carwash_orders", {
|
||||
export const carwashConfig = sqliteTable("carwash_config", {
|
||||
id: integer("id").primaryKey(),
|
||||
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull().default("booth"),
|
||||
/** Confidence floor (0–1) for a vision class to flag a category downgrade. */
|
||||
visionThreshold: real("vision_threshold").notNull().default(0.8),
|
||||
updatedAt: text("updated_at"),
|
||||
updatedBy: text("updated_by"),
|
||||
});
|
||||
|
||||
/** Car Wash REVIEW OUTBOX (wiki/concepts/vision-review-outbox.md): the operator's category
|
||||
* choice is a hypothesis, not truth — each wash order with a vehicle read queues a
|
||||
* plate-blurred vehicle CROP + the choice for a trusted remote reviewer, drained one-way
|
||||
* over the private overlay when it is up. Never blocks the wash; nothing that names the
|
||||
* site leaves the booth. The image is dropped once delivered. */
|
||||
export const carwashReviewOutbox = sqliteTable("carwash_review_outbox", {
|
||||
id: text("id").primaryKey(),
|
||||
orderId: text("order_id").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
status: text("status", { enum: ["queued", "sent", "failed"] }).notNull().default("queued"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
nextAttemptAt: text("next_attempt_at"),
|
||||
lastError: text("last_error"),
|
||||
sentAt: text("sent_at"),
|
||||
/** The JPEG crop (plate blurred). Null once sent. */
|
||||
image: blob("image").$type<Buffer>(),
|
||||
/** What the collector receives beside the image (no site name, no plate, no operator name). */
|
||||
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
});
|
||||
|
||||
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
|
||||
export type CarwashServiceRow = typeof carwashServices.$inferSelect;
|
||||
export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
|
||||
|
||||
@@ -772,7 +772,7 @@ export function transportLabel(t: Transport): string {
|
||||
// --- shared driver config fields ----------------------------------------------
|
||||
// Role + failover are identical across ESC/POS printers; defined here so each
|
||||
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
|
||||
|
||||
// --- shared printer config fields (transport) ---------------------------------
|
||||
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
|
||||
|
||||
@@ -128,8 +128,9 @@ const roleField: ConfigField = {
|
||||
label: "Entry dispenser (outside / at the lane)",
|
||||
},
|
||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
|
||||
],
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
|
||||
};
|
||||
|
||||
const rankField: ConfigField = {
|
||||
|
||||
@@ -262,8 +262,9 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
}
|
||||
}
|
||||
|
||||
/** Type guard: does this device carry a printer role (entry vs. booth)? */
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
||||
/** Where a printer sits: at the lane (entry tickets), in the booth (receipts, reports,
|
||||
* the backup for entry tickets) or at the wash desk (the Car Wash till's slips). */
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
|
||||
|
||||
const roleField: ConfigField = {
|
||||
key: "role",
|
||||
@@ -277,8 +278,9 @@ const roleField: ConfigField = {
|
||||
label: "Entry dispenser (outside / at the lane)",
|
||||
},
|
||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
|
||||
],
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
|
||||
};
|
||||
|
||||
const rankField: ConfigField = {
|
||||
|
||||
@@ -27,6 +27,7 @@ export { stamp as formatStampSq } from "./drivers/printer-escpos.js";
|
||||
export {
|
||||
orderForRole,
|
||||
printWithFailover,
|
||||
printerRoleOf,
|
||||
NoPrinterAvailableError,
|
||||
type PrinterInstance,
|
||||
} from "./printer-routing.js";
|
||||
|
||||
@@ -27,6 +27,19 @@ describe("orderForRole", () => {
|
||||
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
|
||||
});
|
||||
|
||||
it("wash-desk job: the desk printer first, the booth printer as fallback, never the dispenser", () => {
|
||||
const printers = [inst("disp", "entry-dispenser"), inst("booth", "booth-receipt"), inst("desk", "wash-desk")];
|
||||
expect(orderForRole(printers, "wash-desk").map((p) => p.id)).toEqual(["desk", "booth"]);
|
||||
// A site without a desk printer keeps printing wash slips in the booth.
|
||||
expect(orderForRole(printers.slice(0, 2), "wash-desk").map((p) => p.id)).toEqual(["booth"]);
|
||||
});
|
||||
|
||||
it("nothing ever falls back TO the wash desk (booth receipts and entry tickets stay off it)", () => {
|
||||
const printers = [inst("desk", "wash-desk")];
|
||||
expect(orderForRole(printers, "booth-receipt")).toEqual([]);
|
||||
expect(orderForRole(printers, "entry-dispenser")).toEqual([]);
|
||||
});
|
||||
|
||||
it("breaks ties by failoverRank (higher first), then id", () => {
|
||||
const printers = [
|
||||
inst("b", "entry-dispenser", 1),
|
||||
|
||||
@@ -26,13 +26,16 @@ export interface PrinterInstance {
|
||||
* printer is also a fallback for entry tickets, so when an entry ticket is
|
||||
* routed, booth-receipt printers follow the entry dispensers. The reverse is
|
||||
* deliberately NOT done — a receipt never prints on the outside dispenser.
|
||||
* The wash desk's slips (its till's Z-report and vouchers) fall back to the booth
|
||||
* printer the same way — a site without a desk printer keeps printing them in the
|
||||
* booth, as it did before the role existed. Nothing ever falls back TO the wash desk.
|
||||
*/
|
||||
export function orderForRole(
|
||||
printers: readonly PrinterInstance[],
|
||||
wantRole: PrinterRole,
|
||||
): PrinterInstance[] {
|
||||
const fallbackRole: PrinterRole | null =
|
||||
wantRole === "entry-dispenser" ? "booth-receipt" : null;
|
||||
wantRole === "entry-dispenser" || wantRole === "wash-desk" ? "booth-receipt" : null;
|
||||
|
||||
const rank = (p: PrinterInstance): number => {
|
||||
if (p.role === wantRole) return 2;
|
||||
@@ -49,6 +52,14 @@ export function orderForRole(
|
||||
});
|
||||
}
|
||||
|
||||
/** The role a printer's saved config declares — ONE reading of the field, so a
|
||||
* wash-desk printer is never mistaken for an entry dispenser by a loader that only
|
||||
* knew two roles. Unknown/absent = entry-dispenser (the field's default). */
|
||||
export function printerRoleOf(cfg: { role?: unknown } | null | undefined): PrinterRole {
|
||||
const r = cfg?.role;
|
||||
return r === "booth-receipt" || r === "wash-desk" ? r : "entry-dispenser";
|
||||
}
|
||||
|
||||
export class NoPrinterAvailableError extends Error {
|
||||
constructor(public readonly attempts: { id: string; error: string }[]) {
|
||||
super(
|
||||
|
||||
@@ -446,6 +446,10 @@ export const REASON_CODES = [
|
||||
// ticket (e.g. a motion radar dropped the stationary car and re-armed the button).
|
||||
// Post-hoc + advisory (ANPR never gates); the operator voids the duplicate.
|
||||
"entry.duplicatePlate",
|
||||
// Car Wash: vision read the vehicle as a class that maps to a PRICIER category than the
|
||||
// one the operator chose, above the site's confidence threshold. Recorded only (never
|
||||
// blocks, no reason prompt — user, 2026-09-06); the reviewer sees both on one row.
|
||||
"carwash.categoryDowngrade",
|
||||
// exit refusals
|
||||
"exit.refused.closed",
|
||||
"exit.refused.noSession",
|
||||
@@ -498,6 +502,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
||||
"entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)",
|
||||
"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 — no open session for ticket",
|
||||
"exit.refused.unpaid": "exit refused — not paid (take payment first)",
|
||||
@@ -1926,6 +1931,42 @@ export function watchPermissions(effective: readonly ModuleId[]): Permission[] {
|
||||
}
|
||||
|
||||
/** Which tills a role may work more than one of — the composer's "mixes desks" lint. */
|
||||
/** The till an event's ACTIVITY belongs to, for a shift's log: a money event names its
|
||||
* till (`tillOf`); any other event belongs to the till of the module that owns its type
|
||||
* (a `carwash_order` is wash-desk activity even though no money moved); everything
|
||||
* else — entries, exits, barrier commands, pre-till events — is the booth's. The
|
||||
* server's `/api/events?till=` filter and the web feeds share this one rule. */
|
||||
export function tillOfEvent(type: LedgerEventType, payload: { till?: TillId } | null | undefined): TillId {
|
||||
if (payload?.till) return payload.till;
|
||||
const m = MODULES.find((x) => x.ledgerEventTypes.includes(type));
|
||||
return m?.till ?? BOOTH_TILL;
|
||||
}
|
||||
|
||||
/** A job preset by id, with the module that declares it (null = no such job — e.g. a
|
||||
* job remembered by a role whose module was removed from the registry). */
|
||||
export function jobById(id: string): { module: ModuleId; job: JobPreset } | null {
|
||||
for (const m of MODULES) for (const job of m.jobs) if (job.id === id) return { module: m.id, job };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The jobs a role FOLLOWS whose bundle has grown past what the role holds: the role
|
||||
* was built from the chip, a later release added a permission to the job, and the
|
||||
* role fell behind. The admin re-applies with one click (or drops the job); the grid
|
||||
* is never expanded silently. Jobs no longer in the registry are ignored. */
|
||||
export function jobsBehind(
|
||||
jobs: readonly string[],
|
||||
has: (p: Permission) => boolean,
|
||||
): { job: string; missing: Permission[] }[] {
|
||||
const out: { job: string; missing: Permission[] }[] = [];
|
||||
for (const id of jobs) {
|
||||
const found = jobById(id);
|
||||
if (!found) continue;
|
||||
const missing = found.job.permissions.filter((p) => !has(p));
|
||||
if (missing.length > 0) out.push({ job: id, missing });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
|
||||
return tillsFor(effective, has, "shift");
|
||||
}
|
||||
@@ -1988,8 +2029,48 @@ export interface ChargeLine {
|
||||
|
||||
/** Setup → Car wash: the admin-maintained master data, as read/written by
|
||||
* GET/PUT /api/carwash/settings. Ids are stable; names are display text. */
|
||||
/** What the vision service may call a vehicle's body type — a FIXED vocabulary the site
|
||||
* maps onto its own price categories (Setup → Car wash: "car, sedan, hatchback → Vetura").
|
||||
* Phase A (a COCO detector) only ever emits car/truck/bus/motorcycle; the finer classes
|
||||
* arrive with the body-type classifier (venue-modules.md §Vehicle category from vision). */
|
||||
export const VEHICLE_CLASSES = [
|
||||
"car", "sedan", "hatchback", "suv", "minivan", "pickup", "van", "truck", "bus", "motorcycle",
|
||||
] as const;
|
||||
export type VehicleClass = (typeof VEHICLE_CLASSES)[number];
|
||||
export function isVehicleClass(v: unknown): v is VehicleClass {
|
||||
return typeof v === "string" && (VEHICLE_CLASSES as readonly string[]).includes(v);
|
||||
}
|
||||
/** Below this confidence a vision class is shown but never flags a downgrade. Site
|
||||
* config (Setup → Car wash); this is the default. */
|
||||
export const CARWASH_VISION_THRESHOLD_DEFAULT = 0.8;
|
||||
|
||||
/** The advisory vehicle read for a session, off the entry snapshot (unsigned device
|
||||
* event, like the plate). Never a tariff input by itself. */
|
||||
export interface VehicleRead {
|
||||
readonly bodyType: VehicleClass;
|
||||
readonly confidence: number;
|
||||
readonly snapshotId: string | null;
|
||||
/** The vehicle's box and the plate's box as FRACTIONS of the frame (0–1), so they fit
|
||||
* any resized copy of the snapshot. Absent on reads made before boxes were kept. */
|
||||
readonly box?: NormBox | null;
|
||||
readonly plateBox?: NormBox | null;
|
||||
}
|
||||
|
||||
/** A box as fractions of the frame it was found in (x1,y1 top-left; 0–1). */
|
||||
export interface NormBox {
|
||||
readonly x1: number;
|
||||
readonly y1: number;
|
||||
readonly x2: number;
|
||||
readonly y2: number;
|
||||
}
|
||||
export function isNormBox(v: unknown): v is NormBox {
|
||||
if (!v || typeof v !== "object") return false;
|
||||
const b = v as Record<string, unknown>;
|
||||
return ["x1", "y1", "x2", "y2"].every((k) => typeof b[k] === "number" && (b[k] as number) >= 0 && (b[k] as number) <= 1);
|
||||
}
|
||||
|
||||
export interface CarwashSettingsView {
|
||||
readonly categories: { id: string; name: string; sortOrder: number; active: boolean }[];
|
||||
readonly categories: { id: string; name: string; sortOrder: number; active: boolean; visionClasses: VehicleClass[] }[];
|
||||
readonly services: { id: string; name: string; sortOrder: number; active: boolean }[];
|
||||
/** One entry per priced (category, service) pair. */
|
||||
readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[];
|
||||
@@ -1997,6 +2078,8 @@ export interface CarwashSettingsView {
|
||||
/** Where wash money is taken at this site (booth = on the parking ticket; bay = the
|
||||
* wash operator's till). Site-level; the desk no longer asks per order. */
|
||||
readonly payAt: CarWashPayAt;
|
||||
/** Confidence floor for a vision class to flag a downgrade (0–1). */
|
||||
readonly visionThreshold: number;
|
||||
}
|
||||
|
||||
/** A wash order as the desk sees it (GET /api/carwash/orders). */
|
||||
@@ -2024,6 +2107,12 @@ export interface CarwashOrderView {
|
||||
readonly validationEventId: string | null;
|
||||
readonly voidBy: string | null;
|
||||
readonly voidReason: string | null;
|
||||
/** What the camera saw at entry (advisory), the category it mapped to, and the
|
||||
* anomaly signed when the operator chose a cheaper one. Null when vision read nothing. */
|
||||
readonly visionClass: VehicleClass | null;
|
||||
readonly visionConfidence: number | null;
|
||||
readonly visionCategoryId: string | null;
|
||||
readonly downgradeEventId: string | null;
|
||||
}
|
||||
|
||||
export function isModuleId(v: unknown): v is ModuleId {
|
||||
|
||||
Generated
+54
@@ -18,6 +18,37 @@ importers:
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
|
||||
apps/collector:
|
||||
dependencies:
|
||||
'@fastify/multipart':
|
||||
specifier: ^9.2.1
|
||||
version: 9.4.0
|
||||
'@parking/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
better-sqlite3:
|
||||
specifier: 12.10.1
|
||||
version: 12.10.1
|
||||
fastify:
|
||||
specifier: 5.8.5
|
||||
version: 5.8.5
|
||||
devDependencies:
|
||||
'@types/better-sqlite3':
|
||||
specifier: 7.6.13
|
||||
version: 7.6.13
|
||||
'@types/node':
|
||||
specifier: 25.9.3
|
||||
version: 25.9.3
|
||||
tsx:
|
||||
specifier: 4.22.4
|
||||
version: 4.22.4
|
||||
typescript:
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@25.9.3)(jsdom@25.0.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
|
||||
|
||||
apps/desktop:
|
||||
dependencies:
|
||||
'@tauri-apps/plugin-process':
|
||||
@@ -86,6 +117,8 @@ importers:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@25.9.3)(jsdom@25.0.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
|
||||
|
||||
apps/trainer: {}
|
||||
|
||||
apps/vision: {}
|
||||
|
||||
apps/web:
|
||||
@@ -744,12 +777,18 @@ packages:
|
||||
'@fastify/ajv-compiler@4.0.5':
|
||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||
|
||||
'@fastify/busboy@3.2.2':
|
||||
resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
|
||||
|
||||
'@fastify/cookie@11.0.2':
|
||||
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||
|
||||
'@fastify/deepmerge@3.2.1':
|
||||
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
|
||||
|
||||
'@fastify/error@4.2.0':
|
||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||
|
||||
@@ -765,6 +804,9 @@ packages:
|
||||
'@fastify/merge-json-schemas@0.2.1':
|
||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||
|
||||
'@fastify/multipart@9.4.0':
|
||||
resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==}
|
||||
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||
|
||||
@@ -3380,6 +3422,8 @@ snapshots:
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 3.1.2
|
||||
|
||||
'@fastify/busboy@3.2.2': {}
|
||||
|
||||
'@fastify/cookie@11.0.2':
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
@@ -3390,6 +3434,8 @@ snapshots:
|
||||
fastify-plugin: 5.1.0
|
||||
toad-cache: 3.7.1
|
||||
|
||||
'@fastify/deepmerge@3.2.1': {}
|
||||
|
||||
'@fastify/error@4.2.0': {}
|
||||
|
||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||
@@ -3410,6 +3456,14 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
'@fastify/multipart@9.4.0':
|
||||
dependencies:
|
||||
'@fastify/busboy': 3.2.2
|
||||
'@fastify/deepmerge': 3.2.1
|
||||
'@fastify/error': 4.2.0
|
||||
fastify-plugin: 5.1.0
|
||||
secure-json-parse: 4.1.0
|
||||
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
dependencies:
|
||||
'@fastify/forwarded': 3.0.1
|
||||
|
||||
@@ -17,8 +17,10 @@ Each printer instance (a `devices` row, category `printer`) declares a **role**
|
||||
config:
|
||||
|
||||
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
|
||||
- **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, AND serves as the
|
||||
**backup** for entry tickets.
|
||||
- **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, the booth till's
|
||||
Z-reports and vouchers, AND serves as the **backup** for entry tickets (and for wash slips).
|
||||
- **`wash-desk`** — at the Car Wash desk (added 2026-09-06). Prints the wash till's slips:
|
||||
its Z-report and drawer vouchers (see [[shift]] §Tills). Nothing else ever prints here.
|
||||
|
||||
It also declares a **`failoverRank`** (higher = preferred within a role) to order multiple
|
||||
printers of the same role deterministically (ties broken by id).
|
||||
@@ -33,6 +35,16 @@ The reverse is **deliberately not** done: a **receipt** never prints on the outs
|
||||
Receipts are a booth-only job; an entry dispenser falling back to print receipts makes no
|
||||
physical sense.
|
||||
|
||||
For a **wash slip** (`wantRole = wash-desk`): the desk printers first, then the **booth
|
||||
printer** — a site that has not bought a desk printer keeps printing the wash till's Z-report
|
||||
and vouchers in the booth, exactly as it did before the role existed. Nothing falls back *to*
|
||||
the wash desk: a booth receipt or an entry ticket never prints there. `ShiftService` resolves
|
||||
the role from the till (`TILL_PRINTER_ROLE`: booth → `booth-receipt`, carwash → `wash-desk`)
|
||||
and keeps one legacy fallback — a booth with a single printer that carries no booth role still
|
||||
prints its slips on it. `printerRoleOf(config)` is the one reading of the saved `role` field,
|
||||
so every loader (entry flow, booth receipts, shift slips) agrees on what a printer is; the
|
||||
device footer shows a desk printer as "at wash desk".
|
||||
|
||||
## Where the logic lives
|
||||
|
||||
- The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't
|
||||
|
||||
+29
-2
@@ -305,8 +305,35 @@ manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
|
||||
touch the booth by construction; the header button, the hub's start buttons and the
|
||||
drawer switch never offer a till the server would refuse. (A first cut that borrowed
|
||||
`session:read` as "works the booth till" lived for a few hours and is gone.)
|
||||
- Not done: the per-shift *activity log* is still a time window over the whole chain (money
|
||||
figures are per till, the event list is not); bay slips print on the booth printer.
|
||||
- **The activity log is per till too (2026-09-06).** `tillOfEvent(type, payload)` in
|
||||
`@parking/shared` extends the money rule to every event: a money event names its till, any
|
||||
other event belongs to the till of the module that owns its type (a `carwash_order` is
|
||||
wash-desk activity though no money moved), everything else — entries, exits, barrier
|
||||
commands, pre-till events — is the booth's. `/api/events?till=` applies the same rule in
|
||||
SQL (so the page limit applies after the filter); the hub's shift log, the Drawer "today"
|
||||
panel and the booth feed (history and live pushes) pass their till. The events route also
|
||||
admits a role without `event:read` that holds a module's feed permission, and then returns
|
||||
only that module's event types — the live-socket rule, so a wash operator's hub shows the
|
||||
wash shift's log.
|
||||
- **The booth Z-report breaks module money out (2026-09-06).** `chargesByModuleMinor`
|
||||
(`{ carwash: <minor> }`, only when any was taken) sums the `chargeLines` on the till's
|
||||
payments by owning module; the ticket bucket EXCLUDES it, so `Bileta` is parking money only
|
||||
and ticket + subscriptions + Σcharges = cash + card. Printed as `Lavazh (në biletë): X` on
|
||||
the booth slip; the wash till's own slip prints its takings under `Lavazh:` (it sells no
|
||||
tickets or subscriptions). Signed on `shift_z_report`; older reports read back as `{}`.
|
||||
- **The wash till prints on its own printer (2026-09-06).** Printer role `wash-desk`; the
|
||||
wash till's Z-report and vouchers go there, falling back to the booth printer — see
|
||||
[[printer-roles-failover]].
|
||||
- **Is a desk printer required? (user, 2026-09-06)** No, in either policy. With *Pagesa në
|
||||
kabinë* the wash till takes no money at all — orders ride the parking ticket, the booth
|
||||
prints the receipt and the booth Z carries the wash line — so the wash shift is offered but
|
||||
reconciles nothing (a desk printer would sit idle). With *Pagesa në lavazh* the till's Z and
|
||||
vouchers exist and prefer the desk printer, but fall back to the booth's; printing is
|
||||
best-effort, the signed event is the record. Where a desk printer becomes genuinely
|
||||
required is a piece that does not exist: a **customer receipt at bay payment** (today the
|
||||
customer gets nothing on paper). If built, it prints on the desk only, no fallback, and
|
||||
Setup should warn when the policy is bay payment with no `wash-desk` printer. Also open:
|
||||
hiding the wash till section on the desk when the policy is booth payment.
|
||||
|
||||
## Where the fraud control actually lives
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: Vision review outbox — harvesting the operator's category choice for a trusted reviewer
|
||||
type: concept
|
||||
status: booth side built 2026-09-06; collector pending
|
||||
related: [venue-modules, opencv-anpr-service, threat-model, append-only-event-chain, network-isolation]
|
||||
---
|
||||
|
||||
# Vision review outbox
|
||||
|
||||
**The idea (user, 2026-09-06).** The Car Wash desk asks the operator for the vehicle's category,
|
||||
and the entry camera now proposes one ([[venue-modules]] §Vehicle category from vision). The
|
||||
operator's choice is what we would love to train the body-type classifier on — but the
|
||||
operator **cannot be fully trusted** (mistake or intent; the [[threat-model]]). So the booth
|
||||
hands each decision to a **trusted party** who reviews the picture and the label remotely,
|
||||
and *that* verdict is the training label — and, per operator, the honest-mistake / fraud rate.
|
||||
The booths sit on a private zero-trust overlay (**Netbird**), so the hand-off can go to a very
|
||||
locked-down collector without exposing anything to the open internet.
|
||||
|
||||
## Rules (all enforced in `apps/server/src/modules/carwash/review-outbox.ts`)
|
||||
|
||||
1. **Offline-first, never on the intake path.** Creating a wash order *queues* a package (fire
|
||||
and forget — a failure is a log line); a background loop drains the queue when the overlay
|
||||
is up. The wash never waits on the network.
|
||||
2. **One-way.** The booth POSTs; nothing ever comes back into the booth's decisions. The signed
|
||||
ledger ([[append-only-event-chain]]) stays the only record of what happened at the wash.
|
||||
Reviewer verdicts stay central and reach the owner as a report per site.
|
||||
3. **Nothing that names the site leaves the booth.**
|
||||
- Only the vehicle **crop** (the detector's box + 8 % margin, ≤ 640 px) — no walls, no camera
|
||||
OSD (date / camera name burned into the frame), no bystanders.
|
||||
- The **plate is blurred inside the crop** on the booth, from the plate detector's own box.
|
||||
- The booth is a **pseudonymous id** set at deploy (`CARWASH_REVIEW_BOOTH_ID`); the operator
|
||||
is a **keyed hash** (`sha256(boothId:username)[:16]`). The mapping back to places and
|
||||
people is the reviewer's, held off the collector. The dataset export drops even those.
|
||||
- Boxes are stored as **fractions of the frame** on the vision read, so the crop is cut from
|
||||
the stored (downscaled) snapshot copy.
|
||||
4. **The network is not the auth.** A per-booth bearer token on top of the overlay; the booth
|
||||
can do nothing at the collector but this one POST. Payloads are small (a crop ≈ 50–80 kB).
|
||||
5. **Data minimisation.** Queued only when there is a vehicle box (no box = no sample); the
|
||||
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.
|
||||
|
||||
## 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` = one in N entries; **`1` = every entry, and that is
|
||||
the setting park-2 runs** (user, 2026-09-07: 4 TB on the collector host, bandwidth not an issue —
|
||||
the only limit was ever the reviewer's time; the reviewer labels what they have time for, the
|
||||
rest waits and stays useful once a first model exists, as the unlabelled pile it is measured on).
|
||||
0/unset = off; needs the three upload settings. A washed car arrives twice, as an entry sample
|
||||
and as the wash decision — intended, the `kind` keeps them apart.
|
||||
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
|
||||
|
||||
`multipart/form-data`: `meta` (JSON) + `image` (JPEG). Meta = `{ v, booth, item, order, at,
|
||||
operator (hash), operatorCategory {id,name}, service, vision {class, confidence, categoryId},
|
||||
downgraded, image {width, height, plateBlurred} }`. Headers: `Authorization: Bearer <token>`,
|
||||
`X-Booth-Id`.
|
||||
|
||||
## Draining
|
||||
|
||||
Every `CARWASH_REVIEW_INTERVAL_SEC` (60): due items oldest-first, 20 per pass. `2xx` → sent
|
||||
(image cleared). `400/404/413/415/422` → abandoned (the collector refused the package itself).
|
||||
Anything else (auth not yet fixed, 429, 5xx, timeout, no route) → retry with backoff
|
||||
`1 min · 2^attempts`, capped at 6 h. `GET /api/carwash/review/status` (site:read) and a line in
|
||||
Setup → Car wash show queued / delivered / abandoned + the last error.
|
||||
|
||||
## Config
|
||||
|
||||
`CARWASH_REVIEW_URL`, `CARWASH_REVIEW_TOKEN`, `CARWASH_REVIEW_BOOTH_ID` — all three or the outbox
|
||||
is off and **nothing is queued** (an unbounded queue nobody drains is worse than none). Set per
|
||||
booth in the Komodo stack env; compose forwards them.
|
||||
|
||||
- **URL by Netbird DNS name** (`http://docker-station.nb.infra:8090/ingest`): the server container
|
||||
runs on the host network in prod, so it uses the booth's resolver and Netbird's DNS answers
|
||||
`*.nb.infra`; a collector that moves address costs no booth change. A failed lookup behaves like
|
||||
a collector outage (defer, backoff). The collector's own `COLLECTOR_BIND` must be the raw overlay
|
||||
**IP** — Docker port bindings take no hostname.
|
||||
- **Secrets: one per booth, two consumers.** `wash_review_token_booth_2` is referenced by the
|
||||
booth's stack as its `CARWASH_REVIEW_TOKEN` *and* by the collector's stack inside
|
||||
`COLLECTOR_BOOTH_TOKENS=booth-2:[[wash_review_token_booth_2]],booth-3:[[…]]` — one value, nothing
|
||||
to keep in sync, rotating a booth touches one secret. (A first cut had one combined secret for
|
||||
the whole list; replaced the same day — rotation was all-or-nothing and the value lived twice.)
|
||||
Token format: opaque, `openssl rand -hex 32`; the collector only demands ≥ 16 chars and the list
|
||||
splits on commas/whitespace, which hex never contains. Never share a token between booths — it
|
||||
is what names the booth. Total Komodo secrets for one booth + the collector: two (the booth's
|
||||
token, the reviewer's password).
|
||||
- **The operator hash needs no variable**: `sha256(boothId + ":" + username)[:16]`, computed on
|
||||
the booth from values already set; the owner recomputes it from the booth's usernames to map a
|
||||
hash back, the collector never can.
|
||||
|
||||
## The collector — skeleton built 2026-09-06 (`apps/collector`)
|
||||
|
||||
A deliberately small Fastify + SQLite service **in this monorepo** (so it imports the payload
|
||||
contract and the class vocabulary from `@parking/shared` — the two ends cannot drift), delivered
|
||||
to the reviewer's host by **its own Komodo stack** (`wash-collector` in `komodo/resources.toml`
|
||||
→ `docker-compose.collector.yml` only; the booth stacks never see it and it never sees booth
|
||||
services). Image `parking-collector:<branch>-<sha>` from the same workflow as the others.
|
||||
Three surfaces, nothing else — it must not grow into a fleet console:
|
||||
|
||||
- **`POST /ingest`** — bearer token **per booth** (`COLLECTOR_BOOTH_TOKENS`, `boothId:token`
|
||||
pairs; constant-time compare), `X-Booth-Id` must match the token's booth, multipart `meta` +
|
||||
`image` (JPEG magic checked, 2 MB cap), `meta` validated field by field against the contract
|
||||
above (unknown vision class, non-id item, wrong booth → 422), **idempotent on the item id**
|
||||
(a retry after a lost 2xx → 200 `duplicate`). Stored: `crops/<booth>/<item>.jpg` on the
|
||||
volume + one `items` row. The booth now also sends `operatorCategory.classes` (the classes
|
||||
the chosen category covers at that site) so a reviewer's CLASS can be judged against the
|
||||
operator's CATEGORY without the site's setup.
|
||||
- **`/review`** (+ `/api/items`, `/api/items/:id/image`, `/api/items/:id/review`, `/api/stats`)
|
||||
— the reviewer's screen, served by the process itself (no build, no framework): one pending
|
||||
crop at a time, the operator's pick and the camera's pick beside it, one button (and one
|
||||
key) per vocabulary class + *unusable* + *skip*. HTTP Basic, one login
|
||||
(`COLLECTOR_REVIEWER_USER/PASS`), over the overlay. Stats: per booth received / pending /
|
||||
reviewed; per operator (booth + hash) **agree / disagree / unusable** — disagree = the
|
||||
reviewer's class is outside the operator's chosen category. That column is the honest-mistake
|
||||
/ fraud rate.
|
||||
- **`GET /export/labels.csv`** — reviewed, usable rows: item, booth, crop path, the reviewer's
|
||||
label, the operator's category + classes, the camera's class + confidence, downgraded, at.
|
||||
Crops are not packaged: the phase-B trainer runs **on the same host** and reads the SQLite
|
||||
+ crops straight off the volume, read-only ([[bodytype-classifier-training]]: CPU-only, the
|
||||
Xeon is enough) — the `trainer` service beside the collector in
|
||||
`docker-compose.collector.yml` (the CSV export stays for a human with a spreadsheet).
|
||||
- **Training section on `/review`** (+ `/api/training/status|jobs|jobs/:id|versions/:v/report`)
|
||||
— a thin proxy, behind the same reviewer login, to the trainer's job API on the compose
|
||||
network (`COLLECTOR_TRAINER_URL`, unset = hidden): labels per class vs the minimum, Train
|
||||
(mode / backbone / floor), the running job's log, the versions with Report / Evaluate /
|
||||
Publish. The collector forwards only a fixed set of paths and knobs; the trainer validates
|
||||
values and answers 409 while a job runs.
|
||||
|
||||
**Where the data lives.** The collector writes to `/data` in its container: `collector.sqlite`
|
||||
and one JPEG per item at `crops/<booth-id>/<item-id>.jpg`. `/data` is the named Docker volume
|
||||
`collector-data` (compose), on the host under Docker's volume directory — normally
|
||||
`/var/lib/docker/volumes/wash-collector_collector-data/_data/` (`docker volume inspect
|
||||
wash-collector_collector-data` confirms). The trainer mounts the same volume read-only at its
|
||||
own `/data`; nothing is copied or exported for training.
|
||||
|
||||
**Deploy notes.** Bind the published port to the host's **Netbird address** (`COLLECTOR_BIND`),
|
||||
never `0.0.0.0` on a host with a public interface; Netbird policy: booths → this host:8090 and
|
||||
nothing else. The host must be onboarded as a Komodo server like the booths. `TAG` is pinned
|
||||
and promoted with the booths (one sha for all stacks) — fine while the collector stays small;
|
||||
its own repo the day it needs its own cadence. Deploy the collector BEFORE a booth that sends a
|
||||
package kind it does not know (a 422 is abandoned, not retried). The export neutralises cells
|
||||
that start like a spreadsheet formula (category/service names are booth-supplied text).
|
||||
|
||||
**Status (2026-09-07).** Live: the collector runs on `art-docker-station` and park-2 is wired to
|
||||
it (`stage-dbbb051` on both stacks, every entry sampled). The review screen at
|
||||
`http://docker-station.nb.infra:8090/review` is filling; no labels reviewed yet.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user