Item 5 of the MVP roadmap shipped on 2026-08-06: the app is live at gate-watch.possebom.com and the first real plate landed at 12:01 BRT. Records what the deploy taught, so a future session does not relearn it: the central Postgres is reached as postgres:5432 (DNS alias on traefik_proxy) and not as a LAN IP; the Protect webhook must use the LAN address because the public host answers 403 by design; cameras auto-register with the device id as location_name. Also documents why plate_unifi.rs models only the fields the handler reads — the production rejection that motivated it.
11 KiB
Gate Watch
Rust-based street plate monitor: LPR cameras watch the street in front of
Alexandre's house (and later his company), the system records every plate that
passes and notifies about the ones that matter. It observes only — there is
no gate, no whitelist, no authorization. See CONTEXT.md for the domain
glossary (Known Vehicle, Alert, Recurring Unknown).
Forked 2026-08-06 from gate-control (the prefecture's access-control system)
with gate control + whitelist sync removed. Drivers/companies survive as
owner identification metadata (who is that car), not authorization.
AI Behavioral Guidelines
Behavioral guidelines to reduce common LLM coding mistakes.
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
1. Think Before Coding
Don't assume. Don't hide confusion. Surface tradeoffs.
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
2. Simplicity First
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked; no abstractions for single-use code.
- If you write 200 lines and it could be 50, rewrite it.
3. Surgical Changes
Touch only what you must. Clean up only your own mess.
- Don't "improve" adjacent code; match existing style.
- Remove imports/variables/functions that YOUR changes made unused.
4. Goal-Driven Execution
Define success criteria. Loop until verified.
- "Fix the bug" → "Write a test that reproduces it, then make it pass".
- For multi-step tasks, state a brief plan with a verify step per item.
Quick Start
# Backend (JSON API + serves dist/ if present) on :8080
cargo run
# Frontend dev server on :5173 (proxies /api,/sse,/images,/static,/thumbnail → :8080)
cd frontend && bun install && bun dev
# Production build of the SPA (outputs to ../dist, which the backend serves)
cd frontend && bun run build
Architecture
src/ # Rust backend — JSON API only
├── main.rs # Entry point, server setup, DahuaEventWorker
├── config.rs # Env config (no GATE_API_KEY — the gate API is gone)
├── routes.rs # /api/* (Bearer) + public (health/thumbnail/webhooks) + SPA fallback
├── auth/ # JWT Bearer middleware (require_auth)
├── handlers/ # api_<resource>.rs per resource; api.rs = LPR webhooks
├── models/ # entities.rs, dashboard.rs (DTOs), plate_webhook/plate_unifi
├── repositories/ # SQLx queries
├── services/ # passage_ingest, plate_service, image, dahua_event_worker,
│ # telegram, whatsapp, email, passage_broadcast, notify
└── utils/ # plate_validator, digest_http (Digest auth GET), name_case, phone
frontend/ # React 19 + Vite + TanStack Router/Query + shadcn SPA
└── src/features/<x>/ # api/ (React Query) + components/ + data/schema.ts (zod)
Pipeline: webhook (/api/unifi, /api/plate) or Dahua attach stream →
Detection → passage_ingest::ingest (debounce) → image → register_passage
→ SSE broadcast → process_alerts (Telegram/WhatsApp/Email fan-out).
Detection sources
- UniFi Protect (MVP source) —
POST /api/unifi, Alarm Manager payload; see "UniFi integration" below. - Vzenith —
POST /api/platewebhook (legacy from gate-control; kept). - Dahua/Intelbras —
DahuaEventWorkerkeeps oneeventManager.cgi attachstream perprotocol='dahua'camera; camera added after boot needs restart.
Database
PostgreSQL + SQLx, migrations in migrations/ (auto-run on startup).
| Table | Description |
|---|---|
users |
Auth, telegram_id, phone, is_admin (deploy plan: everyone admin) |
vehicles |
Plates seen/registered (brand/model/color, driver_id XOR company_id = owner) |
passages |
Sightings (timestamp, camera, optional direction, image rect, is_whitelisted) |
drivers / companies |
Owner identification (people / organizations) |
cameras |
ip_address, location_name, protocol vzenith|dahua, credentials, crop_percent |
alerts |
Per-user subscriptions: plate, driver or company; channels TG/email/WA |
settings |
Key/value (parking_total_spots is a gate-control leftover, slated for removal) |
Gotchas inherited from gate-control (still true):
passages.vehicle_plateisCHAR(7)(bpchar),vehicles.plateisVARCHAR(10). Binding a&[String]astext[]casts the column and seq-scans; cast the param (= ANY($1::bpchar[])) to keepidx_passages_vehicle_plate.passages.camera_idholds the camera'sip_address(FK →cameras.ip_address,ON DELETE RESTRICT). For UniFi cameras the "IP" is the Protect device id/MAC (wart, see below).is_whitelistedon passages (and the vehicle queries feeding it) now means Known Vehicle (has owner). The column names stay for now; don't read authorization into them.
SQLx offline mode
- Binary build needs
SQLX_OFFLINE=true(uses.sqlx/). After adding/changing aquery!, runcargo sqlx prepare(needs a live dev DB) and commit.sqlx. cargo sqlx preparedeletes test-only.sqlxentries every run — restore those deletions, commit only the added files.SQLX_OFFLINE=true cargo checkis the real gate;--all-targetsoffline was never green (pre-existing).- Tests do NOT use SQLX_OFFLINE — test
query!macros need live Postgres at the.envDATABASE_URL. Prefer runtimesqlx::queryfor new worker code to avoid the prepare dance (pattern:distinct_field_values). - Several
tests/*.rstargets don't compile (inherited from gate-control — signature drift). Reliable gates:SQLX_OFFLINE=true cargo checkandcargo test --lib. Integration tests are#[ignore]+ testcontainers (DOCKER_HOST=unix://$HOME/.colima/default/docker.sock).
Configuration (.env)
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | - | PostgreSQL connection string |
TELEGRAM_TOKEN |
Yes | - | Telegram bot token (gate-watch's own bot) |
JWT_SECRET |
No | insecure default | JWT signing key |
IMAGES_PATH |
No | ./images | Capture storage |
PORT |
No | 8080 | Server port |
PASSAGE_DEBOUNCE_SECONDS |
No | 10 | Duplicate-detection window |
DIRECTION_INVERT_IP |
No | - | Camera whose in/out is inverted |
SMTP_*, WA_API_KEY, WA_BASE_URL, ENABLE_WHATSAPP_NOTIFY |
No | - | Alert channels (Telegram-only in MVP) |
ENABLE_CORS |
No | false | Dev-only permissive CORS |
UniFi integration (/api/unifi)
Alarm Manager webhook from UniFi Protect (AI Pro / AI Port do the LPR):
- Payload
{alarm: {triggers[], thumbnail?, snapshot?, ...}, timestamp};trigger.valueis the plate,trigger.devicethe Protect device id. - Photo comes base64 in
alarm.thumbnail(with or without thedata:image/jpeg;base64,prefix). Nothing is fetched from the Protect API. - No direction, no confidence, no rect in the payload — a UniFi Passage is
a plain sighting (
direction: None), by design (decided 2026-08-06). - Handler parses the raw string (not
Json<T>) to log unparseable payloads. models/plate_unifi.rsmodels ONLY the fields the handler reads (device,timestamp,value,thumbnail,snapshot). Protect sends many more and the set varies by rule and firmware — a real POST was rejected in production withmissing field "group"because the model demanded nine unread fields, and one missing field discards the whole request (the plate is lost). Do not "complete" the struct: a new field goes in only when read, and asOption.
Fixed 2026-08-06 (were gate-control warts): auto-register now uses
protocol='unifi' (Detection carries the source protocol), alarm.snapshot
is the image fallback when thumbnail is absent, and the payload contract has
unit tests (handlers/api.rs). The device MAC in ip_address stays — the
payload has no IP, and the column is just an identifier.
MVP roadmap (agreed 2026-08-06)
Poda: gate + whitelist sync out(done)Monitor dashboard summary (today/distinct/known/unknown), parking gone(done)UniFi warts:(done)unifiprotocol, snapshot fallback, payload testsRecurring Unknown alert(done —services/recurring_unknown.rs, on-ingest check + atomic silence claim inrecurring_unknown_alerts; constants 3 passages / 7-day window / 7-day silence,atalho:to move into settings).Deploy on kauai(done 2026-08-06 — live atgate-watch.possebom.com, first real plate registered 12:01 BRT;possebom-platesuntouched).
Live deployment facts (kauai):
- Stack in
/opt/stacks/gate-watch/, port 8084 → 8080. Protect Alarm Manager posts tohttp://10.2.0.100:8084/api/unifiover the LAN; the public host answers 403 there, by design (require_internal_ip). Requests arrive withip=10.2.0.1(the gateway), not the camera's address. - The central Postgres is reached as
postgres:5432— the DNS alias on thetraefik_proxynetwork, like thevemmarcarandup-trackstacks. There is no10.2.0.200host; pointing the.envat it crashlooped the container withNo route to hoston the first deploy. - Cameras auto-register on first plate (
plate_service.rs), so the Protect device id lands in bothip_addressandlocation_name— rename the location in the UI afterwards.
Deferred deliberately: image retention policy (no cleanup job yet), the company
site / multi-site topology (keep location_name per camera meaningful),
WhatsApp/e-mail channels (infra stays, Telegram-only at first).
Frontend notes (inherited, still true)
- Paginated/filtered list queries must set
placeholderData: keepPreviousDataor the search input unmounts mid-typing. - Each feature's zod schema must match its endpoint DTO exactly — a
schema.parsethat throws looks like an infinite spinner + refetch storm, and.nullable()still requires the key present. - TanStack routes are codegen'd by Vite, not tsc: after adding a route file,
run
bunx vite build/bun devto regeneraterouteTree.gen.ts, thenbun run buildis the authoritative gate (IDE squiggles lie). DialogContentwidth overrides need thesm:prefix (sm:max-w-6xl).- Dashboard passage cards show the full un-cropped capture — don't
reintroduce
object-coverwith fixed height (reverted once in gate-control). - ⌘K menu: server-backed lists need
shouldFilter={false}.
Build & Deploy
CARGO_TARGET_DIR=/Users/alexandre/.cargo-target— artifacts are NOT in./target/.- Frontend gate:
cd frontend && bun run build(tsc -b applies noUnusedLocals; plaintsc --noEmitis not enough). - Docker: multi-stage build (bun → rust → debian-slim), image
git.possebom.com/alexandre/gate-watch. - CI (
.gitea/workflows/docker.yml): build + push on main + Telegram notify. Needs repo secretsREGISTRY_TOKEN,TELEGRAM_TOKEN,TELEGRAM_CHAT_ID. No auto-deploy yet — redeploy on kauai withdocker compose pull && up -d. - Remote is self-hosted Gitea (
git.possebom.com/alexandre/gate-watch) — nogh; PRs via compare URL ortea.