feat: initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.*
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user