Skip to content

Repository files navigation

🚨 IoT Vision Hub

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.

CI

Python 3.14 FastAPI Pydantic v2 OpenCV 5 WebSockets uv


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.


✨ Features

  • 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

Annotated snapshot attached to an alert email
An alert snapshot as emailed by the hub (simulated camera): the best frame of the event, with the moving region boxed.


πŸ—οΈ How It Works

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
Loading

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.


πŸš€ Quick Start

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 admin

For 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 serve

Open 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.


πŸ”Œ API Overview

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.

Real time from a browser

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 missed

Event responses include ready-to-use image URLs (snapshots[].url), signed and valid for an hour, so <img src="..."> works without a token.


βš™οΈ Configuration

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 real devices.toml may 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_HASH creates the admin on first start instead.
  • Encryption at rest: set VISION_HUB_SECURITY__ENCRYPTION_KEYS in production; outside production a key file is generated once in data/. 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 with retention_days. Images live under VISION_HUB_STORAGE__SNAPSHOTS_DIR.
  • Email alerts need VISION_HUB_SMTP__ENABLED=true plus 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-C drains gracefully. Requests still open after VISION_HUB_APP__SHUTDOWN_TIMEOUT_SECONDS (10) are cancelled; give containers more than that (compose uses stop_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.

Metrics

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.


πŸ§ͺ Quality

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 soak

The 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.


πŸ“¬ Contact

"It won't stop them β€” but they'll know you know." 🎯

About

Motion detection on frame deltas that captures the intruder and emails the frame from a background thread, so no frames drop.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages