20 Commits

Author SHA1 Message Date
julian 29594f8bad chore(desktop): bump version to 0.2.0 — first feature release of the bundle (Car Wash, tills, roles jobs, vision category, review outbox)
Build desktop / desktop (push) Successful in 5m19s
CI / check (push) Successful in 55s
Build & push images / images (push) Successful in 2m51s
Release desktop / bundle (push) Successful in 5m21s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:34:33 +02:00
julian 4ff31557a8 feat(trainer): training from the collector UI — the trainer becomes a job service, the review page gains a Training section
Trainer: `parking-trainer serve` — a stdlib HTTP job API on the compose network (never
published): /health, /readiness, /versions, /versions/<v>/report, /jobs. One job at a
time; each job runs the CLI as a subprocess with its output captured, state + log
persisted under /out/jobs/ so a restart keeps history. `publish` takes its URL from
TRAINER_PUBLISH_URL. Dockerfile: CMD serve, EXPOSE 8091, healthcheck.

Collector: COLLECTOR_TRAINER_URL + /api/training/{status,jobs,jobs/:id,versions/:v/report}
— a reviewer-gated proxy that forwards a fixed set of paths and whitelisted knobs and
passes the trainer's status codes through (409 while a job runs; 503 unconfigured, 502
unreachable). /review gains the Training section: labels per class vs the minimum with
Train disabled until two classes clear it, mode / backbone / floor, the running job's
live log, the versions with Report / Evaluate / Publish (publish confirms), and the
reminder that pinning stays a git commit. Fixed on the way: an apostrophe in the page's
inline script broke the whole page — a test now parses the script.

Compose: `trainer` is a service (restart: unless-stopped, read-only data volume, its own
trainer-out volume), the `train` profile and TRAINER_OUT are gone; the Docker-socket
route was rejected (root on the host for a service booths upload to). Verified with both
images running together: a Train started through the proxy finished, version and report
came back, the page rendered.

Wiki: bodytype-classifier-training (loop, running it, operating notes superseded),
vision-review-outbox, fleet-deployment-komodo, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:34:13 +02:00
julian 3e77a4ad7c docs(wiki): trainer as a compose profile — one running service is expected, first-run pull/login, why the collector TAG bump mattered, park-2 needs no bump until a pin
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 11:30:28 +02:00
julian 3f16925fe0 bump(resources): update stage tag to stage-f7a262a in resources.toml
Build & push images / images (push) Successful in 2m59s
2026-09-07 11:26:28 +02:00
julian f7a262ac9a feat(trainer): phase-B body-type classifier — trainer job on the collector host + the classifier stage on the booth
Build & push images / images (push) Successful in 6m31s
apps/trainer (parking-trainer): inspect / train / evaluate / publish. Reads the wash
collector's SQLite + crops read-only off its volume; time split (validation = newest
slice); thin classes dropped; damped class weights; `features` mode (frozen ImageNet
backbone, on-disk feature cache, seconds to retrain) and `finetune` mode (light
augmentation). CPU-only torch from PyTorch's wheel index. ONNX export checked against
the torch model; NO model file below the validation floor (exit 3, report still written);
exit 2 = not enough labels. `evaluate` scores a shipped model on labels reviewed after
training + the unlabelled pile; `publish` PUTs a version folder to a Gitea generic package.
Light core deps; the `train` extra is heavy — CI syncs without it, torch tests skip.

apps/vision: BodyTypeClassifier (bodytype.onnx + sidecar = the preprocessing contract:
crop margin, input size, RGB 0-255, normalisation inside the graph) and
RefinedVehicleDetector over YOLOX — refines only `car` or a class the classifier trained
on, min-confidence, `detector_class` on the result; path set but no file = phase B off
without an error; a broken file is a health detail. models/bodytype.version (tracked,
empty) pins the published version the Dockerfile fetches at build (BuildKit secret;
a pin that cannot be fetched fails the build). Verified: a trainer model gives identical
probabilities inside the vision service; both images built and smoke-tested.

Delivery: parking-trainer image in build-images.yml, the `trainer` compose profile on the
collector stack (CPU, read-only data, TRAINER_OUT), commented TRAINER_OUT/PUBLISH_TOKEN in
the wash-collector stack, .dockerignore for both Python contexts, trainer deps synced in CI.

Wiki: bodytype-classifier-training rewritten as built (+ one fleet model not per site,
secrets/access, where the crops live), opencv-anpr-service §Phase B, vision-review-outbox,
vision-service-packaging, fleet-deployment-komodo, index, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 11:14:50 +02:00
julian f9cb973fe9 docs(wiki): collector live on park-2; secrets shape, DNS vs bind, token format, every-entry sampling, CI extra rule
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 10:01:39 +02:00
julian 1a0fe59488 docs(wiki): phase B training path and hardware decisions — recorded, not built
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 10:00:18 +02:00
julian 8bfc29db2a bump(resources): update stage tag to stage-dbbb051 in resources.toml for booth park-2 and wash-collector.
Build & push images / images (push) Successful in 3m37s
2026-09-07 09:40:29 +02:00
julian dbbb051ebd feat(carwash): entry-stream sampling for the review outbox; park-2 wired to the collector
Build & push images / images (push) Successful in 4m22s
The wash stream is small; the entry camera photographs every car in exactly the view the
classifier is trained on. The booth can now queue entry vehicle reads as pure training
material — crop + the camera's class, no order, no operator, no category.

- Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the
  Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N)
  and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage
  and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose.
- Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated
  without the operator fields, review screen shows an entry sample as such, export has a
  kind column, operator agreement computed from wash items only. Setup line shows
  "1 in N entries sampled"; status carries entrySample.
- komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2,
  the shared per-booth secret, every entry sampled) — the collector is up on the overlay.
- Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed
  assessment), log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 09:38:55 +02:00
julian 3e57af5abc ui(carwash): Setup → Car wash as a two-column grid — the master-data card takes the width
The wrapping flex row capped the left card at max-w-2xl; once each category row grew its
camera-class chips it was squeezed while the right side sat empty (user, park-2). Now the
master data takes the remaining width and the sponsorship card keeps a fixed readable
column; stacks on narrow screens.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 09:38:55 +02:00
julian ef55d1c6a9 fix(resources): bump stage tag to stage-0411b71 in resources.toml both for park-2 and wash-collector stacks.
Build & push images / images (push) Successful in 3m29s
2026-09-07 08:57:13 +02:00
julian 0411b71c2d fix(vision): CI has no numpy — the vehicle-stage tests need it without the alpr extra
Build & push images / images (push) Successful in 4m7s
CI syncs the service with `uv sync --frozen` (no extra), so test_vehicle.py's module-level
numpy import broke collection in ci.yml and both build-images runs. numpy joins the dev
group (the service imports it lazily); the one test that resizes with OpenCV skips when
cv2 is absent. Reproduced locally in a CI-identical env: 13 passed, 1 skipped; ruff + mypy clean.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 08:51:53 +02:00
julian b485e9870b feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
CI / check (push) Failing after 40s
Build & push images / images (push) Failing after 32s
Build desktop / desktop (push) Successful in 5m24s
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small
Fastify + SQLite service in the monorepo (shares the payload contract and the class
vocabulary via @parking/shared), delivered to art-docker-station by its own stack so
nothing booth-side lands there and nothing of it on a booth.

- POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart
  meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on
  the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row.
