Files
Alexandre Possebom 87c4ea32e0
Build and Push Docker Image / build (push) Successful in 5m32s
docs: mark deploy done, record kauai deployment facts
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.
2026-08-06 12:57:55 -03:00

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 → Detectionpassage_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.
  • VzenithPOST /api/plate webhook (legacy from gate-control; kept).
  • Dahua/IntelbrasDahuaEventWorker keeps one eventManager.cgi attach stream per protocol='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_plate is CHAR(7) (bpchar), vehicles.plate is VARCHAR(10). Binding a &[String] as text[] casts the column and seq-scans; cast the param (= ANY($1::bpchar[])) to keep idx_passages_vehicle_plate.
  • passages.camera_id holds the camera's ip_address (FK → cameras.ip_address, ON DELETE RESTRICT). For UniFi cameras the "IP" is the Protect device id/MAC (wart, see below).
  • is_whitelisted on 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 a query!, run cargo sqlx prepare (needs a live dev DB) and commit .sqlx.
  • cargo sqlx prepare deletes test-only .sqlx entries every run — restore those deletions, commit only the added files. SQLX_OFFLINE=true cargo check is the real gate; --all-targets offline was never green (pre-existing).
  • Tests do NOT use SQLX_OFFLINE — test query! macros need live Postgres at the .env DATABASE_URL. Prefer runtime sqlx::query for new worker code to avoid the prepare dance (pattern: distinct_field_values).
  • Several tests/*.rs targets don't compile (inherited from gate-control — signature drift). Reliable gates: SQLX_OFFLINE=true cargo check and cargo 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.value is the plate, trigger.device the Protect device id.
  • Photo comes base64 in alarm.thumbnail (with or without the data: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.rs models 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 with missing 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 as Option.

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)

  1. Poda: gate + whitelist sync out (done)
  2. Monitor dashboard summary (today/distinct/known/unknown), parking gone (done)
  3. UniFi warts: unifi protocol, snapshot fallback, payload tests (done)
  4. Recurring Unknown alert (done — services/recurring_unknown.rs, on-ingest check + atomic silence claim in recurring_unknown_alerts; constants 3 passages / 7-day window / 7-day silence, atalho: to move into settings).
  5. Deploy on kauai (done 2026-08-06 — live at gate-watch.possebom.com, first real plate registered 12:01 BRT; possebom-plates untouched).

Live deployment facts (kauai):

  • Stack in /opt/stacks/gate-watch/, port 8084 → 8080. Protect Alarm Manager posts to http://10.2.0.100:8084/api/unifi over the LAN; the public host answers 403 there, by design (require_internal_ip). Requests arrive with ip=10.2.0.1 (the gateway), not the camera's address.
  • The central Postgres is reached as postgres:5432 — the DNS alias on the traefik_proxy network, like the vemmarcar and up-track stacks. There is no 10.2.0.200 host; pointing the .env at it crashlooped the container with No route to host on the first deploy.
  • Cameras auto-register on first plate (plate_service.rs), so the Protect device id lands in both ip_address and location_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: keepPreviousData or the search input unmounts mid-typing.
  • Each feature's zod schema must match its endpoint DTO exactly — a schema.parse that 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 dev to regenerate routeTree.gen.ts, then bun run build is the authoritative gate (IDE squiggles lie).
  • DialogContent width overrides need the sm: prefix (sm:max-w-6xl).
  • Dashboard passage cards show the full un-cropped capture — don't reintroduce object-cover with 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; plain tsc --noEmit is 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 secrets REGISTRY_TOKEN, TELEGRAM_TOKEN, TELEGRAM_CHAT_ID. No auto-deploy yet — redeploy on kauai with docker compose pull && up -d.
  • Remote is self-hosted Gitea (git.possebom.com/alexandre/gate-watch) — no gh; PRs via compare URL or tea.