feat: initial commit

This commit is contained in:
nikola
2026-05-19 14:53:36 +02:00
commit 6536b5fa23
39 changed files with 2257 additions and 0 deletions
+26
View File
@@ -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
@@ -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
}
}
}
@@ -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
}
}
}
+69
View File
@@ -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
}
}
}
+67
View File
@@ -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/`
+16
View File
@@ -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"]
+69
View File
@@ -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.*
+20
View File
@@ -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
+13
View File
@@ -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:
+112
View File
@@ -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";
}
}
+19
View File
@@ -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-----
+28
View File
@@ -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-----
+71
View File
@@ -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:
+382
View File
@@ -0,0 +1,382 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎥 Video Surveillance Portal</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Orbitron:wght@400;700&display=swap');
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
--blood:#8b0000;--blood-bright:#cc0000;--crimson:#660000;
--ash:#2a2525;--bone:#c0b8b0;--dark:#050505;--panel:rgba(8,5,5,.94);
--border:rgba(139,0,0,.2);--text:#b0a8a0;--dim:#555;
}
body{background:var(--dark);color:var(--text);font-family:'Orbitron',sans-serif;min-height:100vh}
#app{max-width:1400px;margin:0 auto;padding:15px}
h1{font-family:'Cinzel',serif;font-weight:700;color:var(--blood-bright);text-shadow:0 0 8px var(--crimson);text-align:center;margin-bottom:5px;font-size:1.8rem;letter-spacing:3px}
.subtitle{text-align:center;color:#666;font-size:.7rem;margin-bottom:18px;font-family:'Cinzel',serif}
/* NAV */
nav{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-bottom:18px}
nav button{font-family:'Cinzel',serif;font-size:.7rem;font-weight:600;padding:8px 18px;background:transparent;color:var(--bone);border:1px solid var(--border);border-radius:5px;cursor:pointer;text-transform:uppercase;letter-spacing:1px;transition:all .25s}
nav button:hover,nav button.active{background:rgba(139,0,0,.3);border-color:var(--blood-bright);color:#fff;box-shadow:0 0 10px var(--crimson)}
.tab{display:none}.tab.active{display:block}
/* PANELS */
.panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:20px;margin-bottom:15px}
.panel h2{font-family:'Cinzel',serif;font-weight:700;color:var(--blood-bright);margin-bottom:12px;font-size:1rem;letter-spacing:2px}
/* CAMERA GRID */
.cam-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px}
.cam-card{background:rgba(0,0,0,.4);border:1px solid var(--border);border-radius:8px;overflow:hidden;position:relative}
.cam-card video,.cam-card img{width:100%;height:240px;object-fit:cover;background:#000;display:block}
.cam-card .cam-label{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.75);color:var(--bone);padding:4px 10px;border-radius:4px;font-size:.65rem;font-family:'Cinzel',serif}
.cam-card .cam-status{position:absolute;top:8px;right:8px;width:10px;height:10px;border-radius:50%}
.cam-card .cam-status.online{background:#0f0;box-shadow:0 0 8px #0f0}
.cam-card .cam-status.offline{background:#f00;box-shadow:0 0 8px #f00}
.cam-card .cam-ptz{position:absolute;bottom:8px;right:8px;display:flex;gap:4px}
.cam-card .cam-ptz button{width:28px;height:28px;background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.15);border-radius:4px;color:#fff;cursor:pointer;font-size:.7rem;display:flex;align-items:center;justify-content:center}
.cam-card .cam-ptz button:hover{background:rgba(139,0,0,.5)}
.cam-card .cam-actions{position:absolute;bottom:8px;left:8px;display:flex;gap:4px}
.cam-card .cam-actions button{font-family:'Cinzel',serif;font-size:.55rem;padding:4px 8px;background:rgba(139,0,0,.4);color:#fff;border:none;border-radius:3px;cursor:pointer}
/* PHONE CAMERA */
.phone-cam-section{text-align:center;padding:30px}
#phoneStreamBtn{font-family:'Cinzel',serif;font-weight:700;font-size:1rem;padding:14px 36px;background:rgba(139,0,0,.5);color:#fff;border:2px solid var(--blood-bright);border-radius:8px;cursor:pointer;text-transform:uppercase;letter-spacing:2px;box-shadow:0 0 15px var(--crimson);transition:all .3s}
#phoneStreamBtn:hover{background:rgba(139,0,0,.7);box-shadow:0 0 25px var(--blood)}
#phoneStreamBtn.streaming{background:rgba(0,100,0,.5);border-color:#0f0;box-shadow:0 0 15px #0f0}
#phonePreview{max-width:400px;margin:12px auto;border-radius:8px;overflow:hidden;display:none}
#phonePreview video{width:100%;height:auto;background:#000}
/* FORMS */
.form-row{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-bottom:12px}
.form-row input,.form-row select{font-family:'Orbitron',sans-serif;font-size:.7rem;padding:8px 12px;background:#000;color:var(--text);border:1px solid var(--border);border-radius:5px;min-width:140px}
.form-row input:focus{outline:none;border-color:var(--blood-bright)}
.form-row label{font-size:.6rem;color:var(--dim);text-transform:uppercase;letter-spacing:1px;display:block;margin-bottom:3px}
.form-row .field{display:flex;flex-direction:column}
.btn{font-family:'Cinzel',serif;font-weight:600;font-size:.7rem;padding:8px 18px;background:rgba(139,0,0,.4);color:#fff;border:1px solid var(--blood-bright);border-radius:5px;cursor:pointer;text-transform:uppercase;letter-spacing:1px;transition:all .2s}
.btn:hover{background:rgba(139,0,0,.7);box-shadow:0 0 12px var(--crimson)}
.btn.danger{background:rgba(80,0,0,.5);border-color:#600}
.btn.danger:hover{background:rgba(120,0,0,.7)}
/* TABLES */
table{width:100%;border-collapse:collapse;font-size:.7rem}
th{text-align:left;padding:8px 10px;background:rgba(139,0,0,.1);border-bottom:1px solid var(--border);color:var(--bone);font-family:'Cinzel',serif;text-transform:uppercase;letter-spacing:1px;font-size:.6rem}
td{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,.03);color:var(--text)}
tr:hover td{background:rgba(139,0,0,.04)}
.badge{padding:3px 8px;border-radius:3px;font-size:.55rem;font-family:'Cinzel',serif;text-transform:uppercase}
.badge.arrived{background:rgba(0,100,0,.3);color:#0f0}.badge.departed{background:rgba(100,0,0,.3);color:#f66}.badge.seen{background:rgba(100,100,0,.3);color:#ff0}
.badge.human{background:rgba(139,0,0,.3);color:#f66}
.snap-thumb{width:60px;height:40px;object-fit:cover;border-radius:3px;cursor:pointer;border:1px solid var(--border)}
/* TOAST */
.toast{position:fixed;bottom:20px;right:20px;z-index:999;background:var(--panel);border:1px solid var(--blood-bright);border-radius:8px;padding:12px 18px;box-shadow:0 0 20px var(--crimson);animation:toastIn .3s ease,toastOut .3s ease 3s forwards;font-family:'Cinzel',serif;font-size:.75rem}
@keyframes toastIn{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}}
@keyframes toastOut{from{transform:translateY(0);opacity:1}to{transform:translateY(40px);opacity:0}}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:500;display:flex;align-items:center;justify-content:center}
.modal-overlay img{max-width:90vw;max-height:90vh;border-radius:8px;cursor:pointer}
@media(max-width:600px){.cam-grid{grid-template-columns:1fr}.form-row{flex-direction:column}}
</style>
</head>
<body>
<div id="app">
<h1>🎥 VIDEO SURVEILLANCE</h1>
<p class="subtitle">— Dark Portal —</p>
<nav>
<button class="active" data-tab="live">📹 Live View</button>
<button data-tab="alerts">🚨 Alerts</button>
<button data-tab="plates">🚗 Plates</button>
<button data-tab="settings">⚙️ Settings</button>
</nav>
<!-- LIVE VIEW TAB -->
<section class="tab active" id="tab-live">
<div class="panel" id="phonePanel">
<h2>📱 Phone Camera Ingest</h2>
<div class="phone-cam-section">
<button id="phoneStreamBtn">📱 Start Phone Camera</button>
<div id="phonePreview"><video id="phoneLocalVideo" muted autoplay playsinline></video></div>
<p style="color:var(--dim);font-size:.6rem;margin-top:8px">Opens your phone/PC camera and streams to the portal</p>
</div>
</div>
<div class="panel">
<h2>📹 Cameras</h2>
<div class="cam-grid" id="camGrid">
<p style="color:var(--dim);grid-column:1/-1;text-align:center;padding:40px">No cameras added yet. Go to Settings to add one.</p>
</div>
</div>
</section>
<!-- ALERTS TAB -->
<section class="tab" id="tab-alerts">
<div class="panel">
<h2>🚨 Alert History</h2>
<div style="overflow-x:auto">
<table><thead><tr><th>Time</th><th>Camera</th><th>Type</th><th>Snapshot</th><th>Channels</th></tr></thead>
<tbody id="alertTable"><tr><td colspan="5" style="color:var(--dim);text-align:center">No alerts yet</td></tr></tbody></table>
</div>
</div>
</section>
<!-- PLATES TAB -->
<section class="tab" id="tab-plates">
<div class="panel">
<h2>🚗 License Plate Log</h2>
<div style="overflow-x:auto">
<table><thead><tr><th>Time</th><th>Camera</th><th>Plate</th><th>Event</th><th>Confidence</th><th>Snapshot</th></tr></thead>
<tbody id="plateTable"><tr><td colspan="6" style="color:var(--dim);text-align:center">No plates logged yet</td></tr></tbody></table>
</div>
</div>
</section>
<!-- SETTINGS TAB -->
<section class="tab" id="tab-settings">
<div class="panel">
<h2> Add Camera</h2>
<div class="form-row">
<div class="field"><label>Name</label><input id="camName" placeholder="Front Door"></div>
<div class="field"><label>RTSP URL</label><input id="camRtsp" placeholder="rtsp://192.168.1.100:554/stream1" style="min-width:260px"></div>
<div class="field"><label>ONVIF Host</label><input id="camOnvifHost" placeholder="192.168.1.100"></div>
<div class="field"><label>ONVIF Port</label><input id="camOnvifPort" value="80" style="min-width:60px"></div>
<div class="field"><label>ONVIF User</label><input id="camOnvifUser" value="admin"></div>
<div class="field"><label>ONVIF Pass</label><input id="camOnvifPass" type="password"></div>
<button class="btn" id="addCamBtn" style="align-self:flex-end">Add Camera</button>
</div>
</div>
<div class="panel">
<h2>📋 Manage Cameras</h2>
<div id="camList"></div>
</div>
<div class="panel">
<h2>🔑 API Keys</h2>
<p style="color:var(--dim);font-size:.65rem;margin-bottom:10px">Set these in <code>.env</code> file or environment variables</p>
<div class="form-row">
<div class="field"><label>PlateRecognizer Token</label><input id="prToken" placeholder="token_..."></div>
<div class="field"><label>Twilio SID</label><input id="twSid" placeholder="AC..."></div>
<div class="field"><label>Twilio Token</label><input id="twToken" type="password"></div>
<div class="field"><label>Twilio From</label><input id="twFrom" placeholder="+1234567890"></div>
<div class="field"><label>SMS To</label><input id="smsTo" placeholder="+1234567890"></div>
<button class="btn" id="saveKeysBtn">Save Keys</button>
</div>
</div>
</section>
</div>
<script>
// ═══════════════════════════════════════════
// VSP FRONTEND — Single Page App
// ═══════════════════════════════════════════
const API = '/api';
let cameras = [];
let phoneStreaming = false;
let phonePC = null;
let phoneStream = null;
// === NAVIGATION ===
document.querySelectorAll('nav button').forEach(b=>b.addEventListener('click',()=>{
document.querySelectorAll('nav button').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.getElementById('tab-'+b.dataset.tab).classList.add('active');
if(b.dataset.tab==='alerts') loadAlerts();
if(b.dataset.tab==='plates') loadPlates();
if(b.dataset.tab==='live') loadCameras();
if(b.dataset.tab==='settings') loadSettings();
}));
// === TOAST ===
function toast(msg){const t=document.createElement('div');t.className='toast';t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),3300)}
// === API HELPERS ===
async function api(path,opts={}){const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opts});if(!r.ok){const e=await r.text();throw new Error(e)}if(r.status===204)return null;return r.json()}
// ═══════════════════════════════════════════
// LIVE VIEW — WebRTC playback
// ═══════════════════════════════════════════
async function loadCameras(){
try{
cameras = await api('/cameras/');
renderCamGrid();
}catch(e){console.error(e)}
}
async function renderCamGrid(){
const grid = document.getElementById('camGrid');
if(!cameras.length){
grid.innerHTML='<p style="color:var(--dim);grid-column:1/-1;text-align:center;padding:40px">No cameras added. Go to Settings → Add Camera.</p>';
return;
}
grid.innerHTML = cameras.map(c=>`
<div class="cam-card" id="card-${c.id}">
<div class="cam-label">${esc(c.name)}</div>
<div class="cam-status online" id="status-${c.id}"></div>
<video id="vid-${c.id}" muted autoplay playsinline style="width:100%;height:240px;background:#000"></video>
<div class="cam-actions">
<button onclick="snapshotCam(${c.id})" title="Snapshot">📸</button>
<button onclick="deleteCam(${c.id})" title="Delete">🗑️</button>
</div>
<div class="cam-ptz">
<button onclick="ptz(${c.id},'up')">▲</button>
<button onclick="ptz(${c.id},'down')">▼</button>
<button onclick="ptz(${c.id},'left')">◀</button>
<button onclick="ptz(${c.id},'right')">▶</button>
<button onclick="ptzZoom(${c.id},true)" title="Zoom in">+</button>
<button onclick="ptzZoom(${c.id},false)" title="Zoom out"></button>
</div>
</div>
`).join('');
// Connect WebRTC for each camera
cameras.forEach(c=>startWHEP(c.id));
}
async function startWHEP(camId){
try{
const stream = await api(`/streams/camera/${camId}`);
const video = document.getElementById('vid-'+camId);
if(!video) return;
const pc = new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
pc.addTransceiver('video',{direction:'recvonly'});
pc.addTransceiver('audio',{direction:'recvonly'});
pc.ontrack = e => {video.srcObject = e.streams[0]};
pc.oniceconnectionstatechange = ()=>{
const status = document.getElementById('status-'+camId);
if(status)status.className='cam-status '+(pc.iceConnectionState==='connected'?'online':'offline');
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(stream.whep_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:pc.localDescription.sdp});
if(!resp.ok) throw new Error('WHEP failed');
await pc.setRemoteDescription({type:'answer',sdp:await resp.text()});
}catch(e){console.error('WHEP for cam',camId,e)}
}
// === PHONE CAMERA INGEST (WHIP) ===
document.getElementById('phoneStreamBtn').addEventListener('click',togglePhoneStream);
async function togglePhoneStream(){
if(phoneStreaming){stopPhoneStream();return}
try{
const stream = await navigator.mediaDevices.getUserMedia({video:{width:1280,height:720},audio:false});
phoneStream = stream;
const localVid = document.getElementById('phoneLocalVideo');
localVid.srcObject = stream;
document.getElementById('phonePreview').style.display='block';
const streamInfo = await api('/streams/phone');
const pc = new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
phonePC = pc;
stream.getTracks().forEach(t=>pc.addTrack(t,stream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(streamInfo.whip_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:pc.localDescription.sdp});
if(!resp.ok) throw new Error('WHIP failed: '+resp.status);
await pc.setRemoteDescription({type:'answer',sdp:await resp.text()});
phoneStreaming = true;
const btn = document.getElementById('phoneStreamBtn');
btn.textContent = '⏹️ Stop Phone Camera';
btn.classList.add('streaming');
toast('📱 Phone camera streaming LIVE');
}catch(e){toast('Error: '+e.message);console.error(e)}
}
function stopPhoneStream(){
if(phonePC){phonePC.close();phonePC=null}
if(phoneStream){phoneStream.getTracks().forEach(t=>t.stop());phoneStream=null}
phoneStreaming=false;
const btn=document.getElementById('phoneStreamBtn');
btn.textContent='📱 Start Phone Camera';
btn.classList.remove('streaming');
document.getElementById('phonePreview').style.display='none';
toast('Phone camera stopped');
}
// === PTZ ===
async function ptz(camId,dir){
try{await api(`/cameras/${camId}/ptz`,{method:'POST',body:JSON.stringify({direction:dir})})}
catch(e){toast('PTZ: '+e.message)}
}
async function ptzZoom(camId,zin){
try{await api(`/cameras/${camId}/ptz/zoom`,{method:'POST',body:JSON.stringify({zoom_in:zin})})}
catch(e){toast('Zoom: '+e.message)}
}
async function snapshotCam(camId){
try{await api(`/cameras/${camId}/snapshot`,{method:'POST'});toast('Snapshot saved')}
catch(e){toast('Snapshot: '+e.message)}
}
async function deleteCam(camId){
if(!confirm('Delete camera?'))return;
try{await api(`/cameras/${camId}`,{method:'DELETE'});loadCameras();toast('Camera deleted')}
catch(e){toast(e.message)}
}
// === ADD CAMERA ===
document.getElementById('addCamBtn').addEventListener('click',async()=>{
const data={
name:document.getElementById('camName').value,
rtsp_url:document.getElementById('camRtsp').value,
onvif_host:document.getElementById('camOnvifHost').value||null,
onvif_port:parseInt(document.getElementById('camOnvifPort').value)||80,
onvif_user:document.getElementById('camOnvifUser').value||null,
onvif_pass:document.getElementById('camOnvifPass').value||null,
};
if(!data.name||!data.rtsp_url)return toast('Name and RTSP URL required');
try{await api('/cameras/',{method:'POST',body:JSON.stringify(data)});loadCameras();toast('Camera added!')}
catch(e){toast(e.message)}
});
// === SETTINGS ===
async function loadSettings(){
const camList=document.getElementById('camList');
try{
const cams=await api('/cameras/');
camList.innerHTML=cams.length?cams.map(c=>`<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.03)"><span>📹 ${esc(c.name)} <span style="color:var(--dim);font-size:.6rem">${esc(c.rtsp_url)}</span></span><button class="btn danger" onclick="deleteCam(${c.id})">Delete</button></div>`).join(''):'<p style="color:var(--dim)">No cameras</p>';
}catch(e){}
}
async function loadAlerts(){
try{
const alerts=await api('/alerts/?limit=50');
const tbody=document.getElementById('alertTable');
if(!alerts.length){tbody.innerHTML='<tr><td colspan="5" style="color:var(--dim);text-align:center">No alerts yet</td></tr>';return}
tbody.innerHTML=alerts.map(a=>`<tr><td>${fmtTime(a.created_at)}</td><td>${esc(a.camera_name||'?')}</td><td><span class="badge human">${a.type}</span></td><td>${a.snapshot_url?`<img class="snap-thumb" src="${a.snapshot_url}" onclick="showImage(this.src)">`:'—'}</td><td>${esc(a.channels_sent||'—')}</td></tr>`).join('');
}catch(e){}
}
async function loadPlates(){
try{
const plates=await api('/plates/?limit=50');
const tbody=document.getElementById('plateTable');
if(!plates.length){tbody.innerHTML='<tr><td colspan="6" style="color:var(--dim);text-align:center">No plates yet</td></tr>';return}
tbody.innerHTML=plates.map(p=>`<tr><td>${fmtTime(p.created_at)}</td><td>${esc(p.camera_name||'?')}</td><td><strong>${esc(p.plate)}</strong></td><td><span class="badge ${p.event_type}">${p.event_type}</span></td><td>${p.confidence?Math.round(p.confidence*100)+'%':'—'}</td><td>${p.snapshot_url?`<img class="snap-thumb" src="${p.snapshot_url}" onclick="showImage(this.src)">`:'—'}</td></tr>`).join('');
}catch(e){}
}
function showImage(src){
const ov=document.createElement('div');ov.className='modal-overlay';
ov.innerHTML=`<img src="${src}">`;
ov.addEventListener('click',()=>ov.remove());
document.body.appendChild(ov);
}
function fmtTime(iso){if(!iso)return'';const d=new Date(iso);return d.toLocaleString()}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
// === INIT ===
loadCameras();
setInterval(()=>{if(document.querySelector('#tab-live.active'))loadCameras()},15000);
</script>
</body>
</html>
+41
View File
@@ -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
View File
View File
+81
View File
@@ -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)
+47
View File
@@ -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
+23
View File
@@ -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
+42
View File
@@ -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
View File
+103
View File
@@ -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
View File
+54
View File
@@ -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
+207
View File
@@ -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
+51
View File
@@ -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
+37
View File
@@ -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",
)
+45
View File
@@ -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()
View File
+25
View File
@@ -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)
+65
View File
@@ -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)
+139
View File
@@ -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
View File
+79
View File
@@ -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
+92
View File
@@ -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}")
+31
View File
@@ -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
View File
+61
View File
@@ -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