From 6536b5fa2395a5f46e9381b5c1964755e994eeee Mon Sep 17 00:00:00 2001 From: nikola Date: Tue, 19 May 2026 14:53:36 +0200 Subject: [PATCH] feat: initial commit --- .env.example | 26 ++ ...5-12_162155_video-surveillance-portal.json | 73 ++++ ...5-12_162235_video-surveillance-portal.json | 69 ++++ .sessions/latest.json | 69 ++++ AGENTS.md | 67 +++ Dockerfile | 16 + HANDOFF.md | 69 ++++ Makefile | 20 + config/mediamtx.yml | 13 + config/nginx.conf | 112 +++++ config/ssl.crt | 19 + config/ssl.key | 28 ++ docker-compose.yml | 71 ++++ frontend/index.html | 382 ++++++++++++++++++ requirements.txt | 41 ++ src/__init__.py | 0 src/alerts/__init__.py | 0 src/alerts/dispatcher.py | 81 ++++ src/alerts/emailer.py | 47 +++ src/alerts/sms.py | 23 ++ src/alerts/telegram.py | 42 ++ src/anpr/__init__.py | 0 src/anpr/plate_client.py | 103 +++++ src/api/__init__.py | 0 src/api/alerts.py | 54 +++ src/api/cameras.py | 207 ++++++++++ src/api/plates.py | 51 +++ src/api/streams.py | 37 ++ src/config.py | 45 +++ src/db/__init__.py | 0 src/db/database.py | 25 ++ src/db/models.py | 65 +++ src/main.py | 139 +++++++ src/onvif_client/__init__.py | 0 src/onvif_client/events.py | 79 ++++ src/onvif_client/ptz.py | 92 +++++ src/onvif_client/snapshot.py | 31 ++ src/recording/__init__.py | 0 src/recording/recorder.py | 61 +++ 39 files changed, 2257 insertions(+) create mode 100644 .env.example create mode 100644 .sessions/2026-05-12_162155_video-surveillance-portal.json create mode 100644 .sessions/2026-05-12_162235_video-surveillance-portal.json create mode 100644 .sessions/latest.json create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 HANDOFF.md create mode 100644 Makefile create mode 100644 config/mediamtx.yml create mode 100644 config/nginx.conf create mode 100644 config/ssl.crt create mode 100644 config/ssl.key create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/alerts/__init__.py create mode 100644 src/alerts/dispatcher.py create mode 100644 src/alerts/emailer.py create mode 100644 src/alerts/sms.py create mode 100644 src/alerts/telegram.py create mode 100644 src/anpr/__init__.py create mode 100644 src/anpr/plate_client.py create mode 100644 src/api/__init__.py create mode 100644 src/api/alerts.py create mode 100644 src/api/cameras.py create mode 100644 src/api/plates.py create mode 100644 src/api/streams.py create mode 100644 src/config.py create mode 100644 src/db/__init__.py create mode 100644 src/db/database.py create mode 100644 src/db/models.py create mode 100644 src/main.py create mode 100644 src/onvif_client/__init__.py create mode 100644 src/onvif_client/events.py create mode 100644 src/onvif_client/ptz.py create mode 100644 src/onvif_client/snapshot.py create mode 100644 src/recording/__init__.py create mode 100644 src/recording/recorder.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1bce3dc --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# .env — Video Surveillance Portal +# Copy this to .env and fill in your values + +# PlateRecognizer (free tier: https://app.platerecognizer.com/) +PLATERECOGNIZER_TOKEN= + +# SMTP for email alerts +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM= +ALERT_EMAIL_TO= + +# Twilio for SMS alerts +TWILIO_SID= +TWILIO_TOKEN= +TWILIO_FROM= +ALERT_SMS_TO= + +# Telegram Bot +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# Alert cooldown (seconds between alerts per camera) +ALERT_COOLDOWN_SEC=30 diff --git a/.sessions/2026-05-12_162155_video-surveillance-portal.json b/.sessions/2026-05-12_162155_video-surveillance-portal.json new file mode 100644 index 0000000..3dfb43b --- /dev/null +++ b/.sessions/2026-05-12_162155_video-surveillance-portal.json @@ -0,0 +1,73 @@ +{ + "session_id": "2026-05-12_162155_video-surveillance-portal", + "created": "2026-05-12T16:21:55.265756", + "model": "deepseek-v4-pro", + "project": "video-surveillance-portal", + "status": "in-progress", + "tags": [ + "cctv", + "webrtc", + "onvif", + "fastapi", + "anpr", + "twilio", + "telegram" + ], + "summary": "Video surveillance portal: 4 Docker containers (nginx, FastAPI, Redis, MediaMTX). ONVIF event polling, multi-channel alerts (Email/SMS/Telegram), ANPR via PlateRecognizer API, phone camera via WebRTC WHIP, live view grid, PTZ control, HTTPS for mobile access.", + "prompts": [], + "key_decisions": [ + "Skip local AI (Celeron N4000 too weak) — use camera built-in human detection", + "ONVIF PullPoint subscription for events", + "PlateRecognizer Cloud API instead of local ANPR", + "Phone camera via WebRTC WHIP ingest", + "HTTPS self-signed cert for mobile getUserMedia", + "SQLite instead of PostgreSQL", + "MediaMTX network_mode: host for direct WebRTC UDP" + ], + "pending": [ + "Test with real ICSee ONVIF camera", + "Configure Twilio credentials", + "Configure SMTP credentials", + "Configure Telegram bot token", + "Configure PlateRecognizer token", + "Test phone camera from Samsung S22 over HTTPS" + ], + "files_touched": [ + "docker-compose.yml", + "Dockerfile", + "requirements.txt", + "Makefile", + "config/nginx.conf", + "config/mediamtx.yml", + "src/main.py", + "src/config.py", + "src/api/cameras.py", + "src/api/alerts.py", + "src/api/plates.py", + "src/api/streams.py", + "src/onvif_client/events.py", + "src/onvif_client/ptz.py", + "src/onvif_client/snapshot.py", + "src/alerts/dispatcher.py", + "src/alerts/emailer.py", + "src/alerts/sms.py", + "src/alerts/telegram.py", + "src/anpr/plate_client.py", + "src/recording/recorder.py", + "src/db/database.py", + "src/db/models.py", + "frontend/index.html" + ], + "docker": { + "containers": [ + "vsp-nginx", + "vsp-app", + "vsp-redis", + "vsp-mediamtx" + ], + "ports": { + "http": 8081, + "https": 8444 + } + } +} \ No newline at end of file diff --git a/.sessions/2026-05-12_162235_video-surveillance-portal.json b/.sessions/2026-05-12_162235_video-surveillance-portal.json new file mode 100644 index 0000000..af99475 --- /dev/null +++ b/.sessions/2026-05-12_162235_video-surveillance-portal.json @@ -0,0 +1,69 @@ +{ + "session_id": "2026-05-12_162235_video-surveillance-portal", + "created": "2026-05-12T16:22:35.809084", + "model": "deepseek-v4-pro", + "project": "video-surveillance-portal", + "status": "in-progress", + "tags": [ + "cctv", + "webrtc", + "onvif", + "fastapi", + "anpr" + ], + "summary": "Video surveillance portal: 4 Docker containers. ONVIF event polling, multi-channel alerts, ANPR via PlateRecognizer API, phone camera via WebRTC WHIP, live view grid, PTZ control, HTTPS for mobile.", + "prompts": [], + "key_decisions": [ + "Skip local AI (Celeron N4000 too weak)", + "ONVIF PullPoint for events", + "PlateRecognizer Cloud API", + "Phone via WebRTC WHIP", + "HTTPS self-signed cert", + "SQLite instead of PostgreSQL", + "MediaMTX host network" + ], + "pending": [ + "Test with real ICSee ONVIF camera", + "Configure Twilio/SMTP/Telegram", + "Configure PlateRecognizer token", + "Test phone from Samsung S22" + ], + "files_touched": [ + "docker-compose.yml", + "Dockerfile", + "requirements.txt", + "Makefile", + "config/nginx.conf", + "config/mediamtx.yml", + "src/main.py", + "src/config.py", + "src/api/cameras.py", + "src/api/alerts.py", + "src/api/plates.py", + "src/api/streams.py", + "src/onvif_client/events.py", + "src/onvif_client/ptz.py", + "src/onvif_client/snapshot.py", + "src/alerts/dispatcher.py", + "src/alerts/emailer.py", + "src/alerts/sms.py", + "src/alerts/telegram.py", + "src/anpr/plate_client.py", + "src/recording/recorder.py", + "src/db/database.py", + "src/db/models.py", + "frontend/index.html" + ], + "docker": { + "containers": [ + "vsp-nginx", + "vsp-app", + "vsp-redis", + "vsp-mediamtx" + ], + "ports": { + "http": 8081, + "https": 8444 + } + } +} \ No newline at end of file diff --git a/.sessions/latest.json b/.sessions/latest.json new file mode 100644 index 0000000..af99475 --- /dev/null +++ b/.sessions/latest.json @@ -0,0 +1,69 @@ +{ + "session_id": "2026-05-12_162235_video-surveillance-portal", + "created": "2026-05-12T16:22:35.809084", + "model": "deepseek-v4-pro", + "project": "video-surveillance-portal", + "status": "in-progress", + "tags": [ + "cctv", + "webrtc", + "onvif", + "fastapi", + "anpr" + ], + "summary": "Video surveillance portal: 4 Docker containers. ONVIF event polling, multi-channel alerts, ANPR via PlateRecognizer API, phone camera via WebRTC WHIP, live view grid, PTZ control, HTTPS for mobile.", + "prompts": [], + "key_decisions": [ + "Skip local AI (Celeron N4000 too weak)", + "ONVIF PullPoint for events", + "PlateRecognizer Cloud API", + "Phone via WebRTC WHIP", + "HTTPS self-signed cert", + "SQLite instead of PostgreSQL", + "MediaMTX host network" + ], + "pending": [ + "Test with real ICSee ONVIF camera", + "Configure Twilio/SMTP/Telegram", + "Configure PlateRecognizer token", + "Test phone from Samsung S22" + ], + "files_touched": [ + "docker-compose.yml", + "Dockerfile", + "requirements.txt", + "Makefile", + "config/nginx.conf", + "config/mediamtx.yml", + "src/main.py", + "src/config.py", + "src/api/cameras.py", + "src/api/alerts.py", + "src/api/plates.py", + "src/api/streams.py", + "src/onvif_client/events.py", + "src/onvif_client/ptz.py", + "src/onvif_client/snapshot.py", + "src/alerts/dispatcher.py", + "src/alerts/emailer.py", + "src/alerts/sms.py", + "src/alerts/telegram.py", + "src/anpr/plate_client.py", + "src/recording/recorder.py", + "src/db/database.py", + "src/db/models.py", + "frontend/index.html" + ], + "docker": { + "containers": [ + "vsp-nginx", + "vsp-app", + "vsp-redis", + "vsp-mediamtx" + ], + "ports": { + "http": 8081, + "https": 8444 + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..afabf94 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# Video Surveillance Portal — AGENTS.md + +## Project +Web portal for video surveillance. IP cameras, smart devices, person detection alerts via email/SMS. + +## Stack (confirmed) +- Backend: Python 3.12 + FastAPI +- Stream relay: MediaMTX (RTSP → WebRTC/HLS) +- Detection: Camera built-in human detection (ONVIF events) — NO local AI on Celeron N4000 +- Message broker: Redis pub/sub +- Database: SQLite (128GB SSD, single-user) +- Frontend: Vanilla HTML/CSS/JS (single page, no framework) +- Deployment: Docker Compose (4 containers: nginx, app, redis, mediamtx) +- Notifications: SMTP (email), Twilio (SMS), Telegram +- ANPR: PlateRecognizer Cloud API (free tier, 2500 calls/month) + +## Directory Structure +``` +src/ + api/ FastAPI routes + detection/ Person detection engine + streams/ Camera stream management + alerts/ Alert dispatch (email, sms, webhook) + devices/ Smart device integration + storage/ Recording management + config/ App configuration +tests/ +config/ Nginx, docker, default configs +scripts/ Dev helpers +``` + +## Commands +``` +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +docker compose up -d +make fmt # ruff format +make lint # ruff check + mypy +make test # pytest -q +``` + +## Conventions +- Black formatting, 88 chars +- snake_case files, CapWords classes +- Type hints everywhere +- pytest tests mirroring src/ structure +- Conventional Commits + +## Key Design Goals +- Person detection → alert latency under 5 seconds (camera does detection) +- ONVIF PullPoint for event capture (fallback: FTP watchdog) +- RTSP stream relay with WebRTC for low-latency browser viewing +- Alert cooldown per camera to prevent spam +- Phone camera ingest via WebRTC WHIP (no native app) +- HTTPS required for mobile browser getUserMedia + +## Running +``` +docker compose up -d # http://localhost:8081 + https://10.0.50.210:8444 +docker compose down +make lint +make test +``` + +## Handoff +- See `HANDOFF.md` for latest session state and pending tasks +- Sessions: `.sessions/latest.json` + global `.codex/sessions/` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30bd73e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ + +ENV PYTHONUNBUFFERED=1 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..c3de9ed --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,69 @@ +# video-surveillance-portal — Session Handoff + +**Last Session:** 2026-05-12_162235_video-surveillance-portal +**Model:** deepseek-v4-pro +**Status:** in-progress +**Updated:** 2026-05-12T16:22:35.809084 + +## Summary +Video surveillance portal: 4 Docker containers. ONVIF event polling, multi-channel alerts, ANPR via PlateRecognizer API, phone camera via WebRTC WHIP, live view grid, PTZ control, HTTPS for mobile. + +## Key Decisions +- Skip local AI (Celeron N4000 too weak) +- ONVIF PullPoint for events +- PlateRecognizer Cloud API +- Phone via WebRTC WHIP +- HTTPS self-signed cert +- SQLite instead of PostgreSQL +- MediaMTX host network + +## Pending Tasks +- [ ] Test with real ICSee ONVIF camera +- [ ] Configure Twilio/SMTP/Telegram +- [ ] Configure PlateRecognizer token +- [ ] Test phone from Samsung S22 + +## Files Touched +- `docker-compose.yml` +- `Dockerfile` +- `requirements.txt` +- `Makefile` +- `config/nginx.conf` +- `config/mediamtx.yml` +- `src/main.py` +- `src/config.py` +- `src/api/cameras.py` +- `src/api/alerts.py` +- `src/api/plates.py` +- `src/api/streams.py` +- `src/onvif_client/events.py` +- `src/onvif_client/ptz.py` +- `src/onvif_client/snapshot.py` +- `src/alerts/dispatcher.py` +- `src/alerts/emailer.py` +- `src/alerts/sms.py` +- `src/alerts/telegram.py` +- `src/anpr/plate_client.py` +- `src/recording/recorder.py` +- `src/db/database.py` +- `src/db/models.py` +- `frontend/index.html` + +## Docker +```json +{ + "containers": [ + "vsp-nginx", + "vsp-app", + "vsp-redis", + "vsp-mediamtx" + ], + "ports": { + "http": 8081, + "https": 8444 + } +} +``` + +--- +*Auto-generated from session `2026-05-12_162235_video-surveillance-portal`. Run `python3 .codex/save-session.py --handoff video-surveillance-portal` to regenerate.* \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..081c26a --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: fmt lint test build up down + +fmt: + ruff format src/ tests/ + +lint: + ruff check src/ tests/ + mypy src/ --ignore-missing-imports + +test: + pytest -q tests/ + +build: + docker compose build + +up: + docker compose up -d + +down: + docker compose down diff --git a/config/mediamtx.yml b/config/mediamtx.yml new file mode 100644 index 0000000..ed77d5f --- /dev/null +++ b/config/mediamtx.yml @@ -0,0 +1,13 @@ +logLevel: info +logDestinations: [stdout] +api: yes +apiAddress: :9997 +rtsp: yes +protocols: [tcp, udp] +rtspAddress: :8554 +webrtc: yes +webrtcAddress: :8889 +hls: yes +hlsAddress: :8888 +paths: + phone-cam: diff --git a/config/nginx.conf b/config/nginx.conf new file mode 100644 index 0000000..0110972 --- /dev/null +++ b/config/nginx.conf @@ -0,0 +1,112 @@ +server { + listen 80; + server_name _; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://app:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /ws { + proxy_pass http://app:8000/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } + + location /hls/ { + proxy_pass http://localhost:8888/; + proxy_http_version 1.1; + } + + location /whep/ { + proxy_pass http://localhost:8889/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location /whip/ { + proxy_pass http://localhost:8889/; + proxy_http_version 1.1; + } + + location /media/ { + proxy_pass http://app:8000/media/; + } + + location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ { + expires 1h; + add_header Cache-Control "public, immutable"; + } +} + +# HTTPS server for phone camera access +server { + listen 443 ssl; + server_name _; + + ssl_certificate /etc/nginx/ssl/ssl.crt; + ssl_certificate_key /etc/nginx/ssl/ssl.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://app:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + + location /ws { + proxy_pass http://app:8000/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } + + location /hls/ { + proxy_pass http://localhost:8888/; + proxy_http_version 1.1; + } + + location /whep/ { + proxy_pass http://localhost:8889/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location /whip/ { + proxy_pass http://localhost:8889/; + proxy_http_version 1.1; + } + + location /media/ { + proxy_pass http://app:8000/media/; + } + + location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ { + expires 1h; + add_header Cache-Control "public, immutable"; + } +} diff --git a/config/ssl.crt b/config/ssl.crt new file mode 100644 index 0000000..b651bc7 --- /dev/null +++ b/config/ssl.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDTCCAfWgAwIBAgIUfk7t0JmuB+pX+bdLZDb4Nb/YbNswDQYJKoZIhvcNAQEL +BQAwFjEUMBIGA1UEAwwLMTAuMC41MC4yMTAwHhcNMjYwNTA1MTUyMjExWhcNMjcw +NTA1MTUyMjExWjAWMRQwEgYDVQQDDAsxMC4wLjUwLjIxMDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBANVxoY/NItynssUEHHvPa9NwmCyirq/SMWZ9LhoT +CfOZM1Rh7umTx8jfVkIkgRj38nCSN6adjGMSF0x1i2yUhRzCZqMhbgTgmRg83xjf +9TnTjdIX+cLDfwNQATMioKm9AEUgwNco+bq+CtNfj4GrEbiPUzpXxry8QXHxgHXs +WwJiV/3727J5UvkoUWy3WDlpGuEnyPT+m4ISFxqca9moc0DmPOA1rJpuKqgryMSY +/2z5nuTFSlJDbB5t3p/NM1ZsFLWw2sRt2jA3gm3+hUH/oDuNovIlLBUq2fKpK/0o +KVuiw6j3BlTFDMI3hC/NroRplBRHxf72ZVG52IDGDHfhANECAwEAAaNTMFEwHQYD +VR0OBBYEFL4w/HT2j50CMskln85+ieG/77fyMB8GA1UdIwQYMBaAFL4w/HT2j50C +Mskln85+ieG/77fyMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB +ABkOxgkdOg/SfNJ6A06azGclosnTASRYI6SCnesfH6vIEgs0BmH+eaG5Mgy56dxg +BL8vIslKK8yz0m3STTgsepHhOePBZJ92JASvFsjSpEJCq3BkrbyFK8+g6OiOd0T/ +d+3Hn0dne25ZLRdHNZeDTe+pHPj7ReejBGOv/udDLDdxWhpV+sdWTPU2S+Up5GQT +d9EIr7GzasrtrEViqbqP8QUXmmLg8ENGePnXMHgQ3NlxFZu4sfG9cfDUm15LMYIe +g9K0p7v63FUKbmS+fqQEQvxTK/RGs4UeHcRge0cwXHEqiUqliVreKFtomhB2dCN+ +AoB68PQcO6VUsKyhehE5CIM= +-----END CERTIFICATE----- diff --git a/config/ssl.key b/config/ssl.key new file mode 100644 index 0000000..f94a7be --- /dev/null +++ b/config/ssl.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDVcaGPzSLcp7LF +BBx7z2vTcJgsoq6v0jFmfS4aEwnzmTNUYe7pk8fI31ZCJIEY9/JwkjemnYxjEhdM +dYtslIUcwmajIW4E4JkYPN8Y3/U5043SF/nCw38DUAEzIqCpvQBFIMDXKPm6vgrT +X4+BqxG4j1M6V8a8vEFx8YB17FsCYlf9+9uyeVL5KFFst1g5aRrhJ8j0/puCEhca +nGvZqHNA5jzgNayabiqoK8jEmP9s+Z7kxUpSQ2webd6fzTNWbBS1sNrEbdowN4Jt +/oVB/6A7jaLyJSwVKtnyqSv9KClbosOo9wZUxQzCN4Qvza6EaZQUR8X+9mVRudiA +xgx34QDRAgMBAAECggEAAOTcjUnzKf5svrKfgB7Ef0KuZs6RaSBXGgYyCC/TTaBs +pp9D5jpNNn9F81OTbb1V3ot09ZwSbkO7AegtcOzKZIb5VKzllNgaIdXH+Nz6KpAM +UWOEkjqor4Fg/m21t7h8WxKbYhUAs+DzrOzEhefIUl65QoJMXYui3bsD81+aCImd +wdxUJvkBw1wuFhP9eKSves3kgacq2lg/YOCfVVOndB7rx1kpvftfOR1sAJI/wYEy +AnDUXURBcYxp0OPFmA5p3QAnDjiFudEm60RArOfkKeo6fuMfS0DoXuQP7EV14Swy +dxyJUk+r7KWb3zMYTAaJdjSREl5Gt6ZNR/8+4a71jQKBgQD2YRqB9efV9IiQy5ZI +v4YhJiihB1yxtuZXQkUW4FYoq+8JVcSPZCd7JPTMPnJMU5gxdSt5QbZIY8jRlyTL +u2eRm0S8a343Nm6+ItiaKUsnVLIptWMJ7T7cXNH3gN+e9jYKXrThzhkRy0SO46pA +Y8s+eM1bycKDfd+Lm3TCGnHIwwKBgQDdx0sDsZjJxQwfnCDckis2lWhMlUZvxxqP +6AO/jy/hKbW8yMkkh6kF2M5acVoopJUMmEjaTxnr/mmkfGWiwYO0l68k613sbB3V +k2s4qWgK2GqAmycIO5/ZwPLfECpKIwkLQMQaDIFQjgWa1O1oeWdNWisYbuF5IT// +L6OzkPiW2wKBgG0/eYWPCJHysGukGqt/YXZpeTKOFSgoNdMTa5dNOmcmdgPpqBL9 +EhSkunemaQA3QRENJ43I+Y730CA/qO092BrUgpIaCAlrdYeR5AHXtZ9nCuk7qQLJ +1EnaTrA00POnsSa//+zwemVNgMTrZp7OoVy+LDE7Makks28wK4G2QCYJAoGAVETT +phWnXpN25dPnUKhhrx3aQa8+0l4vI5sfQXxgzweQPKl2dGDvwn78+bS0c1kGIYED +vZ/s05UaAxwZalxpdQdlz3t3dswmEe8wAJmuJODSpwdNL32oYY9FOajkyMLDPvlf +Ch7STA+K5agehWIL9IJcea44ElSmAorRgvuE2Z0CgYAVMC5iNFZb7psDLE0ff8xW +ueoygM5tLQ0ZXRQ21PaKEIo3VcBlYoF5QWzbmHafoRl9bNUuzd9J26F6JkImidfF +1FygVsijtJeemPOyTrzYkMQbL+x/c7elHwehRI1EvlleUH9cCNt4jy+S+zbkbPLj +Jpk+IHa0CdqEQdqBHGn8TQ== +-----END PRIVATE KEY----- diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8197010 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,71 @@ +services: + mediamtx: + image: bluenviron/mediamtx:latest + container_name: vsp-mediamtx + restart: unless-stopped + network_mode: host + volumes: + - ./config/mediamtx.yml:/mediamtx.yml:ro + - vsp-recordings:/recordings + environment: + - MTX_PATH=/mediamtx.yml + + redis: + image: redis:7-alpine + container_name: vsp-redis + restart: unless-stopped + ports: + - "127.0.0.1:6379:6379" + volumes: + - vsp-redis:/data + + app: + build: . + container_name: vsp-app + restart: unless-stopped + ports: + - "127.0.0.1:8000:8000" + volumes: + - ./:/app:ro + - vsp-recordings:/recordings + - vsp-data:/data + environment: + - DATABASE_URL=sqlite:////data/vsp.db + - REDIS_URL=redis://redis:6379/0 + - MEDIAMTX_API=http://localhost:9997 + - PLATERECOGNIZER_TOKEN=${PLATERECOGNIZER_TOKEN:-} + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASS=${SMTP_PASS:-} + - SMTP_FROM=${SMTP_FROM:-} + - TWILIO_SID=${TWILIO_SID:-} + - TWILIO_TOKEN=${TWILIO_TOKEN:-} + - TWILIO_FROM=${TWILIO_FROM:-} + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} + - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} + - ALERT_EMAIL_TO=${ALERT_EMAIL_TO:-} + - ALERT_SMS_TO=${ALERT_SMS_TO:-} + - ALERT_COOLDOWN_SEC=${ALERT_COOLDOWN_SEC:-30} + depends_on: + - redis + + nginx: + image: nginx:alpine + container_name: vsp-nginx + restart: unless-stopped + ports: + - "8081:80" + - "8444:443" + volumes: + - ./frontend:/usr/share/nginx/html:ro + - ./config/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./config/ssl.crt:/etc/nginx/ssl/ssl.crt:ro + - ./config/ssl.key:/etc/nginx/ssl/ssl.key:ro + depends_on: + - app + +volumes: + vsp-recordings: + vsp-redis: + vsp-data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c551a48 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,382 @@ + + + + + +🎥 Video Surveillance Portal + + + +
+

