An asynchronous camera monitoring hub built with FastAPI and OpenCV.
It pulls video from your cameras, detects motion in real time, emails you the evidence,
and pushes alerts and live video to any browser.
Status: the backend, camera engine, alerts and real-time delivery are done (Phases 1β2), and devices, users and sessions persist in PostgreSQL. Next up: event history with stored snapshots (rest of Phase 3), then a web dashboard (Phase 4). Progress is tracked in
ROADMAP.md. The project started life as a single OpenCV script, Thief Detection Notifier.
- Multi-camera ingestion: USB webcams, RTSP cameras (with automatic reconnection), video files and a built-in simulator, each on its own worker thread so the API never blocks
- Smart motion detection: an adaptive background that ignores dusk and passing clouds, a reset instead of an alarm when the lights switch on, debounced events, per-camera regions of interest, and best-frame selection
- Email alerts that are not lost: the annotated snapshot sent inline and as an attachment, with per-camera cooldowns; pending alerts are stored in the database and retried, even across restarts
- Real time: WebSocket alerts with per-camera subscriptions and replay of events missed while offline, and MJPEG live streams that work in a plain
<img>tag - Event history: every event is stored with its clean frame, annotated frame and thumbnail, browsable through the API, with signed image links and per-camera retention
- Device API: add, update, start and stop cameras, hot-reload detection settings, and test a source before saving it
- Persistent: PostgreSQL via async SQLAlchemy and Alembic migrations (applied on startup); camera passwords encrypted at rest with rotatable keys; an audit trail of every device and user change
- Observable and efficient: Prometheus metrics for every camera (analysed fps, time per frame, frame age, status) plus alerts, HTTP and event-loop lag; about 7 % of a core per 1080p camera at 10 fps (benchmarks)
- Resilient: supervised background tasks restart after a crash, events cut short by a crash are flagged on the next start, and shutdown is graceful: live streams end, cameras close their events, and readiness fails so traffic drains
- Secure by default: JWT with rotating refresh tokens (reuse detection survives restarts), roles, rate-limited login, single-use stream tickets, write-only camera passwords, security headers, and strict production config checks