- /review + /api/*: the reviewer's screen served by the process (Basic auth, one login):
  one pending crop at a time, operator's pick and camera's pick beside it, one button/key
  per vocabulary class + unusable + skip; stats per booth and per hashed operator
  (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's
  category).
- GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are
  neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host.
- Booth payload now carries operatorCategory.classes so the comparison needs no site setup.
- Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml
  (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build
  step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one
  secret per booth referenced from both the collector's token list and the booth's own
  stack (park-2 lines templated, commented, DNS name for the URL).
- Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image
  built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 08:26:22 +02:00
julian ec44547122 docs(wiki): close the session's loose threads — bay printer question, vocabulary-in-code decision, training note, guard family, OQ #16 settled
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 22:37:57 +02:00
julian e67f0ccef0 feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash
order with a vehicle read queues a package for a trusted reviewer over the private overlay
(Netbird); the verdict becomes the phase-B training label and the per-operator error rate.
wiki/concepts/vision-review-outbox.md.

- Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and
  plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a
  downscaled copy); vehicleForIdentity() returns them.
- carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 %
  margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a
  pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no
  bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped);
  400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided
  orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured.
- Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every
  CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close.
- GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash.
- Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in
  .env.example and forwarded by compose.
- Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms,
  queue/drain/backoff/abandon, through the app). Wiki: new concept page, index,
  venue-modules As built, log. The collector is not built.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 22:33:43 +02:00
julian 78ca58d264 ui(carwash): mapping chips show the camera's canonical class ids, not translations
The vocabulary is a code constant (the model's contract), the site only maps it; a chip
reading VETURË beside a site category named Veture blurred exactly that (user, 2026-09-06).
Translation stays as the tooltip; a hint names where the list lives.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 21:25:08 +02:00
julian 20a3cb3e80 feat(vision): vehicle stage, phase A — YOLOX-S (Apache-2.0 ONNX) beside the plate recognizer
Fills /analyze vehicle.body_type + confidence (car / motorcycle / bus / truck from COCO,
mapped to the shared vocabulary) for the Car Wash desk's category suggestion
(venue-modules.md §Vehicle category from vision). Advisory: the operator decides, a
confident downgrade is flagged, nothing is gated on it.

- vision_service/vehicle.py: pure numpy/cv2 letterbox (pad 114, raw BGR), stride-grid
  decode, class-agnostic NMS, one vehicle per frame (the box holding the plate's centre,
  else the largest); YoloxVehicleDetector on onnxruntime CPU, 2 intra-op threads.
- recognizer.py: WithVehicle composes the stage over any plate recognizer (stub included);
  a failing stage yields vehicle=null + a "vehicle: …" note in /health.detail — never
  costs the plate read. model_version reads "<plate>+yolox:yolox_s.onnx@640".
- settings: VISION_VEHICLE_MODEL_PATH (unset = off), _INPUT_SIZE (640), _MIN_CONFIDENCE
  (0.4, the detector's floor; the flag threshold is site config).
- Dockerfile bakes yolox_s.onnx (best-effort curl at build; no network → stage off) and
  sets the path; compose forwards it (empty = off); .env.example documents it.
- Measured on four real dev entry frames (DS-2CD1047G3H, 2560×1440): car at 0.83–0.88 in
  ~240–330 ms; empty lane with a person → none.
- tests/test_vehicle.py: decode/NMS/pick/letterbox on synthetic tensors, the composition,
  and a missing-model /health. Wiki: opencv-anpr-service, venue-modules, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 19:53:11 +02:00
julian 5e1395db18 feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the
open half (no bundled recognizer emits body_type yet, so the desk shows nothing until
phase A lands in the vision service).

- Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT,
  reason code carwash.categoryDowngrade; settings/order/lookup views carry the read.
- Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node
  client normalises to the vocabulary and drops the rest.
- Record: snapshot.ts stores the read in the plate's device_events row (or its own when
  the plate was unreadable); vehicleForIdentity() resolves it like the plate.
- Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"),
  carwash_config.vision_threshold (signed config_change when it moves), four vision
  columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId.
- Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup
  offers class chips per category and the threshold. Operator decides.
- Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen
  one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on
  the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no
  reason prompt (user, 2026-09-06).

Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 13:37:34 +02:00
julian 50c18405b6 feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix):

- `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from
  (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in
  @parking/shared surface a followed job whose bundle grew past the role in a later
  release; the roles list shows a "behind <job>" badge with a one-click "Update to job"
  (the union, nothing removed) and the editor lints it. Never a runtime union: the grid
  stays the explicit enforcement layer and an update never widens a role without a click.
- Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
  name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes
  now takes the ledger.
- booth-supervisor already carries subscription:*; the stale open note is closed.

Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:52:47 +02:00
julian e14e31a840 feat(tills): per-till activity log, wash bucket on the booth Z-report, wash-desk printer role
Closes the three known follow-ups of the Tills decision (venue-modules.md):

- Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events
  by payload till, other events by their owning module's till, everything else booth),
  applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today"
  panel and the booth feed (history + live pushes). The events route admits a role that
  holds a module feed permission without event:read and returns only that module's
  event types — the live-socket rule.
- Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by
  module; the ticket bucket excludes them (Bileta = parking only); printed
  "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:".
- Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling
  back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the
  one reading of the role field (the entry/booth loaders treated any non-booth role as
  an entry dispenser). Footer label "at wash desk".

Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new
requireAnyPermission) — the Wash operator job could not load the desk's category and
service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules,
log) updated.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:37:26 +02:00
110 changed files with 8616 additions and 159 deletions
+37 -1
View File
@@ -1,6 +1,6 @@
name: Build & push images 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, # 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). # 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 # Mirrors the house pattern (cf. trm/processor build.yml). See
@@ -13,6 +13,8 @@ on:
- 'apps/server/**' - 'apps/server/**'
- 'apps/web/**' - 'apps/web/**'
- 'apps/vision/**' - 'apps/vision/**'
- 'apps/collector/**'
- 'apps/trainer/**'
- 'packages/**' - 'packages/**'
- 'package.json' - 'package.json'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
@@ -60,6 +62,11 @@ jobs:
working-directory: apps/vision working-directory: apps/vision
run: uv sync --frozen 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. # Don't publish a broken image — run the same checks as ci.yml first.
- name: Build + lint + test (Turbo) - name: Build + lint + test (Turbo)
run: pnpm turbo run build lint test run: pnpm turbo run build lint test
@@ -100,18 +107,47 @@ jobs:
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max 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) - name: Build & push VISION (ANPR)
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: apps/vision context: apps/vision
file: apps/vision/Dockerfile file: apps/vision/Dockerfile
push: true 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: | tags: |
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }} ${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }} ${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache,mode=max 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 # 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. # KOMODO_* secrets; left guarded so it no-ops until the parking stack is wired.
- name: Trigger Komodo redeploy - name: Trigger Komodo redeploy
+5
View File
@@ -48,6 +48,11 @@ jobs:
working-directory: apps/vision working-directory: apps/vision
run: uv sync --frozen 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) - name: Build + lint (Turbo)
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en # 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. # key fails the build), AND the vision service's ruff lint via uv.
+16
View File
@@ -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
+48
View File
@@ -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"]
+28
View File
@@ -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"
}
}
+153
View File
@@ -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/);
});
});
+291
View File
@@ -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;
}
+40
View File
@@ -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,
};
}
+186
View File
@@ -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));
}
}
+16
View File
@@ -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());
+234
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>`;
}
+126
View File
@@ -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);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["src/**/*.test.ts"], env: { LOG_LEVEL: "silent" } },
});
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Parking System", "productName": "Parking System",
"version": "0.1.0", "version": "0.2.0",
"identifier": "com.parking.desktop", "identifier": "com.parking.desktop",
"build": { "build": {
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
+17
View File
@@ -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 # 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. # (parking) are always on. See wiki/decisions/venue-modules.md.
#MODULES_ENTITLED=parking,validation #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
+1
View File
@@ -15,6 +15,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/server/package.json apps/server/ COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/ COPY apps/web/package.json apps/web/
COPY apps/vision/package.json apps/vision/ COPY apps/vision/package.json apps/vision/
COPY apps/collector/package.json apps/collector/
COPY packages/db/package.json packages/db/ COPY packages/db/package.json packages/db/
COPY packages/devices/package.json packages/devices/ COPY packages/devices/package.json packages/devices/
COPY packages/shared/package.json packages/shared/ COPY packages/shared/package.json packages/shared/
+3
View File
@@ -84,6 +84,7 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 }, plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
plates: [], plates: [],
lowConfidence: false, lowConfidence: false,
vehicle: null,
modelVersion: "test", modelVersion: "test",
tookMs: 1, tookMs: 1,
}; };
@@ -177,6 +178,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] }, plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [], plates: [],
lowConfidence: false, lowConfidence: false,
vehicle: null,
modelVersion: "test", modelVersion: "test",
tookMs: 1, tookMs: 1,
})), })),
@@ -207,6 +209,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] }, plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [], plates: [],
lowConfidence: false, lowConfidence: false,
vehicle: null,
modelVersion: "test", modelVersion: "test",
tookMs: 1, tookMs: 1,
})), })),
+16
View File
@@ -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 — * preHandler that requires a valid signed-in session but NO specific permission —
* for "about me" routes (/me, change own language) every authenticated user may * for "about me" routes (/me, change own language) every authenticated user may
+2 -1
View File
@@ -6,6 +6,7 @@ import {
type PrinterInstance, type PrinterInstance,
type ReceiptData, type ReceiptData,
type TicketHeader, type TicketHeader,
printerRoleOf,
} from "@parking/devices"; } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js"; import { devicesByDirection } from "./device-resolve.js";
@@ -41,7 +42,7 @@ function loadPrinters(db: Db): PrinterInstance[] {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) continue; if (!driver) continue;
const cfg = row.config as Record<string, unknown>; const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; const role = printerRoleOf(cfg);
try { try {
out.push({ out.push({
id: row.id, id: row.id,
+22 -3
View File
@@ -1,6 +1,7 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices"; import type { PrinterStatus } from "@parking/devices";
import type { LedgerEventRow } from "@parking/db"; import type { LedgerEventRow } from "@parking/db";
import type { VehicleRead } from "@parking/shared";
// Internal event bus for device-originated events (button presses, etc.). // Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry // Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -52,7 +53,7 @@ export interface ReadOutcome {
export interface PrinterStatusEvent { export interface PrinterStatusEvent {
readonly deviceId: string; // devices id readonly deviceId: string; // devices id
readonly driverId: string; readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
@@ -74,10 +75,10 @@ export interface DeviceStatusEvent {
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina": * chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay) * - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[]) * - 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) * - 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 state: "ready" | "degraded" | "offline";
readonly detail?: string; readonly detail?: string;
readonly checkedAt: string; // ISO-8601 readonly checkedAt: string; // ISO-8601
@@ -105,6 +106,17 @@ export interface PlateRecognizedEvent {
readonly direction: "entry" | "exit"; readonly direction: "entry" | "exit";
} }
/** Emitted when vision classified the vehicle in an entry/exit frame (advisory; stored on
* the read row like the plate). A module may sample these — the Car Wash review outbox
* queues one in N ENTRY reads for the remote reviewer, in the gate view the classifier
* will be trained on (wiki/concepts/vision-review-outbox.md). The core emits; it never
* knows who listens. */
export interface VehicleReadEvent {
readonly identity: string;
readonly direction: "entry" | "exit";
readonly read: VehicleRead;
}
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the /** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has * entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink: * confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
@@ -197,6 +209,13 @@ class DeviceEventBus extends EventEmitter {
this.on("plate-recognized", cb); this.on("plate-recognized", cb);
return () => this.off("plate-recognized", cb); return () => this.off("plate-recognized", cb);
} }
emitVehicleRead(event: VehicleReadEvent): void {
this.emit("vehicle-read", event);
}
onVehicleRead(cb: (event: VehicleReadEvent) => void): () => void {
this.on("vehicle-read", cb);
return () => this.off("vehicle-read", cb);
}
} }
/** Process-wide device event bus. */ /** Process-wide device event bus. */
+2 -1
View File
@@ -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) * - reader/camera → the direction inherited from its bound relay (entry/exit/both)
* - access → entry/exit/both from its relays[]; "mixed" if it spans more * - access → entry/exit/both from its relays[]; "mixed" if it spans more
* than one direction; null if it declares none yet * 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"] { function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) { switch (row.category) {
@@ -89,6 +89,7 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
const role = (row.config as { role?: string }).role; const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth"; if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane"; if (role === "entry-dispenser") return "lane";
if (role === "wash-desk") return "wash";
return null; return null;
} }
default: default:
+2 -1
View File
@@ -9,6 +9,7 @@ import {
type PrinterInstance, type PrinterInstance,
type TicketData, type TicketData,
type TicketHeader, type TicketHeader,
printerRoleOf,
} from "@parking/devices"; } from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared"; import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
@@ -523,7 +524,7 @@ export class EntryFlow {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) continue; if (!driver) continue;
const cfg = row.config as Record<string, unknown>; const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; const role = printerRoleOf(cfg);
try { try {
out.push({ out.push({
id: row.id, id: row.id,
+153 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing"; 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 type { FastifyInstance } from "fastify";
import { buildServer } from "../../server.js"; import { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.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). // 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 } }); const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
expect(again.json().chargeLines).toEqual([]); 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 () => { 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"], permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
}); });
const w = await login(app, washer.username, washer.password); 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. // What the UI offers: only the wash till.
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } }); 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"]); 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"); 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);
});
});
+19 -2
View File
@@ -1,4 +1,6 @@
import { deviceEvents } from "../../device-events.js";
import type { ServerModule } from "../index.js"; import type { ServerModule } from "../index.js";
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
import { carwashRoutes } from "./routes.js"; import { carwashRoutes } from "./routes.js";
import { CarwashService } from "./service.js"; import { CarwashService } from "./service.js";
@@ -10,10 +12,25 @@ import { CarwashService } from "./service.js";
export const carwashModule: ServerModule = { export const carwashModule: ServerModule = {
id: "carwash", id: "carwash",
async register(app, deps) { 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; // 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. // the core calls back after the payment is signed so the order is marked paid.
deps.payStation.registerChargeProvider(service.chargeProvider()); 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,
};
}
}
+11 -3
View File
@@ -1,9 +1,10 @@
import type { FastifyInstance, FastifyReply } from "fastify"; import type { FastifyInstance, FastifyReply } from "fastify";
import type { Tender } from "@parking/shared"; import type { Tender } from "@parking/shared";
import { requirePermission } from "../../auth.js"; import { requireAnyPermission, requirePermission } from "../../auth.js";
import { requireModule } from "../../modules.js"; import { requireModule } from "../../modules.js";
import { NoShiftOpenError } from "../../shift-service.js"; import { NoShiftOpenError } from "../../shift-service.js";
import type { ServerModuleDeps } from "../index.js"; import type { ServerModuleDeps } from "../index.js";
import type { ReviewOutbox } from "./review-outbox.js";
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.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 // 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; 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 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 settingsWrite = [moduleOn, requirePermission("site:update")];
const read = [moduleOn, requirePermission("carwash:read")]; const read = [moduleOn, requirePermission("carwash:read")];
const create = [moduleOn, requirePermission("carwash:create")]; const create = [moduleOn, requirePermission("carwash:create")];
const update = [moduleOn, requirePermission("carwash:update")]; const update = [moduleOn, requirePermission("carwash:update")];
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings()); 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) => { app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
try { try {
+140 -7
View File
@@ -23,10 +23,17 @@ import {
type CarwashOrderView, type CarwashOrderView,
type CarwashSettingsView, type CarwashSettingsView,
type ChargeLine, type ChargeLine,
CARWASH_VISION_THRESHOLD_DEFAULT,
isVehicleClass,
reasonPayload,
type VehicleClass,
type VehicleRead,
type Tender, type Tender,
type TillId, type TillId,
} from "@parking/shared"; } from "@parking/shared";
import type { EventLog } from "../../event-log.js"; 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 { effectiveModulesFor } from "../../modules.js";
import type { ChargeProvider, PayStation } from "../../pay-station.js"; import type { ChargeProvider, PayStation } from "../../pay-station.js";
import type { ShiftService } from "../../shift-service.js"; import type { ShiftService } from "../../shift-service.js";
@@ -57,11 +64,12 @@ export class CarwashError extends Error {
} }
export interface SettingsBody { 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 }[]; services?: { id?: string; name?: string; active?: boolean }[];
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[]; prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
/** Where wash money is taken at this site (site-level policy). */ /** Where wash money is taken at this site (site-level policy). */
payAt?: unknown; payAt?: unknown;
visionThreshold?: unknown;
} }
export interface CreateOrderInput { export interface CreateOrderInput {
@@ -83,6 +91,10 @@ export interface TicketLookup {
enteredAt: string | null; enteredAt: string | null;
currency: string | null; currency: string | null;
orders: CarwashOrderView[]; 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}$/; const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -105,13 +117,15 @@ export class CarwashService {
readonly #pay: PayStation; readonly #pay: PayStation;
readonly #shift: ShiftService; readonly #shift: ShiftService;
readonly #logger: FastifyBaseLogger; 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.#db = deps.db;
this.#log = deps.eventLog; this.#log = deps.eventLog;
this.#pay = deps.payStation; this.#pay = deps.payStation;
this.#shift = deps.shiftService; this.#shift = deps.shiftService;
this.#logger = logger; this.#logger = logger;
this.#outbox = outbox;
} }
#enabled(): boolean { #enabled(): boolean {
@@ -127,7 +141,7 @@ export class CarwashService {
.where(isNull(carwashCategories.deletedAt)) .where(isNull(carwashCategories.deletedAt))
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name)) .orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
.all() .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 const services = this.#db
.select() .select()
.from(carwashServices) .from(carwashServices)
@@ -142,7 +156,7 @@ export class CarwashService {
.all() .all()
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId)) .filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor })); .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. */ /** 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; 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 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. */ * the booth takes). null when no tariff is published yet. */
#currency(): string | null { #currency(): string | null {
@@ -171,7 +204,7 @@ export class CarwashService {
const now = new Date().toISOString(); const now = new Date().toISOString();
const upsertList = ( const upsertList = (
table: typeof carwashCategories | typeof carwashServices, 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, label: string,
): string[] => { ): string[] => {
if (items === undefined) { if (items === undefined) {
@@ -190,11 +223,19 @@ export class CarwashService {
while (seen.has(id)) id = `${id}-${sort}`; while (seen.has(id)) id = `${id}-${sort}`;
seen.add(id); seen.add(id);
const active = it.active !== false; 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(); const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
if (existing) { 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 { } 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); keep.push(id);
sort += 1; 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(); return this.settings();
} }
@@ -289,6 +349,10 @@ export class CarwashService {
validationEventId: r.validationEventId, validationEventId: r.validationEventId,
voidBy: r.voidBy, voidBy: r.voidBy,
voidReason: r.voidReason, 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 { lookup(identity: string): TicketLookup {
const id = identity.trim(); const id = identity.trim();
const s = this.#pay.lookup(id); const s = this.#pay.lookup(id);
const vision = s.found ? vehicleForIdentity(this.#db, id) : null;
return { return {
identity: id, identity: id,
found: s.found, found: s.found,
@@ -344,6 +409,8 @@ export class CarwashService {
enteredAt: s.enteredAt, enteredAt: s.enteredAt,
currency: s.currency, currency: s.currency,
orders: this.#ordersFor(id), 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(); const currency = s.currency ?? this.#pay.activeCurrency();
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff"); 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 now = new Date().toISOString();
const row: CarwashOrderRow = { const row: CarwashOrderRow = {
id: randomUUID(), id: randomUUID(),
@@ -408,8 +520,29 @@ export class CarwashService {
voidAt: null, voidAt: null,
voidBy: null, voidBy: null,
voidReason: null, voidReason: null,
visionClass: vision?.bodyType ?? null,
visionConfidence: vision?.confidence ?? null,
visionCategoryId: visionCategory?.id ?? null,
downgradeEventId,
}; };
this.#db.insert(carwashOrders).values(row).run(); 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({ await this.#log.append({
type: "carwash_order", type: "carwash_order",
source: "manual", source: "manual",
+33
View File
@@ -1,4 +1,5 @@
import { and, desc, deviceEvents, eq, type Db } from "@parking/db"; 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 // 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 // lives in the unsigned, prunable `device_events` (kind="read") stream written by the
@@ -23,6 +24,38 @@ interface ReadDetail {
plate?: string; plate?: string;
confidence?: number; confidence?: number;
direction?: string; 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. */ /** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
+44 -9
View File
@@ -1,7 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db"; import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared"; import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
import { effectiveModulesFor } from "../modules.js";
import { enrichEvents } from "../event-enrich.js"; import { enrichEvents } from "../event-enrich.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
@@ -15,25 +16,59 @@ export async function eventRoutes(
db: Db, db: Db,
eventLog: EventLog, eventLog: EventLog,
): Promise<void> { ): Promise<void> {
// Reading the log (the audit trail). // Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it
const guard = requirePermission("event:read"); // 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). // 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 // 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 // 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 // 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. // 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. // (logs are per-shift, not all history). An optional `till` keeps only that till's
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>( // 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", "/api/events",
{ preHandler: guard }, { preHandler: requireAuth },
async (req) => { 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 limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const since = (req.query.since ?? "").trim(); const since = (req.query.since ?? "").trim();
const until = (req.query.until ?? "").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 = [ const bounds = [
since ? gte(ledgerEvents.occurredAt, since) : undefined, since ? gte(ledgerEvents.occurredAt, since) : undefined,
until ? lte(ledgerEvents.occurredAt, until) : undefined, until ? lte(ledgerEvents.occurredAt, until) : undefined,
till ? sql`${tillExpr} = ${till}` : undefined,
types ? inArray(ledgerEvents.type, types) : undefined,
].filter(Boolean); ].filter(Boolean);
const rows = db const rows = db
.select() .select()
+102
View File
@@ -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([]);
});
});
+74 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db"; import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared"; import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js"; import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { softDelete } from "../recycle-bin.js"; import { softDelete } from "../recycle-bin.js";
// Role management (admin). Roles are DATA: an admin composes a role from the // 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 // 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 // only put permissions they ALREADY hold onto a role. An admin (full set) is
// unrestricted, which is the intended behaviour. // 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 { interface RoleBody {
name: string; name: string;
permissions: string[]; permissions: string[];
jobs?: string[];
} }
interface UpdateBody { interface UpdateBody {
name?: string; name?: string;
permissions?: 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); const VALID = new Set<string>(PERMISSIONS);
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
return { ok: true, perms: [...out] }; 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 readGuard = requirePermission("role:read");
const createGuard = requirePermission("role:create"); const createGuard = requirePermission("role:create");
const updateGuard = requirePermission("role:update"); const updateGuard = requirePermission("role:update");
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
name: role.name, name: role.name,
builtin: role.builtin === 1, builtin: role.builtin === 1,
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms, permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
jobs: jobsOf(roleId),
userCount, 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). */ /** Replace a role's permission rows with `perms` (in a single pass). */
function setPermissions(roleId: string, perms: Permission[]): void { function setPermissions(roleId: string, perms: Permission[]): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run(); 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 ?? []); const cleaned = cleanPermissions(req.body?.permissions ?? []);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad }); 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); 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}` }); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
const id = randomUUID(); const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run(); db.insert(roles).values({ id, name, builtin: 0 }).run();
setPermissions(id, cleaned.perms); setPermissions(id, cleaned.perms);
setJobs(id, jobs.jobs);
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, null, shapeOf(id));
return reply.code(201).send(roleView(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) { if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be edited" }); return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
} }
const prev = shapeOf(id);
if (req.body?.name != null) { if (req.body?.name != null) {
const name = req.body.name.trim(); 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}` }); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
setPermissions(id, cleaned.perms); 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(); bumpPermsCache();
await signRoleChange(req, id, prev, shapeOf(id));
return roleView(id); return roleView(id);
}, },
); );
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (holders > 0) { if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` }); 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); softDelete(db, "role", id, req.user.sub);
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, prev, null);
return { ok: true }; return { ok: true };
}, },
); );
+1 -1
View File
@@ -117,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// RBAC administration: compose roles (role:*) + manage users (user:*). The // 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. // built-in admin role is protected; the last admin can't be removed. See auth.ts.
await userRoutes(app, db); await userRoutes(app, db);
await roleRoutes(app, db);
// Vision (ANPR) client — built early so the device monitor can include the vision // 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 // 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. // 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. // See wiki/concepts/first-run-setup.md, entry-exit-points.md.
await setupRoutes(app, db, visionClient, eventLog); await setupRoutes(app, db, visionClient, eventLog);
await roleRoutes(app, db, eventLog);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events), // Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from // guarded by source-IP allowlist + a shared-secret path token, both read from
+104 -41
View File
@@ -1,6 +1,6 @@
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db"; import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices"; import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } from "@parking/shared"; import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -71,12 +71,19 @@ export interface ShiftSummary {
readonly subscriptionSalesMinor: number; readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number; readonly subscriptionWindowMinor: number;
readonly discountTotalMinor: number; readonly discountTotalMinor: number;
readonly chargesByModuleMinor: ChargesByModule;
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
readonly cashAddedMinor: number; readonly cashAddedMinor: number;
readonly cashRemovedMinor: number; readonly cashRemovedMinor: number;
readonly expectedDrawerMinor: 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 { export interface ShiftReport {
readonly till: TillId; readonly till: TillId;
readonly operator: string; readonly operator: string;
@@ -98,6 +105,8 @@ export interface ShiftReport {
/** Merchant-validation DISCOUNT total given away in the window (leakage — the /** Merchant-validation DISCOUNT total given away in the window (leakage — the
* cash/card figures above are already NET of it). See validation-discounts.md. */ * cash/card figures above are already NET of it). See validation-discounts.md. */
readonly discountTotalMinor: number; 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) --- // --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */ /** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
@@ -142,6 +151,15 @@ export class InvalidCashMovementError extends Error {
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */ /** Printed (Albanian) name of a till on Z-reports and voucher slips. */
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" }; 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 { export class ShiftService {
readonly #db: Db; readonly #db: Db;
@@ -248,6 +266,7 @@ export class ShiftService {
subscriptionSalesMinor?: number; subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number; subscriptionWindowMinor?: number;
discountTotalMinor?: number; discountTotalMinor?: number;
chargesByModuleMinor?: ChargesByModule;
openingFloatMinor?: number; openingFloatMinor?: number;
cashAddedMinor?: number; cashAddedMinor?: number;
cashRemovedMinor?: number; cashRemovedMinor?: number;
@@ -283,6 +302,8 @@ export class ShiftService {
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0), (pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0. // Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
discountTotalMinor: pl.discountTotalMinor ?? 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, openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0, cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0, cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -558,8 +579,8 @@ export class ShiftService {
.from(ledgerEvents) .from(ledgerEvents)
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the // 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 // 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 // tender totals so the expected drawer is right; the booth-paid wash is then
// Z-report is a follow-up (venue-modules.md). // broken OUT of the ticket bucket into chargesByModuleMinor (see below).
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"])) .where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
.all() .all()
.filter( .filter(
@@ -578,11 +599,16 @@ export class ShiftService {
// Merchant-validation leakage: Σ discountMinor across the window's payments. The // Merchant-validation leakage: Σ discountMinor across the window's payments. The
// tender totals are already NET; this is the "given away" figure beside them. // tender totals are already NET; this is the "given away" figure beside them.
let discountTotalMinor = 0; 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; let currency: string | null = null;
for (const p of payments) { for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload & { const pl = (p.payload ?? {}) as LedgerPayload & {
subscriptionSale?: boolean; subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean; subscriptionWindowCharge?: boolean;
chargeLines?: ChargeLine[];
}; };
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (pl.tender === "card") cardTotalMinor += amt; if (pl.tender === "card") cardTotalMinor += amt;
@@ -591,10 +617,17 @@ export class ShiftService {
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt; else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription) // (else → transient ticket; derived below as total − subscription)
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor; 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; if (pl.currency) currency = pl.currency;
} }
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor; 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 --- // --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start); // Opening float was fixed on shift_open (inherited from the chain at start);
@@ -649,6 +682,7 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -689,6 +723,7 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -713,6 +748,8 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
// Only when a module charged in the window (older slips/payloads stay identical).
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -729,14 +766,9 @@ export class ShiftService {
return { ...report, printed }; return { ...report, printed };
} }
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event /** Print the Z-report on the till's printer (best-effort; the signed event is the
* is the record — a failed print doesn't undo the close). */ * record — a failed print doesn't undo the close). */
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> { 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 cur = r.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2); const money = (m: number) => (m / 100).toFixed(2);
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not // 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}`, `Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"", "",
"-- Arkëtime sipas burimit --", "-- Arkëtime sipas burimit --",
// 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}`, `Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out. // 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.) // (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`, `Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${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 // Merchant-validation leakage — printed only when the shift actually gave any
// (older slips stay byte-identical). The takings above are already NET of it. // (older slips stay byte-identical). The takings above are already NET of it.
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []), ...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
@@ -770,13 +814,7 @@ export class ShiftService {
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`, `Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`, `Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
]; ];
try { return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
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;
}
} }
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort — /** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
@@ -792,11 +830,6 @@ export class ShiftService {
at: string; at: string;
till: TillId; till: TillId;
}): Promise<boolean> { }): 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 cur = v.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2); const money = (m: number) => (m / 100).toFixed(2);
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE"; const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
@@ -810,27 +843,57 @@ export class ShiftService {
"", "",
`Regjistroi: ${v.operator}`, `Regjistroi: ${v.operator}`,
]; ];
try { return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
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;
}
} }
/** First enabled booth-receipt printer, or any enabled printer. */ /** Print a till's slip on its printer with failover (wash desk → booth printer;
async #boothPrinter(): Promise<PrinterDevice | null> { * see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all(); * record — every failure is logged and reported as "not printed", never thrown.
const enabled = rows.filter((r) => r.enabled); * Legacy fallback: a site whose only printer carries no booth role (one unit,
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0]; * configured as the entry dispenser) still prints its slips on it, as before. */
if (!booth) return null; async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
const driver = registry.get(booth.driverId); const printers = this.#loadPrinters();
if (!driver) return null; 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 { try {
return driver.create(booth.config as never) as PrinterDevice; 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 { } catch {
return null; // skip a printer whose config won't build
} }
} }
return out;
}
} }
+53 -8
View File
@@ -167,22 +167,42 @@ async function recognizePlate(
): Promise<void> { ): Promise<void> {
try { try {
const result = await vision.analyze(shot.bytes, shot.contentType); const result = await vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate || result.lowConfidence) return; // nothing trustworthy to record if (!result) return;
const plate = result.plate.text.trim().toUpperCase(); // Boxes are kept as FRACTIONS of the analysed frame (the stored snapshot is a
if (!plate) return; // 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) db.insert(deviceEventsTable)
.values({ .values({
id: randomUUID(), id: randomUUID(),
deviceId, deviceId,
category: "camera", category: "camera",
kind: "read", 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: { detail: {
identity, identity,
direction, direction,
plate, ...(plate
confidence: result.plate.confidence, ? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null, ...(plateBox ? { plateBox } : {}) }
region: result.plate.region ?? null, : {}),
...vehicle,
modelVersion: result.modelVersion, modelVersion: result.modelVersion,
snapshotId, snapshotId,
source: "entry-exit-snapshot", source: "entry-exit-snapshot",
@@ -190,7 +210,18 @@ async function recognizePlate(
occurredAt: new Date().toISOString(), occurredAt: new Date().toISOString(),
}) })
.run(); .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 // 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. // booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
deviceEvents.emitPlateRecognized({ identity, plate, direction }); 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' /** 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 * 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. */ * re-pressing within minutes; a long window would flag legit re-visits. */
+22 -3
View File
@@ -23,6 +23,8 @@ import type { FastifyBaseLogger } from "fastify";
// transport + contract adapter only. // transport + contract adapter only.
/** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */ /** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */
import { isVehicleClass, type VehicleClass } from "@parking/shared";
export interface PlateBBox { export interface PlateBBox {
readonly x1: number; readonly x1: number;
readonly y1: number; readonly y1: number;
@@ -40,12 +42,21 @@ export interface VisionPlate {
readonly region?: string | null; readonly region?: string | null;
} }
/** The raw /analyze response shape (the Python contract). `vehicle` is reserved for /** The vehicle attributes stage of /analyze (advisory). `body_type` is one of the shared
* Job 2 (vehicle verification) — not yet produced. */ * 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 { interface AnalyzeResponse {
readonly plate: VisionPlate | null; readonly plate: VisionPlate | null;
readonly plates: VisionPlate[]; 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 low_confidence: boolean;
readonly model_version: string; readonly model_version: string;
readonly took_ms: number; 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 /** True when the best plate is below the confidence floor — treat as advisory only
* and fall back to the ticket/manual path. */ * and fall back to the ticket/manual path. */
readonly lowConfidence: boolean; 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 modelVersion: string;
readonly tookMs: number; readonly tookMs: number;
} }
@@ -124,10 +137,16 @@ export class VisionClient {
const best = res.plate ?? null; const best = res.plate ?? null;
const lowConfidence = const lowConfidence =
res.low_confidence || (best != null && best.confidence < this.#minConfidence); 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 { return {
plate: best, plate: best,
plates: Array.isArray(res.plates) ? res.plates : [], plates: Array.isArray(res.plates) ? res.plates : [],
lowConfidence, lowConfidence,
vehicle,
modelVersion: res.model_version ?? "unknown", modelVersion: res.model_version ?? "unknown",
tookMs: typeof res.took_ms === "number" ? res.took_ms : 0, tookMs: typeof res.took_ms === "number" ? res.took_ms : 0,
}; };
+7
View File
@@ -0,0 +1,7 @@
.venv/
**/__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
out/
.env
+12
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.12
+49
View File
@@ -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"]
+36
View File
@@ -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.
+13
View File
@@ -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)'"
}
}
+73
View File
@@ -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"]
+102
View File
@@ -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
+98
View File
@@ -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
+111
View File
@@ -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"
+155
View File
@@ -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"
+7
View File
@@ -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.
"""
+414
View File
@@ -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())
+179
View File
@@ -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)))
+51
View File
@@ -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
+305
View File
@@ -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,
)
+88
View File
@@ -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)
+152
View File
@@ -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"
+382
View File
@@ -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)
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": []
}
}
}
+1444
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -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
+17
View File
@@ -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 # 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. # treats it as advisory and falls back to the ticket path. Keep in sync with the server.
VISION_MIN_CONFIDENCE=0.5 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
+2 -1
View File
@@ -7,5 +7,6 @@ __pycache__/
.ruff_cache/ .ruff_cache/
# Model weights (fetched at deploy / first run, never committed — can be large + license-scoped) # Model weights (fetched at deploy / first run, never committed — can be large + license-scoped)
models/ models/*
!models/bodytype.version
*.onnx *.onnx
+29 -2
View File
@@ -14,7 +14,7 @@ ENV UV_LINK_MODE=copy \
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal. # System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
RUN apt-get update \ 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/* && rm -rf /var/lib/apt/lists/*
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ---- # ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
@@ -26,6 +26,31 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# ---- project source ---- # ---- project source ----
COPY vision_service/ ./vision_service/ COPY vision_service/ ./vision_service/
COPY README.md ./ 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 \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --extra alpr 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. # Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
ENV VISION_RECOGNIZER=stub \ ENV VISION_RECOGNIZER=stub \
VISION_HOST=0.0.0.0 \ 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 EXPOSE 8089
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ 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 CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
View File
+3
View File
@@ -35,6 +35,9 @@ dev = [
"pytest>=8.3", "pytest>=8.3",
"httpx>=0.27", # FastAPI TestClient transport "httpx>=0.27", # FastAPI TestClient transport
"mypy>=1.13", "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] [tool.ruff]
+277
View File
@@ -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 "")
+3
View File
@@ -760,6 +760,8 @@ alpr = [
dev = [ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "mypy" }, { 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 = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -779,6 +781,7 @@ provides-extras = ["alpr"]
dev = [ dev = [
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "mypy", specifier = ">=1.13" }, { name = "mypy", specifier = ">=1.13" },
{ name = "numpy", specifier = ">=1.26" },
{ name = "pytest", specifier = ">=8.3" }, { name = "pytest", specifier = ">=8.3" },
{ name = "ruff", specifier = ">=0.8" }, { name = "ruff", specifier = ">=0.8" },
] ]
+67 -2
View File
@@ -14,11 +14,16 @@ Adding a recognizer (e.g. a fine-tuned YOLO + PaddleOCR) = a new class here, no
from __future__ import annotations from __future__ import annotations
import logging
import time import time
from pathlib import Path
from typing import Protocol from typing import Protocol
from .schemas import AnalyzeResponse, BBox, PlateResult from .schemas import AnalyzeResponse, BBox, PlateResult
from .settings import Settings from .settings import Settings
from .vehicle import BodyTypeClassifier, RefinedVehicleDetector, VehicleDetector, YoloxVehicleDetector
log = logging.getLogger("vision")
class Recognizer(Protocol): 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: def build_recognizer(settings: Settings) -> Recognizer:
"""Factory: pick the recognizer from settings. Falls back to the stub if the real """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": if settings.recognizer == "fast_alpr":
rec = FastAlprRecognizer(settings) rec = FastAlprRecognizer(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 return rec
return StubRecognizer(settings)
+14 -1
View File
@@ -31,10 +31,23 @@ class PlateResult(BaseModel):
class VehicleResult(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 colour: str | None = None
body_type: 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 make: str | None = None
model: str | None = None model: str | None = None
+18
View File
@@ -32,6 +32,24 @@ class Settings(BaseSettings):
# Node side can fall back to the ticket path rather than trust it. # Node side can fall back to the ticket path rather than trust it.
min_confidence: float = 0.5 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: def get_settings() -> Settings:
return Settings() return Settings()
+421
View File
@@ -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
+6 -3
View File
@@ -13,6 +13,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js"; import { ActiveSessions } from "./ActiveSessions.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
import { EventDetailModal, EventRow } from "./ui/event-detail.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 // 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 // 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 occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({ const eventsQuery = useQuery({
queryKey: [...qk.events, shiftStart ?? "none"], queryKey: [...qk.events, shiftStart ?? "none"],
queryFn: () => fetchEvents(100, shiftStart ?? undefined), queryFn: () => fetchEvents(100, shiftStart ?? undefined, undefined, "booth"),
enabled: shiftOpen, enabled: shiftOpen,
}); });
@@ -275,13 +276,15 @@ export function BoothScreen() {
// Merge: live events first (newest), then the queried history, de-duped by id — // 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 // 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 seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id)); const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const merged = [...liveFeed, ...history].slice(0, 200); const merged = [...liveFeed, ...history].slice(0, 200);
const scoped = const scoped =
shiftOpen && shiftStart 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`) // Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
+2 -2
View File
@@ -177,8 +177,8 @@ function StatePanel({ till }: { till: TillId }) {
function TodayPanel({ till }: { till: TillId }) { function TodayPanel({ till }: { till: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const q = useQuery({ const q = useQuery({
queryKey: ["drawer", "today"], queryKey: ["drawer", "today", till],
queryFn: () => fetchEvents(1000, startOfToday()), queryFn: () => fetchEvents(1000, startOfToday(), undefined, till),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
+49 -8
View File
@@ -13,7 +13,7 @@ import {
type SessionUser, type SessionUser,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.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 // 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 // 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): // 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 // "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). // 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. */ /** Group "resource:action" permissions by resource for the grid rows. */
function groupByResource(perms: Permission[]): Record<string, Permission[]> { 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"> <span className="text-[0.6875rem] text-term-muted">
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })} {t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span> </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>
<div className="flex gap-2"> <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 && ( {canUpdate && !r.builtin && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button> <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); } 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. */ /** The jobs the composer offers: every effective module's, in registry order. */
function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] { 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 }))); 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. */ /** 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 has = (p: Permission) => perms.has(p);
const out: { key: string; vars?: Record<string, string> }[] = []; 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. // Mixes desks: may OPEN more than one till.
const workable: TillId[] = tillsFor(effective, has, "shift"); const workable: TillId[] = tillsFor(effective, has, "shift");
if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } }); if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } });
@@ -165,11 +194,14 @@ function RoleEditor({
grouped: Record<string, Permission[]>; grouped: Record<string, Permission[]>;
effective: readonly ModuleId[]; effective: readonly ModuleId[];
onCancel: () => void; onCancel: () => void;
onSubmit: (v: { name: string; permissions: Permission[] }) => void; onSubmit: (v: { name: string; permissions: Permission[]; jobs: string[] }) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(role?.name ?? ""); const [name, setName] = useState(role?.name ?? "");
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? [])); 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) => const toggle = (p: Permission) =>
setPerms((prev) => { setPerms((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -177,15 +209,24 @@ function RoleEditor({
return next; return next;
}); });
const jobs = useMemo(() => jobsFor(effective), [effective]); const jobs = useMemo(() => jobsFor(effective), [effective]);
const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p)); const complete = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
const toggleJob = (job: JobPreset) => 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) => { setPerms((prev) => {
const next = new Set(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); else for (const p of job.permissions) next.add(p);
return next; 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; const valid = name.trim().length > 0;
@@ -244,7 +285,7 @@ function RoleEditor({
<div className="mt-3 flex justify-end gap-2"> <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-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>
</div> </div>
); );
+10 -1
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js"; 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.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} /> <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' {/* 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). */} part is broken out below it; subscription SALES is not (it's the remainder). */}
<span /> <span />
+27 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import {
@@ -91,6 +91,7 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workabl
subscriptionTotalMinor: x.subscriptionTotalMinor, subscriptionTotalMinor: x.subscriptionTotalMinor,
subscriptionSalesMinor: x.subscriptionSalesMinor, subscriptionSalesMinor: x.subscriptionSalesMinor,
subscriptionWindowMinor: x.subscriptionWindowMinor, subscriptionWindowMinor: x.subscriptionWindowMinor,
chargesByModuleMinor: x.chargesByModuleMinor,
openingFloatMinor: x.openingFloatMinor, openingFloatMinor: x.openingFloatMinor,
cashAddedMinor: x.cashAddedMinor, cashAddedMinor: x.cashAddedMinor,
cashRemovedMinor: x.cashRemovedMinor, 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({ function ShiftActivityLog({
shift, shift,
isCurrent, isCurrent,
@@ -356,9 +375,10 @@ function ShiftActivityLog({
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null); const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded. // 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({ const q = useQuery({
queryKey: ["shift-events", shift.id, shift.endedAt], queryKey: ["shift-events", shift.id, shift.endedAt, shift.till],
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt), queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt, shift.till),
refetchInterval: isCurrent ? 5000 : false, refetchInterval: isCurrent ? 5000 : false,
}); });
const events = q.data?.events ?? []; const events = q.data?.events ?? [];
@@ -389,6 +409,7 @@ function ShiftActivityLog({
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} /> <Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */} {/* 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 /> <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.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} /> <Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} /> <Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
@@ -446,6 +467,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} /> <Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, 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. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span /> <span />
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub /> <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"> <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.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, 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. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span /> <span />
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
@@ -513,6 +536,7 @@ function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void })
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} /> <Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, 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. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span /> <span />
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
+13 -4
View File
@@ -213,6 +213,9 @@ export interface ManagedRole {
name: string; name: string;
builtin: boolean; builtin: boolean;
permissions: Permission[]; 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; userCount: number;
} }
@@ -241,10 +244,10 @@ export function deleteUser(id: string): Promise<{ ok: boolean }> {
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> { export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
return apiFetch("/api/roles"); 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) }); 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) }); return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
} }
export function deleteRole(id: string): Promise<{ ok: boolean }> { export function deleteRole(id: string): Promise<{ ok: boolean }> {
@@ -1096,6 +1099,9 @@ export interface ShiftSourceSplit {
subscriptionTotalMinor: number; subscriptionTotalMinor: number;
subscriptionSalesMinor: number; subscriptionSalesMinor: number;
subscriptionWindowMinor: 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 { export interface ShiftReport extends ShiftSourceSplit {
@@ -1331,7 +1337,7 @@ export interface DeviceStatus {
category: "access" | "reader" | "camera" | "printer" | "vision"; category: "access" | "reader" | "camera" | "printer" | "vision";
/** Role/direction token for the footer label (NOT the vendor) — the client /** Role/direction token for the footer label (NOT the vendor) — the client
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */ * 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"; state: "ready" | "degraded" | "offline";
detail?: string; detail?: string;
checkedAt: string; checkedAt: string;
@@ -1351,15 +1357,18 @@ export type { AppLogRecord };
/** Recent ledger events, newest first (default 100, max 1000). Used for the /** 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) * 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 * 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( export function fetchEvents(
limit = 100, limit = 100,
since?: string, since?: string,
until?: string, until?: string,
till?: TillId,
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> { ): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
const qs = new URLSearchParams({ limit: String(limit) }); const qs = new URLSearchParams({ limit: String(limit) });
if (since) qs.set("since", since); if (since) qs.set("since", since);
if (until) qs.set("until", until); if (until) qs.set("until", until);
if (till) qs.set("till", till);
return apiFetch(`/api/events?${qs.toString()}`); return apiFetch(`/api/events?${qs.toString()}`);
} }
+30
View File
@@ -67,6 +67,18 @@ export const en: Catalog = {
carwash: "Car wash", 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: { wash: {
tillTitle: "Wash till", 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.", 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", sponsorship: "Parking discount",
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.", sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
sponsorshipLabel: "Car wash", 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: { update: {
available: "Update available", available: "Update available",
@@ -232,6 +256,7 @@ export const en: Catalog = {
mixed: "entry/exit", mixed: "entry/exit",
lane: "at lane", lane: "at lane",
booth: "at booth", booth: "at booth",
wash: "at wash desk",
}, },
state: { state: {
ready: "ready", ready: "ready",
@@ -384,6 +409,7 @@ export const en: Catalog = {
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)", "entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
"entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry", "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}}", "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.closed": "Exit refused — session already closed",
"exit.refused.noSession": "Exit refused — unknown ticket", "exit.refused.noSession": "Exit refused — unknown ticket",
"exit.refused.unpaid": "Exit refused — not paid (take payment first)", "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.", 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?", 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.", 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: { jobs: {
"booth-operator": "Booth operator", "booth-operator": "Booth operator",
@@ -960,6 +989,7 @@ export const en: Catalog = {
card: "Card:", card: "Card:",
srcTickets: "Tickets:", srcTickets: "Tickets:",
srcSubscriptions: "Subscriptions:", srcSubscriptions: "Subscriptions:",
srcOnTicket: "{{module}} (on ticket):",
srcSubWindow: "out-of-window", srcSubWindow: "out-of-window",
drawerSection: "— Drawer —", drawerSection: "— Drawer —",
openingFloat: "Opening cash:", openingFloat: "Opening cash:",
+29
View File
@@ -70,6 +70,18 @@ export const sq = {
carwash: "Lavazh", carwash: "Lavazh",
}, },
}, },
vehicleClass: {
car: "veturë",
sedan: "sedan",
hatchback: "hatchback",
suv: "SUV",
minivan: "minivan",
pickup: "pikap",
van: "furgon",
truck: "kamion",
bus: "autobus",
motorcycle: "motor",
},
wash: { wash: {
tillTitle: "Arka e lavazhit", 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.", 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", sponsorship: "Zbritje parkimi",
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.", sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
sponsorshipLabel: "Lavazh", 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: { update: {
available: "Përditësim i disponueshëm", available: "Përditësim i disponueshëm",
@@ -235,6 +258,7 @@ export const sq = {
mixed: "hyrje/dalje", mixed: "hyrje/dalje",
lane: "në korsi", lane: "në korsi",
booth: "në kabinë", booth: "në kabinë",
wash: "në lavazh",
}, },
state: { state: {
ready: "gati", ready: "gati",
@@ -388,6 +412,7 @@ export const sq = {
"entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)", "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.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}}", "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.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur", "exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)", "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.", 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?", 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.", 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: { jobs: {
"booth-operator": "Operator kabine", "booth-operator": "Operator kabine",
@@ -974,6 +1002,7 @@ export const sq = {
card: "Kartë:", card: "Kartë:",
srcTickets: "Bileta:", srcTickets: "Bileta:",
srcSubscriptions: "Abonime:", srcSubscriptions: "Abonime:",
srcOnTicket: "{{module}} (në biletë):",
srcSubWindow: "jashtë orarit", srcSubWindow: "jashtë orarit",
drawerSection: "— Arka —", drawerSection: "— Arka —",
openingFloat: "Arka fillestare:", openingFloat: "Arka fillestare:",
+80 -12
View File
@@ -1,16 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
import { StationForm, defaultProgram } from "../../ValidationSetup.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 × // 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 // 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 // validation program (id "carwash"), composed with the same editor the merchant
// programs use. See wiki/decisions/venue-modules.md ("v1 answers"). // 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 fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, ""));
const toMinor = (s: string): number | null => { const toMinor = (s: string): number | null => {
@@ -25,18 +25,34 @@ function ListEditor({
items, items,
onChange, onChange,
addLabel, addLabel,
visionMap,
}: { }: {
title: string; title: string;
items: Item[]; items: Item[];
onChange: (items: Item[]) => void; onChange: (items: Item[]) => void;
addLabel: string; 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 { 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 ( return (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div> <div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
{items.map((it, i) => ( {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 <input
className="input flex-1" className="input flex-1"
value={it.name} value={it.name}
@@ -55,6 +71,26 @@ function ListEditor({
✕ ✕
</button> </button>
</div> </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 }])}> <button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
+ {addLabel} + {addLabel}
@@ -73,21 +109,26 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
const [prices, setPrices] = useState<Record<string, string>>({}); const [prices, setPrices] = useState<Record<string, string>>({});
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */ /** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
const [payAt, setPayAt] = useState<CarWashPayAt>("booth"); 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 [msg, setMsg] = useState<string | null>(null);
const [program, setProgram] = useState<ValidationProgramView | null>(null); const [program, setProgram] = useState<ValidationProgramView | null>(null);
const [review, setReview] = useState<CarwashReviewStatus | null>(null);
function load() { function load() {
fetchCarwashSettings() fetchCarwashSettings()
.then((s) => { .then((s) => {
setSettings(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 }))); setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {}; const p: Record<string, string> = {};
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor); for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
setPrices(p); setPrices(p);
setPayAt(s.payAt); setPayAt(s.payAt);
setThreshold(String(Math.round(s.visionThreshold * 100)));
}) })
.catch((e) => setMsg((e as Error).message)); .catch((e) => setMsg((e as Error).message));
fetchCarwashReviewStatus().then(setReview).catch(() => {});
fetchValidationPrograms() fetchValidationPrograms()
.then((r) => { .then((r) => {
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID); const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
@@ -103,7 +144,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
setMsg(null); setMsg(null);
try { try {
const listBody = { 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 })), 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 // 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 }); if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
}), }),
); );
const thr = Number(threshold);
const saved = await saveCarwashSettings({ 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 })), services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
prices: priceRows, prices: priceRows,
payAt, payAt,
...(Number.isFinite(thr) && thr >= 0 && thr <= 100 ? { visionThreshold: thr / 100 } : {}),
}); });
setSettings(saved); setSettings(saved);
setPayAt(saved.payAt); 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 }))); setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {}; const p: Record<string, string> = {};
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor); 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 ?? ""; const currency = settings?.currency ?? "";
return ( return (
<div className="mt-6 flex flex-wrap items-start gap-6"> // Two columns on a wide screen: the master data (categories with their camera-class
<section className="card w-full max-w-2xl p-4"> // chips, services, the price matrix, policies) takes the room it needs; the sponsorship
// program keeps a fixed, readable width beside it. Stacks on narrow screens.
<div className="mt-6 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,26rem)]">
<section className="card min-w-0 p-4">
<div className="grid gap-4"> <div className="grid gap-4">
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} /> <ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} visionMap />
<ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} /> <ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} />
<div> <div>
@@ -215,6 +262,27 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
</div> </div>
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span> <span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
</div> </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 && ( {canEdit && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -226,7 +294,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
</section> </section>
{canEdit && program && ( {canEdit && program && (
<section className="card w-full max-w-md p-4"> <section className="card min-w-0 p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div> <div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div>
<span className="hint">{t("wash.sponsorshipHint")}</span> <span className="hint">{t("wash.sponsorshipHint")}</span>
<div className="mt-2"> <div className="mt-2">
+21 -1
View File
@@ -73,7 +73,10 @@ export function WashDesk() {
setMsg(null); setMsg(null);
if (!ticket.trim()) return; if (!ticket.trim()) return;
try { 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) { } catch (err) {
setMsg((err as Error).message); setMsg((err as Error).message);
} }
@@ -170,6 +173,23 @@ export function WashDesk() {
<span className="text-term-muted">{t("wash.plate")}</span> <span className="text-term-muted">{t("wash.plate")}</span>
<span className="font-mono">{lookup.plate ?? "—"}</span> <span className="font-mono">{lookup.plate ?? "—"}</span>
</div> </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 && ( {lookup.enteredAt && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-term-muted">{t("wash.enteredAt")}</span> <span className="text-term-muted">{t("wash.enteredAt")}</span>
+24 -2
View File
@@ -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"; import { apiFetch } from "../../api.js";
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core // 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; enteredAt: string | null;
currency: string | null; currency: string | null;
orders: CarwashOrderView[]; 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 { 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 }[]; services?: { id?: string; name: string; active?: boolean }[];
prices?: { categoryId: string; serviceId: string; priceMinor: number }[]; prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
/** Where wash money is taken at this site (site-level; the desk no longer asks). */ /** Where wash money is taken at this site (site-level; the desk no longer asks). */
payAt?: CarWashPayAt; 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> { export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
+51
View File
@@ -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:
+9
View File
@@ -28,6 +28,12 @@ services:
# off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it # 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. # offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md.
BACKUP_KEY: ${BACKUP_KEY:-} 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, # CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators # so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook". # CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
@@ -55,6 +61,9 @@ services:
environment: environment:
# Engine: stub (no models) by default; prod override sets fast_alpr. # Engine: stub (no models) by default; prod override sets fast_alpr.
VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub} 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: networks:
- parking - parking
+51 -1
View File
@@ -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 # 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 # :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. # exists as the pointer; we deploy the sha, not the mover.
TAG=stage-2aa1045 TAG=stage-dbbb051
COOKIE_SECURE=0 COOKIE_SECURE=0
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within # 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. # 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). # park-2 pilots the Car Wash module (2026-09-05).
MODULES_ENTITLED=parking,carwash 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 VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach # platform-ws.ts, since the native WS plugin has no page context to auto-attach
@@ -100,3 +109,44 @@ JWT_SECRET=[[park_2_jwt_secret]]
EVENT_SIGNING_KEY=[[park_2_event_signing_key]] EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
BACKUP_KEY=[[park_2_backup_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]]
"""
+10
View File
@@ -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`);
+21
View File
@@ -204,6 +204,27 @@
"when": 1788605000000, "when": 1788605000000,
"tag": "0028_carwash_config", "tag": "0028_carwash_config",
"breakpoints": true "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
} }
] ]
} }
+51 -1
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm"; 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: // Schema notes:
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md): // - 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", { export const users = sqliteTable("users", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
username: text("username").notNull().unique(), username: text("username").notNull().unique(),
@@ -635,6 +653,9 @@ export const carwashCategories = sqliteTable("carwash_categories", {
name: text("name").notNull(), name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0), sortOrder: integer("sort_order").notNull().default(0),
active: integer("active", { mode: "boolean" }).notNull().default(true), 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") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -705,6 +726,13 @@ export const carwashOrders = sqliteTable("carwash_orders", {
voidAt: text("void_at"), voidAt: text("void_at"),
voidBy: text("void_by"), voidBy: text("void_by"),
voidReason: text("void_reason"), 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 /** 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", { export const carwashConfig = sqliteTable("carwash_config", {
id: integer("id").primaryKey(), id: integer("id").primaryKey(),
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull().default("booth"), 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"), updatedAt: text("updated_at"),
updatedBy: text("updated_by"), 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 CarwashCategoryRow = typeof carwashCategories.$inferSelect;
export type CarwashServiceRow = typeof carwashServices.$inferSelect; export type CarwashServiceRow = typeof carwashServices.$inferSelect;
export type CarwashPriceRow = typeof carwashPrices.$inferSelect; export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
@@ -772,7 +772,7 @@ export function transportLabel(t: Transport): string {
// --- shared driver config fields ---------------------------------------------- // --- shared driver config fields ----------------------------------------------
// Role + failover are identical across ESC/POS printers; defined here so each // Role + failover are identical across ESC/POS printers; defined here so each
// driver shares them. See wiki/concepts/printer-roles-failover.md. // 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) --------------------------------- // --- shared printer config fields (transport) ---------------------------------
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each // 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)", label: "Entry dispenser (outside / at the lane)",
}, },
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" }, { 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 = { const rankField: ConfigField = {
@@ -262,8 +262,9 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
} }
/** Type guard: does this device carry a printer role (entry vs. booth)? */ /** Where a printer sits: at the lane (entry tickets), in the booth (receipts, reports,
export type PrinterRole = "entry-dispenser" | "booth-receipt"; * 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 = { const roleField: ConfigField = {
key: "role", key: "role",
@@ -277,8 +278,9 @@ const roleField: ConfigField = {
label: "Entry dispenser (outside / at the lane)", label: "Entry dispenser (outside / at the lane)",
}, },
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" }, { 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 = { const rankField: ConfigField = {
+1
View File
@@ -27,6 +27,7 @@ export { stamp as formatStampSq } from "./drivers/printer-escpos.js";
export { export {
orderForRole, orderForRole,
printWithFailover, printWithFailover,
printerRoleOf,
NoPrinterAvailableError, NoPrinterAvailableError,
type PrinterInstance, type PrinterInstance,
} from "./printer-routing.js"; } from "./printer-routing.js";
@@ -27,6 +27,19 @@ describe("orderForRole", () => {
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]); 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", () => { it("breaks ties by failoverRank (higher first), then id", () => {
const printers = [ const printers = [
inst("b", "entry-dispenser", 1), inst("b", "entry-dispenser", 1),
+12 -1
View File
@@ -26,13 +26,16 @@ export interface PrinterInstance {
* printer is also a fallback for entry tickets, so when an entry ticket is * 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 * routed, booth-receipt printers follow the entry dispensers. The reverse is
* deliberately NOT done — a receipt never prints on the outside dispenser. * 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( export function orderForRole(
printers: readonly PrinterInstance[], printers: readonly PrinterInstance[],
wantRole: PrinterRole, wantRole: PrinterRole,
): PrinterInstance[] { ): PrinterInstance[] {
const fallbackRole: PrinterRole | null = const fallbackRole: PrinterRole | null =
wantRole === "entry-dispenser" ? "booth-receipt" : null; wantRole === "entry-dispenser" || wantRole === "wash-desk" ? "booth-receipt" : null;
const rank = (p: PrinterInstance): number => { const rank = (p: PrinterInstance): number => {
if (p.role === wantRole) return 2; 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 { export class NoPrinterAvailableError extends Error {
constructor(public readonly attempts: { id: string; error: string }[]) { constructor(public readonly attempts: { id: string; error: string }[]) {
super( super(
+90 -1
View File
@@ -446,6 +446,10 @@ export const REASON_CODES = [
// ticket (e.g. a motion radar dropped the stationary car and re-armed the button). // 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. // Post-hoc + advisory (ANPR never gates); the operator voids the duplicate.
"entry.duplicatePlate", "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 refusals
"exit.refused.closed", "exit.refused.closed",
"exit.refused.noSession", "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.operatorIssued": "entry ticket issued by operator {operator} (physical button)",
"entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry", "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}", "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.closed": "exit refused — session already closed",
"exit.refused.noSession": "exit refused — no open session for ticket", "exit.refused.noSession": "exit refused — no open session for ticket",
"exit.refused.unpaid": "exit refused — not paid (take payment first)", "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. */ /** 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[] { export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
return tillsFor(effective, has, "shift"); return tillsFor(effective, has, "shift");
} }
@@ -1988,8 +2029,48 @@ export interface ChargeLine {
/** Setup → Car wash: the admin-maintained master data, as read/written by /** Setup → Car wash: the admin-maintained master data, as read/written by
* GET/PUT /api/carwash/settings. Ids are stable; names are display text. */ * 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 { 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 }[]; readonly services: { id: string; name: string; sortOrder: number; active: boolean }[];
/** One entry per priced (category, service) pair. */ /** One entry per priced (category, service) pair. */
readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[]; 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 /** 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. */ * wash operator's till). Site-level; the desk no longer asks per order. */
readonly payAt: CarWashPayAt; 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). */ /** A wash order as the desk sees it (GET /api/carwash/orders). */
@@ -2024,6 +2107,12 @@ export interface CarwashOrderView {
readonly validationEventId: string | null; readonly validationEventId: string | null;
readonly voidBy: string | null; readonly voidBy: string | null;
readonly voidReason: 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 { export function isModuleId(v: unknown): v is ModuleId {
+54
View File
@@ -18,6 +18,37 @@ importers:
specifier: 6.0.3 specifier: 6.0.3
version: 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: apps/desktop:
dependencies: dependencies:
'@tauri-apps/plugin-process': '@tauri-apps/plugin-process':
@@ -86,6 +117,8 @@ importers:
specifier: ^4.1.9 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)) 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/vision: {}
apps/web: apps/web:
@@ -744,12 +777,18 @@ packages:
'@fastify/ajv-compiler@4.0.5': '@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/busboy@3.2.2':
resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
'@fastify/cookie@11.0.2': '@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
'@fastify/cors@11.2.0': '@fastify/cors@11.2.0':
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
'@fastify/deepmerge@3.2.1':
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
'@fastify/error@4.2.0': '@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
@@ -765,6 +804,9 @@ packages:
'@fastify/merge-json-schemas@0.2.1': '@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
'@fastify/multipart@9.4.0':
resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==}
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
@@ -3380,6 +3422,8 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0) ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.2 fast-uri: 3.1.2
'@fastify/busboy@3.2.2': {}
'@fastify/cookie@11.0.2': '@fastify/cookie@11.0.2':
dependencies: dependencies:
cookie: 1.1.1 cookie: 1.1.1
@@ -3390,6 +3434,8 @@ snapshots:
fastify-plugin: 5.1.0 fastify-plugin: 5.1.0
toad-cache: 3.7.1 toad-cache: 3.7.1
'@fastify/deepmerge@3.2.1': {}
'@fastify/error@4.2.0': {} '@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.0.3': '@fastify/fast-json-stringify-compiler@5.0.3':
@@ -3410,6 +3456,14 @@ snapshots:
dependencies: dependencies:
dequal: 2.0.3 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': '@fastify/proxy-addr@5.1.0':
dependencies: dependencies:
'@fastify/forwarded': 3.0.1 '@fastify/forwarded': 3.0.1
+14 -2
View File
@@ -17,8 +17,10 @@ Each printer instance (a `devices` row, category `printer`) declares a **role**
config: config:
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes. - **`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 - **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, the booth till's
**backup** for entry tickets. 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 It also declares a **`failoverRank`** (higher = preferred within a role) to order multiple
printers of the same role deterministically (ties broken by id). 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 Receipts are a booth-only job; an entry dispenser falling back to print receipts makes no
physical sense. 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 ## Where the logic lives
- The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't - The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't
+29 -2
View File
@@ -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 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 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.) `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 - **The activity log is per till too (2026-09-06).** `tillOfEvent(type, payload)` in
figures are per till, the event list is not); bay slips print on the booth printer. `@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 ## Where the fraud control actually lives
+159
View File
@@ -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