🎥 VIDEO SURVEILLANCE

+

— Dark Portal —

+ + + + +
+
+

📱 Phone Camera Ingest

+
+ +
+

Opens your phone/PC camera and streams to the portal

+
+
+
+

📹 Cameras

+
+

No cameras added yet. Go to Settings to add one.

+
+
+
+ + +
+
+

🚨 Alert History

+
+ +
TimeCameraTypeSnapshotChannels
No alerts yet
+
+
+
+ + +
+
+

🚗 License Plate Log

+
+ +
TimeCameraPlateEventConfidenceSnapshot
No plates logged yet
+
+
+
+ + +
+
+

➕ Add Camera

+
+
+
+
+
+
+
+ +
+
+
+

📋 Manage Cameras

+
+
+
+

🔑 API Keys

+

Set these in .env file or environment variables

+
+
+
+
+
+
+ +
+
+
+
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e213f24 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,41 @@ +# Video Surveillance Portal — requirements + +# Core +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.30,<1.0 +pydantic>=2.0,<3.0 +pydantic-settings>=2.0,<3.0 + +# ONVIF +onvif-zeep>=0.2.11,<1.0 + +# Redis +redis>=5.0,<6.0 + +# Database +sqlalchemy>=2.0,<3.0 +aiosqlite>=0.20,<1.0 + +# HTTP client (ANPR, camera snapshots) +httpx>=0.27,<1.0 + +# Image processing +Pillow>=10.0,<12.0 +opencv-python-headless>=4.9,<5.0 + +# ANPR +requests>=2.31,<3.0 + +# Alerts +# email via stdlib smtplib (no extra deps) +# twilio +twilio>=9.0,<10.0 + +# Recording (subprocess ffmpeg) + +# Dev +ruff>=0.5,<1.0 +mypy>=1.10,<2.0 +pytest>=8.0,<9.0 +pytest-asyncio>=0.24,<1.0 +httpx-ws>=0.5,<1.0 # for testing WebSocket diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/alerts/__init__.py b/src/alerts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/alerts/dispatcher.py b/src/alerts/dispatcher.py new file mode 100644 index 0000000..2ecac9b --- /dev/null +++ b/src/alerts/dispatcher.py @@ -0,0 +1,81 @@ +"""Alert dispatcher — routes alerts to all configured channels.""" + +import asyncio +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +async def dispatch_alert(cam: Any, alert_obj: Any, snapshot_path: str | None): + """ + Main dispatch: sends alert via all configured channels. + Updates alert.channels_sent with comma-separated list of channels used. + """ + channels = [] + + tasks = [] + if _is_configured("email"): + tasks.append(("email", _send_email_alert(cam, alert_obj, snapshot_path))) + if _is_configured("sms"): + tasks.append(("sms", _send_sms_alert(cam, alert_obj))) + if _is_configured("telegram"): + tasks.append(("telegram", _send_telegram_alert(cam, alert_obj, snapshot_path))) + + results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True) + + for (name, _), result in zip(tasks, results): + if result is True: + channels.append(name) + elif isinstance(result, Exception): + logger.warning(f"Alert channel {name} failed: {result}") + + # Update alert record + if channels and alert_obj: + from src.db.database import async_session + from src.db.models import Alert + from sqlalchemy import select + + async with async_session() as db: + result = await db.execute(select(Alert).where(Alert.id == alert_obj.id)) + a = result.scalar_one_or_none() + if a: + a.channels_sent = ",".join(channels) + await db.commit() + + +def _is_configured(channel: str) -> bool: + from src.config import settings + + if channel == "email": + return bool(settings.smtp_host and settings.alert_email_to) + if channel == "sms": + return bool(settings.twilio_sid and settings.alert_sms_to) + if channel == "telegram": + return bool(settings.telegram_bot_token and settings.telegram_chat_id) + return False + + +async def _send_email_alert(cam, alert_obj, snapshot_path: str | None) -> bool: + """Send email alert with optional snapshot attachment.""" + from src.alerts.emailer import send_email + + subject = f"[ALERT] Human detected — {cam.name}" + body = f"Human detected on camera '{cam.name}' at {alert_obj.created_at}." + return await send_email(subject, body, snapshot_path) + + +async def _send_sms_alert(cam, alert_obj) -> bool: + """Send SMS alert via Twilio.""" + from src.alerts.sms import send_sms + + body = f"[VSP] Human detected on {cam.name} at {alert_obj.created_at.strftime('%H:%M:%S')}" + return await send_sms(body) + + +async def _send_telegram_alert(cam, alert_obj, snapshot_path: str | None) -> bool: + """Send Telegram alert with optional photo.""" + from src.alerts.telegram import send_telegram + + text = f"🦇 *HUMAN DETECTED*\n📷 {cam.name}\n🕐 {alert_obj.created_at.strftime('%Y-%m-%d %H:%M:%S')}" + return await send_telegram(text, snapshot_path) diff --git a/src/alerts/emailer.py b/src/alerts/emailer.py new file mode 100644 index 0000000..d20c2c5 --- /dev/null +++ b/src/alerts/emailer.py @@ -0,0 +1,47 @@ +"""Email alert sender via SMTP.""" + +import asyncio +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.image import MIMEImage + +from src.config import settings + +logger = logging.getLogger(__name__) + + +async def send_email(subject: str, body: str, attachment_path: str | None = None) -> bool: + """Send an email alert. Returns True on success.""" + if not settings.smtp_host: + return False + + def _send(): + msg = MIMEMultipart() + msg["From"] = settings.smtp_from + msg["To"] = settings.alert_email_to + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain")) + + if attachment_path: + try: + with open(attachment_path, "rb") as f: + img = MIMEImage(f.read()) + img.add_header("Content-Disposition", "attachment", filename="snapshot.jpg") + msg.attach(img) + except FileNotFoundError: + pass + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as server: + server.starttls() + if settings.smtp_user: + server.login(settings.smtp_user, settings.smtp_pass) + server.sendmail(settings.smtp_from, [settings.alert_email_to], msg.as_string()) + + try: + await asyncio.to_thread(_send) + return True + except Exception as e: + logger.error(f"Email failed: {e}") + return False diff --git a/src/alerts/sms.py b/src/alerts/sms.py new file mode 100644 index 0000000..8f7b099 --- /dev/null +++ b/src/alerts/sms.py @@ -0,0 +1,23 @@ +"""SMS alert sender via Twilio.""" + +import logging + +from src.config import settings + +logger = logging.getLogger(__name__) + + +async def send_sms(body: str) -> bool: + """Send SMS via Twilio. Returns True on success.""" + if not settings.twilio_sid or not settings.twilio_from or not settings.alert_sms_to: + return False + + try: + from twilio.rest import Client + + client = Client(settings.twilio_sid, settings.twilio_token) + message = client.messages.create(body=body, from_=settings.twilio_from, to=settings.alert_sms_to) + return message.sid is not None + except Exception as e: + logger.error(f"SMS failed: {e}") + return False diff --git a/src/alerts/telegram.py b/src/alerts/telegram.py new file mode 100644 index 0000000..b671448 --- /dev/null +++ b/src/alerts/telegram.py @@ -0,0 +1,42 @@ +"""Telegram alert sender.""" + +import asyncio +import logging + +from src.config import settings + +logger = logging.getLogger(__name__) + + +async def send_telegram(text: str, photo_path: str | None = None) -> bool: + """Send Telegram message. Returns True on success.""" + if not settings.telegram_bot_token or not settings.telegram_chat_id: + return False + + try: + import httpx + + async with httpx.AsyncClient(timeout=10) as client: + if photo_path: + # Send photo with caption + with open(photo_path, "rb") as f: + files = {"photo": f} + data = {"chat_id": settings.telegram_chat_id, "caption": text, "parse_mode": "Markdown"} + resp = await client.post( + f"https://api.telegram.org/bot{settings.telegram_bot_token}/sendPhoto", + data=data, + files=files, + ) + else: + resp = await client.post( + f"https://api.telegram.org/bot{settings.telegram_bot_token}/sendMessage", + json={ + "chat_id": settings.telegram_chat_id, + "text": text, + "parse_mode": "Markdown", + }, + ) + return resp.status_code == 200 + except Exception as e: + logger.error(f"Telegram failed: {e}") + return False diff --git a/src/anpr/__init__.py b/src/anpr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/anpr/plate_client.py b/src/anpr/plate_client.py new file mode 100644 index 0000000..bc17d56 --- /dev/null +++ b/src/anpr/plate_client.py @@ -0,0 +1,103 @@ +"""ANPR client — PlateRecognizer Cloud API.""" + +import logging + +from src.config import settings + +logger = logging.getLogger(__name__) + + +async def recognize_plate(image_path: str) -> dict | None: + """ + Send image to PlateRecognizer API for license plate recognition. + Returns dict with {plate, confidence, vehicle_type} or None. + """ + if not settings.platerecognizer_token: + logger.warning("PlateRecognizer token not configured") + return None + + try: + import httpx + + async with httpx.AsyncClient(timeout=15) as client: + with open(image_path, "rb") as f: + files = {"upload": f} + headers = {"Authorization": f"Token {settings.platerecognizer_token}"} + # Using the snapshot API endpoint + resp = await client.post( + "https://api.platerecognizer.com/v1/plate-reader/", + headers=headers, + files=files, + ) + + if resp.status_code == 200: + data = resp.json() + results = data.get("results", []) + if results: + best = results[0] + return { + "plate": best.get("plate", ""), + "confidence": best.get("score", 0.0), + "vehicle_type": best.get("vehicle", {}).get("type", "unknown"), + } + elif resp.status_code == 429: + logger.warning("PlateRecognizer rate limit hit") + else: + logger.warning(f"PlateRecognizer API error: {resp.status_code}") + except Exception as e: + logger.error(f"PlateRecognizer request failed: {e}") + + return None + + +async def process_plate_frame( + image_path: str, camera_id: int, db_session +) -> str | None: + """ + Process a frame for license plate recognition. + Saves result to database and determines arrival/departure status. + """ + from src.db.models import PlateLog + from sqlalchemy import select, desc + import datetime + + result = await recognize_plate(image_path) + if not result or not result["plate"]: + return None + + plate = result["plate"] + conf = result["confidence"] + + # Determine arrival vs departure + # Look for this plate in recent logs (last 10 minutes) + cutoff = datetime.datetime.utcnow() - datetime.timedelta(minutes=10) + existing = await db_session.execute( + select(PlateLog) + .where( + PlateLog.plate == plate, + PlateLog.created_at >= cutoff, + ) + .order_by(desc(PlateLog.created_at)) + ) + recent = existing.scalars().all() + + if not recent: + event_type = "arrived" + else: + last_event = recent[0] + if last_event.event_type == "arrived": + event_type = "departed" + else: + event_type = "seen" + + log = PlateLog( + camera_id=camera_id, + plate=plate, + confidence=conf, + event_type=event_type, + snapshot_path=image_path, + ) + db_session.add(log) + await db_session.commit() + + return event_type diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/alerts.py b/src/api/alerts.py new file mode 100644 index 0000000..b186dab --- /dev/null +++ b/src/api/alerts.py @@ -0,0 +1,54 @@ +"""Alert history API routes.""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from src.db.database import get_db +from src.db.models import Alert + +router = APIRouter() + + +class AlertOut(BaseModel): + id: int + camera_id: int + camera_name: str | None = None + type: str + snapshot_url: str | None = None + channels_sent: str | None = None + created_at: str + + model_config = {"from_attributes": True} + + +@router.get("/", response_model=list[AlertOut]) +async def list_alerts( + camera_id: int | None = Query(None), + limit: int = Query(50, le=200), + offset: int = Query(0), + db: AsyncSession = Depends(get_db), +): + query = select(Alert).order_by(desc(Alert.created_at)) + if camera_id is not None: + query = query.where(Alert.camera_id == camera_id) + + query = query.offset(offset).limit(limit) + result = await db.execute(query) + alerts = result.scalars().all() + + out = [] + for a in alerts: + out.append( + AlertOut( + id=a.id, + camera_id=a.camera_id, + camera_name=a.camera.name if a.camera else None, + type=a.type, + snapshot_url=f"/api/media/{a.snapshot_path.split('/')[-1]}" if a.snapshot_path else None, + channels_sent=a.channels_sent, + created_at=a.created_at.isoformat() if a.created_at else "", + ) + ) + return out diff --git a/src/api/cameras.py b/src/api/cameras.py new file mode 100644 index 0000000..5576d13 --- /dev/null +++ b/src/api/cameras.py @@ -0,0 +1,207 @@ +"""Camera management API routes.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from src.db.database import get_db +from src.db.models import Camera + +router = APIRouter() + + +class CameraCreate(BaseModel): + name: str + rtsp_url: str + onvif_host: str | None = None + onvif_port: int = 80 + onvif_user: str | None = None + onvif_pass: str | None = None + enabled: bool = True + + +class CameraUpdate(BaseModel): + name: str | None = None + rtsp_url: str | None = None + onvif_host: str | None = None + onvif_port: int | None = None + onvif_user: str | None = None + onvif_pass: str | None = None + enabled: bool | None = None + + +class CameraOut(BaseModel): + id: int + name: str + rtsp_url: str + onvif_host: str | None + onvif_port: int + onvif_user: str | None + enabled: bool + stream_name: str + + model_config = {"from_attributes": True} + + @classmethod + def from_orm_custom(cls, cam: Camera): + return cls( + id=cam.id, + name=cam.name, + rtsp_url=cam.rtsp_url, + onvif_host=cam.onvif_host, + onvif_port=cam.onvif_port or 80, + onvif_user=cam.onvif_user, + enabled=cam.enabled, + stream_name=f"cam_{cam.id}", + ) + + +@router.get("/", response_model=list[CameraOut]) +async def list_cameras(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Camera).order_by(Camera.id)) + cameras = result.scalars().all() + return [CameraOut.from_orm_custom(c) for c in cameras] + + +@router.post("/", response_model=CameraOut, status_code=201) +async def add_camera(data: CameraCreate, db: AsyncSession = Depends(get_db)): + cam = Camera( + name=data.name, + rtsp_url=data.rtsp_url, + onvif_host=data.onvif_host, + onvif_port=data.onvif_port, + onvif_user=data.onvif_user, + onvif_pass=data.onvif_pass, + enabled=data.enabled, + ) + db.add(cam) + await db.commit() + await db.refresh(cam) + + # Register stream with MediaMTX + await _register_mediamtx_stream(cam.id, cam.rtsp_url) + + return CameraOut.from_orm_custom(cam) + + +@router.get("/{camera_id}", response_model=CameraOut) +async def get_camera(camera_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Camera).where(Camera.id == camera_id)) + cam = result.scalar_one_or_none() + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + return CameraOut.from_orm_custom(cam) + + +@router.patch("/{camera_id}", response_model=CameraOut) +async def update_camera(camera_id: int, data: CameraUpdate, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Camera).where(Camera.id == camera_id)) + cam = result.scalar_one_or_none() + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + + update_data = data.model_dump(exclude_unset=True) + for key, val in update_data.items(): + setattr(cam, key, val) + + await db.commit() + await db.refresh(cam) + return CameraOut.from_orm_custom(cam) + + +@router.delete("/{camera_id}", status_code=204) +async def delete_camera(camera_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Camera).where(Camera.id == camera_id)) + cam = result.scalar_one_or_none() + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + await db.delete(cam) + await db.commit() + + +class PTZMove(BaseModel): + direction: str # up, down, left, right, stop + + +class PTZZoom(BaseModel): + zoom_in: bool = True + + +@router.post("/{camera_id}/ptz") +async def ptz_move(camera_id: int, data: PTZMove, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Camera).where(Camera.id == camera_id)) + cam = result.scalar_one_or_none() + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + if not cam.onvif_host: + raise HTTPException(status_code=400, detail="No ONVIF configured for this camera") + + from src.onvif_client.ptz import ptz_move as do_ptz + + await do_ptz(cam, data.direction) + return {"status": "ok"} + + +@router.post("/{camera_id}/ptz/zoom") +async def ptz_zoom(camera_id: int, data: PTZZoom, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Camera).where(Camera.id == camera_id)) + cam = result.scalar_one_or_none() + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + if not cam.onvif_host: + raise HTTPException(status_code=400, detail="No ONVIF configured for this camera") + + from src.onvif_client.ptz import ptz_zoom as do_zoom + + await do_zoom(cam, data.zoom_in) + return {"status": "ok"} + + +@router.post("/{camera_id}/snapshot") +async def take_snapshot(camera_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Camera).where(Camera.id == camera_id)) + cam = result.scalar_one_or_none() + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + if not cam.onvif_host: + raise HTTPException(status_code=400, detail="No ONVIF configured for this camera") + + import os + import datetime + + from src.onvif_client.snapshot import grab_onvif_snapshot + from src.config import settings + + img = await grab_onvif_snapshot(cam) + if not img: + raise HTTPException(status_code=500, detail="Failed to grab snapshot") + + timestamp = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S") + filename = f"snap_{camera_id}_{timestamp}.jpg" + filepath = os.path.join(settings.recording_dir, filename) + with open(filepath, "wb") as f: + f.write(img) + return {"status": "ok", "path": filepath} + + +async def _register_mediamtx_stream(camera_id: int, rtsp_url: str): + """Register a camera stream with MediaMTX via its API.""" + import httpx + from src.config import settings + + stream_name = f"cam_{camera_id}" + try: + async with httpx.AsyncClient(timeout=5) as client: + await client.post( + f"{settings.mediamtx_api}/v3/config/paths/add/{stream_name}", + json={ + "name": stream_name, + "source": rtsp_url, + "sourceOnDemand": True, + "sourceOnDemandStartTimeout": "10s", + "sourceOnDemandCloseAfter": "5s", + }, + ) + except Exception: + pass # MediaMTX might not be running yet diff --git a/src/api/plates.py b/src/api/plates.py new file mode 100644 index 0000000..3d14017 --- /dev/null +++ b/src/api/plates.py @@ -0,0 +1,51 @@ +"""License plate log API routes.""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from src.db.database import get_db +from src.db.models import PlateLog + +router = APIRouter() + + +class PlateOut(BaseModel): + id: int + camera_id: int + camera_name: str | None = None + plate: str + confidence: float | None = None + event_type: str + snapshot_url: str | None = None + created_at: str + + model_config = {"from_attributes": True} + + +@router.get("/", response_model=list[PlateOut]) +async def list_plates( + limit: int = Query(50, le=200), + offset: int = Query(0), + db: AsyncSession = Depends(get_db), +): + query = select(PlateLog).order_by(desc(PlateLog.created_at)).offset(offset).limit(limit) + result = await db.execute(query) + plates = result.scalars().all() + + out = [] + for p in plates: + out.append( + PlateOut( + id=p.id, + camera_id=p.camera_id, + camera_name=p.camera.name if p.camera else None, + plate=p.plate, + confidence=p.confidence, + event_type=p.event_type, + snapshot_url=f"/api/media/{p.snapshot_path.split('/')[-1]}" if p.snapshot_path else None, + created_at=p.created_at.isoformat() if p.created_at else "", + ) + ) + return out diff --git a/src/api/streams.py b/src/api/streams.py new file mode 100644 index 0000000..8b528e1 --- /dev/null +++ b/src/api/streams.py @@ -0,0 +1,37 @@ +"""Stream management API routes (WHEP/WHIP URLs, phone camera ingest).""" + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter() + + +class StreamInfo(BaseModel): + stream_name: str + whep_url: str # Browser playback URL (WebRTC) + whip_url: str # Browser ingest URL (phone camera) + hls_url: str # Fallback HLS URL + + +@router.get("/camera/{camera_id}", response_model=StreamInfo) +async def get_camera_stream(camera_id: int): + """Get stream URLs for a camera.""" + name = f"cam_{camera_id}" + return StreamInfo( + stream_name=name, + whep_url=f"/whep/{name}/whep", + whip_url=f"/whip/{name}/whip", + hls_url=f"/hls/{name}/index.m3u8", + ) + + +@router.get("/phone", response_model=StreamInfo) +async def get_phone_stream(): + """Get stream URLs for phone camera ingest.""" + name = "phone-cam" + return StreamInfo( + stream_name=name, + whep_url=f"/whep/{name}/whep", + whip_url=f"/whip/{name}/whip", + hls_url=f"/hls/{name}/index.m3u8", + ) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..a4b2e2e --- /dev/null +++ b/src/config.py @@ -0,0 +1,45 @@ +"""Application settings loaded from environment variables.""" + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_url: str = "sqlite:///data/vsp.db" + redis_url: str = "redis://localhost:6379/0" + mediamtx_api: str = "http://localhost:9997" + + # ANPR + platerecognizer_token: str = "" + + # SMTP + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_pass: str = "" + smtp_from: str = "" + + # Twilio + twilio_sid: str = "" + twilio_token: str = "" + twilio_from: str = "" + + # Telegram + telegram_bot_token: str = "" + telegram_chat_id: str = "" + + # Alert recipients + alert_email_to: str = "" + alert_sms_to: str = "" + + # Alert cooldown in seconds (per camera) + alert_cooldown_sec: int = 30 + + # Recording + recording_dir: str = "/recordings" + recording_pre_sec: int = 15 + recording_post_sec: int = 30 + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} + + +settings = Settings() diff --git a/src/db/__init__.py b/src/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/db/database.py b/src/db/database.py new file mode 100644 index 0000000..87bf0f4 --- /dev/null +++ b/src/db/database.py @@ -0,0 +1,25 @@ +"""Database connection and session management.""" + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker + +from src.config import settings + +# Convert sqlite:/// to sqlite+aiosqlite:/// +db_url = settings.database_url +if db_url.startswith("sqlite:///"): + db_url = db_url.replace("sqlite:///", "sqlite+aiosqlite:///", 1) + +engine = create_async_engine(db_url, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db() -> AsyncSession: + async with async_session() as session: + yield session + + +async def init_db(): + from src.db.models import Base + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/src/db/models.py b/src/db/models.py new file mode 100644 index 0000000..b165be8 --- /dev/null +++ b/src/db/models.py @@ -0,0 +1,65 @@ +"""SQLAlchemy ORM models.""" + +import datetime + +from sqlalchemy import Column, Integer, String, DateTime, Text, Float, ForeignKey, Boolean +from sqlalchemy.orm import DeclarativeBase, relationship + + +class Base(DeclarativeBase): + pass + + +class Camera(Base): + __tablename__ = "cameras" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + rtsp_url = Column(String, nullable=False) + onvif_host = Column(String, nullable=True) + onvif_port = Column(Integer, default=80) + onvif_user = Column(String, nullable=True) + onvif_pass = Column(String, nullable=True) + enabled = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + + alerts = relationship("Alert", back_populates="camera", cascade="all, delete-orphan") + plates = relationship("PlateLog", back_populates="camera", cascade="all, delete-orphan") + + +class Alert(Base): + __tablename__ = "alerts" + + id = Column(Integer, primary_key=True, autoincrement=True) + camera_id = Column(Integer, ForeignKey("cameras.id"), nullable=False) + type = Column(String, nullable=False) # "human", "motion", "vehicle" + snapshot_path = Column(String, nullable=True) + channels_sent = Column(String, nullable=True) # comma-separated: email,sms,telegram + created_at = Column(DateTime, default=datetime.datetime.utcnow) + + camera = relationship("Camera", back_populates="alerts") + + +class PlateLog(Base): + __tablename__ = "plate_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + camera_id = Column(Integer, ForeignKey("cameras.id"), nullable=False) + plate = Column(String, nullable=False) + confidence = Column(Float, nullable=True) + event_type = Column(String, nullable=False) # "arrived", "departed", "seen" + snapshot_path = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + + camera = relationship("Camera", back_populates="plates") + + +class Recording(Base): + __tablename__ = "recordings" + + id = Column(Integer, primary_key=True, autoincrement=True) + camera_id = Column(Integer, ForeignKey("cameras.id"), nullable=False) + alert_id = Column(Integer, ForeignKey("alerts.id"), nullable=True) + file_path = Column(String, nullable=False) + duration_sec = Column(Float, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..33d6049 --- /dev/null +++ b/src/main.py @@ -0,0 +1,139 @@ +"""FastAPI application entrypoint.""" + +import asyncio +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from src.config import settings +from src.db.database import init_db +from src.api import cameras, alerts, plates, streams + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + # Start ONVIF polling as background task if cameras are configured + # (runs in separate task, doesn't block startup) + asyncio.create_task(poll_onvif_events()) + yield + + +app = FastAPI(title="Video Surveillance Portal", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount media directory for snapshots/recordings +import os + +os.makedirs(settings.recording_dir, exist_ok=True) + +# API routes +app.include_router(cameras.router, prefix="/api/cameras", tags=["cameras"]) +app.include_router(alerts.router, prefix="/api/alerts", tags=["alerts"]) +app.include_router(plates.router, prefix="/api/plates", tags=["plates"]) +app.include_router(streams.router, prefix="/api/streams", tags=["streams"]) + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} + + +# ─── ONVIF background polling ─────────────────────────── +async def poll_onvif_events(): + """Poll ONVIF cameras for human detection events.""" + from src.db.database import async_session + from src.db.models import Camera + + while True: + try: + async with async_session() as db: + from sqlalchemy import select + + result = await db.execute(select(Camera).where(Camera.enabled == True)) + cameras_list = result.scalars().all() + + for cam in cameras_list: + if cam.onvif_host: + try: + await _check_onvif_camera(cam) + except Exception: + pass # Camera might be offline, skip + except Exception: + pass + await asyncio.sleep(2) # Poll every 2 seconds + + +async def _check_onvif_camera(cam): + """Check a single ONVIF camera for events.""" + import json + import datetime + + from src.onvif_client.events import pull_onvif_events + from src.alerts.dispatcher import dispatch_alert + + events = await pull_onvif_events(cam) + if not events: + return + + from src.db.database import async_session + from src.db.models import Alert + from sqlalchemy import select, desc + + for event in events: + # Only react to HumanDetection, not generic Motion + if "human" not in str(event).lower(): + continue + + # Check cooldown + async with async_session() as db: + last_alert = await db.execute( + select(Alert) + .where(Alert.camera_id == cam.id) + .order_by(desc(Alert.created_at)) + .limit(1) + ) + last = last_alert.scalar_one_or_none() + if last and ( + datetime.datetime.utcnow() - last.created_at + ).total_seconds() < settings.alert_cooldown_sec: + continue + + # Dispatch alert + snapshot_path = await _grab_snapshot(cam) + + alert = Alert(camera_id=cam.id, type="human", snapshot_path=snapshot_path) + db.add(alert) + await db.commit() + + # Send notifications + await dispatch_alert(cam, alert, snapshot_path) + + +async def _grab_snapshot(cam) -> str | None: + """Grab a snapshot from the camera via ONVIF or HTTP.""" + import os + import datetime + + try: + from src.onvif_client.snapshot import grab_onvif_snapshot + + img_data = await grab_onvif_snapshot(cam) + if img_data: + timestamp = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S") + filename = f"snap_{cam.id}_{timestamp}.jpg" + filepath = os.path.join(settings.recording_dir, filename) + with open(filepath, "wb") as f: + f.write(img_data) + return filepath + except Exception: + pass + return None diff --git a/src/onvif_client/__init__.py b/src/onvif_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/onvif_client/events.py b/src/onvif_client/events.py new file mode 100644 index 0000000..31a48ab --- /dev/null +++ b/src/onvif_client/events.py @@ -0,0 +1,79 @@ +"""ONVIF event polling — PullPoint subscription for motion/human detection.""" + +import asyncio +from typing import Any + + +async def pull_onvif_events(cam: Any) -> list[dict]: + """ + Poll ONVIF PullPoint subscription for events. + Returns list of raw event dicts (motion, human detection, etc.). + """ + try: + from onvif import ONVIFCamera + + wsdl_dir = None # Let zeep use bundled WSDL + onvif_cam = ONVIFCamera( + cam.onvif_host, + cam.onvif_port or 80, + cam.onvif_user or "admin", + cam.onvif_pass or "", + wsdl_dir=wsdl_dir, + ) + + # Create PullPoint subscription + event_service = onvif_cam.create_events_service() + pull_point = event_service.CreatePullPointSubscription() + + # Pull messages with short timeout + messages = pull_point.PullMessages( + Timeout="PT1S", # 1 second timeout + MessageLimit=10, + ) + + events = [] + if messages and hasattr(messages, "NotificationMessage"): + for msg in messages.NotificationMessage or []: + event_data = _parse_onvif_message(msg) + if event_data: + events.append(event_data) + + return events + + except Exception: + return [] + + +def _parse_onvif_message(msg: Any) -> dict | None: + """Parse a raw ONVIF notification message into a dict.""" + try: + data = {"raw": str(msg)} + # Try extracting common fields + if hasattr(msg, "Message"): + inner = msg.Message + if hasattr(inner, "Message"): + data["description"] = str(inner.Message) + if hasattr(inner, "Data"): + for item in inner.Data or []: + if hasattr(item, "SimpleItem"): + for si in item.SimpleItem or []: + name = getattr(si, "Name", "") + value = getattr(si, "Value", "") + data[name.lower()] = value + return data + except Exception: + return None + + +async def probe_onvif(host: str, port: int = 80, user: str = "admin", password: str = "") -> bool: + """Probe whether an ONVIF camera is reachable and responsive.""" + try: + from onvif import ONVIFCamera + + cam = ONVIFCamera(host, port, user, password) + # Try to get device info + dev_mgmt = cam.create_devicemgmt_service() + info = dev_mgmt.GetDeviceInformation() + return bool(info and getattr(info, "Manufacturer", None)) + except Exception: + return False diff --git a/src/onvif_client/ptz.py b/src/onvif_client/ptz.py new file mode 100644 index 0000000..2547114 --- /dev/null +++ b/src/onvif_client/ptz.py @@ -0,0 +1,92 @@ +"""ONVIF PTZ control.""" + +from typing import Any + + +async def ptz_move(cam: Any, direction: str, speed: float = 0.5): + """ + Move PTZ camera. + direction: "up", "down", "left", "right", "stop" + speed: 0.0 to 1.0 + """ + try: + from onvif import ONVIFCamera + + onvif_cam = ONVIFCamera( + cam.onvif_host, + cam.onvif_port or 80, + cam.onvif_user or "admin", + cam.onvif_pass or "", + ) + + ptz = onvif_cam.create_ptz_service() + + # Get profile token + media = onvif_cam.create_media_service() + profiles = media.GetProfiles() + if not profiles: + return + + profile_token = profiles[0].token + + # Get PTZ status for current position + status = ptz.GetStatus({"ProfileToken": profile_token}) + + pan = status.Position.PanTilt.x if status and status.Position else 0.0 + tilt = status.Position.PanTilt.y if status and status.Position else 0.0 + + # Map direction to pan/tilt vectors + vectors = { + "up": (0.0, speed), + "down": (0.0, -speed), + "left": (-speed, 0.0), + "right": (speed, 0.0), + "stop": (0.0, 0.0), + } + pan_vec, tilt_vec = vectors.get(direction, (0.0, 0.0)) + + ptz.RelativeMove( + { + "ProfileToken": profile_token, + "Translation": { + "PanTilt": {"x": pan_vec, "y": tilt_vec}, + "Zoom": {"x": 0.0}, + }, + } + ) + + except Exception as e: + raise RuntimeError(f"PTZ move failed: {e}") + + +async def ptz_zoom(cam: Any, zoom_in: bool = True): + """Zoom PTZ camera in or out.""" + try: + from onvif import ONVIFCamera + + onvif_cam = ONVIFCamera( + cam.onvif_host, + cam.onvif_port or 80, + cam.onvif_user or "admin", + cam.onvif_pass or "", + ) + + ptz = onvif_cam.create_ptz_service() + media = onvif_cam.create_media_service() + profiles = media.GetProfiles() + if not profiles: + return + profile_token = profiles[0].token + + ptz.RelativeMove( + { + "ProfileToken": profile_token, + "Translation": { + "PanTilt": {"x": 0.0, "y": 0.0}, + "Zoom": {"x": 0.3 if zoom_in else -0.3}, + }, + } + ) + + except Exception as e: + raise RuntimeError(f"PTZ zoom failed: {e}") diff --git a/src/onvif_client/snapshot.py b/src/onvif_client/snapshot.py new file mode 100644 index 0000000..2b8d777 --- /dev/null +++ b/src/onvif_client/snapshot.py @@ -0,0 +1,31 @@ +"""ONVIF snapshot grab.""" + + +async def grab_onvif_snapshot(cam) -> bytes | None: + """Grab a JPEG snapshot from an ONVIF camera.""" + try: + from onvif import ONVIFCamera + + onvif_cam = ONVIFCamera( + cam.onvif_host, + cam.onvif_port or 80, + cam.onvif_user or "admin", + cam.onvif_pass or "", + ) + media = onvif_cam.create_media_service() + profiles = media.GetProfiles() + if not profiles: + return None + + token = profiles[0].token + uri_response = media.GetSnapshotUri({"ProfileToken": token}) + if uri_response and uri_response.Uri: + import httpx + + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(uri_response.Uri) + if resp.status_code == 200: + return resp.content + return None + except Exception: + return None diff --git a/src/recording/__init__.py b/src/recording/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/recording/recorder.py b/src/recording/recorder.py new file mode 100644 index 0000000..de4ae77 --- /dev/null +++ b/src/recording/recorder.py @@ -0,0 +1,61 @@ +"""Recording module — FFmpeg-based clip capture on events.""" + +import asyncio +import logging +import os +import subprocess +from datetime import datetime + +from src.config import settings + +logger = logging.getLogger(__name__) + + +async def record_clip( + rtsp_url: str, + camera_id: int, + alert_id: int | None = None, + pre_sec: int | None = None, + post_sec: int | None = None, +) -> str | None: + """ + Record a video clip around an event. + Captures 'pre_sec' seconds before + 'post_sec' seconds after. + This is a best-effort: starts recording from NOW and runs for post_sec. + Real pre-event recording requires a ring buffer (future enhancement). + """ + pre = pre_sec if pre_sec is not None else settings.recording_pre_sec + post = post_sec if post_sec is not None else settings.recording_post_sec + total_dur = pre + post + + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + filename = f"rec_cam{camera_id}_{timestamp}.mp4" + filepath = os.path.join(settings.recording_dir, filename) + + try: + # Spawn ffmpeg to record from RTSP for `post_sec` seconds + cmd = [ + "ffmpeg", + "-y", + "-rtsp_transport", "tcp", + "-i", rtsp_url, + "-t", str(post), # Record for post_sec + "-c:v", "copy", # Copy video stream (no re-encode) + "-c:a", "aac", # Re-encode audio to AAC + "-movflags", "+faststart", + filepath, + ] + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + await proc.wait() + + if proc.returncode == 0 and os.path.exists(filepath): + return filepath + except Exception as e: + logger.error(f"Recording failed: {e}") + + return None