An alert snapshot as emailed by the hub (simulated camera): the best frame of the event, with the moving region boxed.
flowchart LR
subgraph threads["Camera worker threads (one per camera)"]
S["Source<br/>webcam Β· RTSP Β· file Β· simulator"] --> D["Motion detector"] --> T["Event tracker"] --> J["JPEG encoder"]
end
J -->|"LoopBridge<br/>(latest frame wins)"| F["Live frames"]
J -->|"LoopBridge<br/>(events never dropped)"| R["Event recorder"]
subgraph loop["asyncio event loop"]
R -->|"store first"| P[("PostgreSQL<br/>+ snapshot files")]
R -->|"then publish"| B[("Event bus")]
F --> M["MJPEG streams<br/>& snapshots"]
B --> N["Notification service"] -->|"outbox, retries"| E["Email (SMTP)"]
B --> W["WebSocket clients"]
P -. "replay & history" .-> W
A["REST API"] --> DS["Device service"] --> CM["Camera manager"]
end
CM -. "start / stop / restart" .-> threads
Everything CPU- or IO-heavy (capture, decoding, detection, encoding) runs in worker threads. OpenCV releases the GIL, so cameras run in parallel, and the event loop only ever handles bytes. A test runs live cameras with asyncio's debug mode reporting every callback slower than 50 ms, and finds none.
src/vision_hub/
βββ core/ # Configuration, logging, security, errors, service container
βββ api/ # FastAPI routers, dependencies, middleware
βββ schemas/ # Pydantic request/response and WebSocket models
βββ domain/ # Pure domain models and ports (Protocols)
βββ services/ # Application logic: audit, auth, devices, events, health, notifications
βββ vision/ # Frame sources, motion detector, tracker, camera workers
βββ realtime/ # WebSocket connections and MJPEG streaming
βββ infra/ # Adapters: database, snapshot storage, event bus, notifiers, auth stores, rate limiting
Design decisions are recorded in ROADMAP.md, and the
rules every endpoint follows (errors, pagination, auth, real time) in
docs/api-conventions.md.
The whole stack with Docker (hub + PostgreSQL):
git clone https://github.com/AndrewTechTips/Thief-Detection-Notifier.git
cd Thief-Detection-Notifier
docker compose up --build --wait
docker compose exec api vision-hub create-user admin --role adminFor development you need uv, which installs Python 3.14 if needed, and a PostgreSQL database:
docker run -d --name vision-hub-db -p 127.0.0.1:5432:5432 \
-e POSTGRES_USER=vision_hub -e POSTGRES_PASSWORD=vision_hub -e POSTGRES_DB=vision_hub \
postgres:17-alpine
uv sync
uv run vision-hub create-user admin --role admin # migrates the database, prompts for a password
VISION_HUB_VISION__DEVICES_FILE=devices.example.toml uv run vision-hub serveOpen http://localhost:8000/docs, click Authorize and log in as admin. Two simulated
cameras are running, and a figure walks past each of them every 20β30 seconds. Devices from the
file are added to the database on first start; after that, the database is the source of truth.
The image runs as a non-root user on a read-only filesystem, has a built-in health check, and
keeps its data in the hub-data and pg-data volumes.
| Endpoint | Purpose | Access |
|---|---|---|
POST /api/v1/auth/token Β· /refresh Β· /logout |
OAuth2 login, refresh-token rotation, logout | public (rate-limited) |
GET /api/v1/auth/me Β· POST /api/v1/auth/tickets |
Current user Β· single-use ticket for browsers | logged in |
GET /api/v1/devices Β· /devices/{id} |
Cameras with live status and last event | logged in |
POST Β· PATCH Β· DELETE /api/v1/devices[/{id}] |
Add, update, remove cameras | admin |
POST /api/v1/devices/{id}/start Β· /stop |
Run or stop a camera | admin |
PUT /api/v1/devices/{id}/detection-config |
Hot-reload detection settings | admin |
POST /api/v1/devices/test |
Check a source works before saving it | admin |
GET /api/v1/devices/{id}/snapshot |
Latest frame as JPEG | logged in |
GET /api/v1/devices/{id}/stream |
MJPEG live stream | logged in (bearer or ticket) |
GET /api/v1/events Β· /events/{id} |
Event history (filter by camera and time) | logged in |
GET /api/v1/events/{id}/snapshot?kind= |
Event image: annotated, clean or thumbnail |
bearer or signed link |
GET /api/v1/audit |
Who changed which device or user, and when | admin |
WS /api/v1/ws/events |
Live motion and status events | ticket |
GET /api/v1/health/live Β· /ready |
Probes for Docker and Kubernetes | public |
Errors are always RFC 9457 application/problem+json
with a request_id that matches the server logs.
Browsers cannot send an Authorization header when opening a WebSocket or an <img>, so they
exchange their token for a short-lived, single-use ticket first:
const ticket = async () =>
(await (await fetch("/api/v1/auth/tickets", { method: "POST", headers: auth })).json()).ticket;
// Live video: a plain <img> tag
document.querySelector("#porch").src = `/api/v1/devices/porch/stream?ticket=${await ticket()}`;
// Live alerts
const ws = new WebSocket(`wss://${location.host}/api/v1/ws/events?ticket=${await ticket()}`);
ws.onmessage = ({ data }) => {
const message = JSON.parse(data); // {type, v, ts, device_id, data}
if (message.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
if (message.type === "motion.started") console.log(`Motion on ${message.device_id}`);
};
ws.onopen = () => ws.send(JSON.stringify({ type: "subscribe", devices: ["porch", "gate"] }));
// After a reconnect: {type: "resume", after: lastEventId} replays what was missedEvent responses include ready-to-use image URLs (snapshots[].url), signed and valid for an
hour, so <img src="..."> works without a token.
All settings are environment variables prefixed with VISION_HUB_, with __ separating nested
groups (for example VISION_HUB_SMTP__PASSWORD). Every option is documented in
.env.example. With VISION_HUB_APP__ENV=prod, the hub refuses to start
without an explicit JWT secret, admin password hash and database password, and rejects debug
mode, wildcard CORS/hosts and unencrypted SMTP.
- Cameras live in the database and are managed through the API. A TOML file
(
devices.example.toml) can seed devices the database does not have yet. A realdevices.tomlmay contain camera passwords, so it is git-ignored and should be mounted into containers, not baked into images. - Users are managed with
vision-hub create-user NAME --role admin|viewer, which also resets passwords.VISION_HUB_SECURITY__ADMIN_PASSWORD_HASHcreates the admin on first start instead. - Encryption at rest: set
VISION_HUB_SECURITY__ENCRYPTION_KEYSin production; outside production a key file is generated once indata/. Back it up, or stored camera passwords cannot be decrypted. - Migrations run on startup. For schema work:
uv run alembic revision --autogenerate -m "...". - Event history is kept for
VISION_HUB_STORAGE__RETENTION_DAYS(30) days; a camera can override it withretention_days. Images live underVISION_HUB_STORAGE__SNAPSHOTS_DIR. - Email alerts need
VISION_HUB_SMTP__ENABLED=trueplus a server; Gmail with an App Password works. Failed deliveries are retried with growing delays (VISION_HUB_NOTIFICATIONS__*) and survive restarts; an alert may arrive twice after a crash, but is never silently lost. - Shutdown:
SIGTERM/Ctrl-Cdrains gracefully. Requests still open afterVISION_HUB_APP__SHUTDOWN_TIMEOUT_SECONDS(10) are cancelled; give containers more than that (compose usesstop_grace_period: 30s). - Sensitivity is set per camera:
min_motion_area(fraction of the frame),pixel_threshold, regions of interest, and how long a quiet period ends an event.
GET /metrics serves Prometheus metrics. Set VISION_HUB_METRICS__TOKEN (32+ characters) and
give Prometheus the same token:
scrape_configs:
- job_name: vision-hub
static_configs:
- targets: ["vision-hub:8000"]
authorization:
credentials: "<VISION_HUB_METRICS__TOKEN>"Useful queries: rate(vision_hub_camera_frames_analysed_total[1m]) (analysed fps per camera),
vision_hub_camera_status{status="online"} == 0 (camera down),
vision_hub_camera_last_frame_age_seconds > 10 (stalled), vision_hub_alerts_pending > 0 and
histogram_quantile(0.99, rate(vision_hub_event_loop_lag_seconds_bucket[5m])).
Where the CPU goes and how the hub was tuned is in docs/performance.md.
uv run ruff check && uv run ruff format --check # lint and format
uv run mypy # strict type checking
uv run pytest # 570+ tests, ~100 % branch coverage
VISION_HUB_TEST_POSTGRES_URL=postgresql+asyncpg://vision_hub:vision_hub@localhost:5432/test \
uv run pytest tests/integration/db # repositories against PostgreSQL too
LOAD_SMOKE_SECONDS=60 uv run pytest tests/load -s # longer load soakThe load smoke test runs a real server with 5 cameras, 20 WebSocket clients, 2 MJPEG viewers and steady API traffic. Over 30 seconds on a laptop, event-loop lag stayed at p99 2 ms, the API answered at p95 6.5 ms, and every client received every motion event. GitHub Actions runs all checks (with a PostgreSQL service) plus the full Docker Compose stack on every push.
- LinkedIn: Andrei Condrea
- Email: condrea.andrey777@gmail.com
"It won't stop them β but they'll know you know." π―