Video Surveillance
The Video Surveillance module (cameras) connects IP cameras and NVRs to FreeSDN. You get live streaming in multiple transports, NVR-stored recording playback, PTZ control, smart-detection configuration, forensic export with legal hold, and license-plate recognition - all inside the same multi-tenant, role-gated platform.
All REST endpoints mount under /api/v1/cameras. The frontend lives at /cameras.
Supported vendors
Section titled “Supported vendors”| Adapter | Tier | Notes |
|---|---|---|
| Hikvision (ISAPI) | Production | Full feature set - image settings, smart detection, schedules, PTZ tours, thermal, deep NVR system endpoints |
| ONVIF (generic fallback shim) | Fallback shim | Used as the default protocol fallback for Dahua, Axis, Reolink, Amcrest, and other ONVIF-compliant devices when no vendor-specific adapter is registered; not an audited vendor adapter and not listed in any maturity tier |
| UniFi Protect | Available | Connected via the UniFi Protect adapter |
The live-view UI tries transports in order: MSE WebSocket → progressive fMP4 → HLS → MJPEG → snapshot polling, degrading automatically on failure or codec incompatibility.
Permissions
Section titled “Permissions”| Permission | Grants |
|---|---|
cameras.view | Camera list, live streams, snapshots, event feed, stream-token minting |
cameras.manage | Add / edit / remove cameras; adapter config writes; evidence hold (with site_admin) |
cameras.ptz | PTZ movement, presets, tours, auto-tracking, two-way audio |
cameras.playback | View recorded footage, search recordings |
cameras.export | Evidence batch hold (≤ 32 cameras) and evidence bundle download; requires site_admin role |
cameras.nvr | Configure NVR/DVR devices, run import and channel sync |
cameras.access | Grant and revoke per-camera user permissions |
Two enforcement layers apply on every request. First, the module permission above is checked. Second, a per-camera access grant check (_enforce_camera_access) resolves access in this order:
org_admin,admin, andsuper_adminbypass per-camera grants entirely.- Explicit camera grant for the user (respecting
expires_atTTL). - Group grants (OR-merge boolean flags; highest level wins).
- Org-role defaults:
viewer→ live only;operator→ live + playback + PTZ;site_admin→ all including export + configure. - Deny.
Several destructive endpoints additionally require the site_admin role on top of the permission: video export, NVR reboot, evidence hold creation, evidence download, and evidence deletion.
Add cameras and NVRs
Section titled “Add cameras and NVRs”Manual camera entry
Section titled “Manual camera entry”- Go to Cameras → Add Camera.
- Fill in name, site, IP address, port (default 554), vendor, credentials, and RTSP main/sub stream URLs.
- Save. The password is Fernet-encrypted at rest. RTSP URLs and the IP address are SSRF-validated on write.
ONVIF network discovery
Section titled “ONVIF network discovery”Use the discovery scanner to find cameras on your local network before importing them.
| METHOD | Path | Purpose |
|---|---|---|
POST | /api/v1/cameras/discovery/scan | Probe local network via ONVIF WS-Discovery multicast |
The scan is read-only - nothing is imported. Supply ?timeout= (0.5 - 15 s, default 4 s). Results pre-fill the Add Camera form; you choose what to import. Requires cameras.nvr.
NVR import (bulk channel import)
Section titled “NVR import (bulk channel import)”Import an NVR and optionally select which channels to register as cameras in one call.
| METHOD | Path | Purpose |
|---|---|---|
POST | /api/v1/cameras/nvrs/test-connection | Test connectivity; return device info |
POST | /api/v1/cameras/nvrs/discover | List all channels on an NVR without importing |
POST | /api/v1/cameras/nvrs/import | Import NVR + selected channels (idempotent re-sync) |
POST | /api/v1/cameras/nvrs/import-camera | Import a standalone IP camera (not NVR) |
POST | /api/v1/cameras/nvrs/{nvr_id}/sync | Re-scan NVR and sync channels |
Import procedure:
- Call
test-connectionwith host, port, username, password, and vendor to verify reachability. - Call
discoverto preview channels. - Call
importwithselected_channels(1 - 256) to create the NVR record and camera rows. Re-running import on an existing NVR re-syncs it (synced: true); it does not create duplicates.
The host is SSRF-validated. Site membership is verified against the organisation on every import (cross-org FK injection blocked).
Live streaming
Section titled “Live streaming”FreeSDN supports multiple live transports. The UI selects the best one automatically.
Transport overview
Section titled “Transport overview”| Transport | Endpoint | Notes |
|---|---|---|
| Snapshot polling | GET /{camera_id}/snapshot | JPEG, Cache-Control: no-store; auth via Bearer, ?token=, or cookie |
| MJPEG proxy | GET /{camera_id}/stream/mjpeg | Native ISAPI httpPreview; shared snapshot-cache fallback; adaptive FPS (see below) |
| fMP4 via go2rtc | GET /{camera_id}/live/stream.mp4 | go2rtc fans out one RTSP connection to N viewers; plays HEVC in capable browsers |
| MSE WebSocket via go2rtc | WS /{camera_id}/live/mse | Sub-second latency; 20 s frame-idle timeout; fail-closed on indeterminate authz |
| HLS (live) | POST /{camera_id}/stream/hls/start | FFmpeg RTSP → HLS; quality presets low / medium / high / source |
| HLS (recorded) | POST /{camera_id}/playback/hls/start | NVR-stored RTSP → HLS; Hikvision only (see caveat below) |
All transports require cameras.view plus the per-camera live (or playback) grant.
Stream tokens
Section titled “Stream tokens”Media URLs (<img src>, <video src>) cannot carry the session cookie. Use a short-lived stream token instead.
POST /api/v1/cameras/{camera_id}/stream-tokenAuthorization: Bearer <jwt>Returns { "token": "...", "expires_in": 60 }. The token is camera-scoped and org-scoped; it expires in 60 seconds. Minting a token requires the live grant, so a user without camera access cannot obtain one.
Pass as ?token=<value> on snapshot, MJPEG, fMP4, and MSE endpoints. Cookie auth outranks the query-string token - a leaked URL cannot downgrade an active session.
MJPEG adaptive FPS and NVR connection cap
Section titled “MJPEG adaptive FPS and NVR connection cap”The MJPEG proxy reduces frame rate automatically as concurrent stream count rises:
| Active streams (global total, all NVRs) | Target FPS |
|---|---|
| ≤ 8 | 10 |
| ≤ 16 | 5 |
| ≤ 32 | 3 |
| ≤ 64 | 2 |
| > 64 | 1 |
The stream count is tracked globally across the entire server (_StreamPool singleton). The per-NVR connection cap is a separate hard limit of 6 simultaneous streams per NVR (Hikvision safe limit); streams exceeding that cap receive 429 with X-Degrade-To: snapshot before this FPS tier applies. A shared snapshot-cache keeps one ISAPI fetch loop per channel regardless of how many viewers watch.
HLS quality presets (live)
Section titled “HLS quality presets (live)”| Preset | Codec | Bitrate | Resolution |
|---|---|---|---|
low | libx264 | 500 kbps | 640 × 360 |
medium | libx264 | 1500 kbps | 1280 × 720 |
high | libx264 | 3000 kbps | no scale |
source | copy (H.264) or transcode (H.265) | native | native |
Keep the heartbeat alive every 15 seconds (POST /api/v1/cameras/streams/hls/{session_id}/heartbeat). The HLS grant is re-evaluated on every playlist and segment fetch - a revoked grant stops serving within one fetch interval.
go2rtc sidecar
Section titled “go2rtc sidecar”The fMP4 and MSE transports require the go2rtc restreamer sidecar. Enable it with the cameras Compose profile:
docker compose --env-file .env.pro --profile cameras up -dSet GO2RTC_URL (default http://go2rtc:1984) if go2rtc runs on a non-default address. go2rtc is not exposed publicly - it is an internal service.
PTZ control
Section titled “PTZ control”PTZ endpoints require cameras.ptz plus the per-camera ptz grant.
| METHOD | Path | Purpose |
|---|---|---|
POST | /{camera_id}/ptz?action=up&speed=50 | Move / zoom / preset - see query parameters below |
GET | /{camera_id}/ptz/presets | List saved presets |
POST | /{camera_id}/ptz/presets?preset=<1-255>&name=<string> | Save a preset - both preset (1-255) and name (≤ 50 chars) are query-string parameters, not request-body fields. Example: POST /api/v1/cameras/{id}/ptz/presets?preset=3&name=entrance |
action and speed are query parameters, not request body fields. Sending a JSON body is ignored and omitting action returns HTTP 422.
| Query parameter | Required | Values | Default |
|---|---|---|---|
action | Yes | up | down | left | right | zoom_in | zoom_out | stop | preset | - |
speed | No | integer 1 - 100 | 50 |
preset | No | integer preset number | - |
POST /api/v1/cameras/{camera_id}/ptz?action=up&speed=50Authorization: Bearer <jwt>Every PTZ action emits a camera.ptz_{action}.{ok|failed} HIGH-priority event on the event bus.
PTZ tours (Hikvision only)
Section titled “PTZ tours (Hikvision only)”Tours (patrols) automate pan sequences across saved presets. All tour endpoints return 400 for non-Hikvision cameras.
| METHOD | Path | Purpose |
|---|---|---|
GET | /{camera_id}/ptz/tours | List tours |
GET | /{camera_id}/ptz/tours/{tour_id} | Get a single tour (ID 1 - 255) |
PUT | /{camera_id}/ptz/tours/{tour_id} | Create or update a tour (ID 1 - 255) |
DELETE | /{camera_id}/ptz/tours/{tour_id} | Delete a tour |
POST | /{camera_id}/ptz/tours/{tour_id}/start | Start a tour |
POST | /{camera_id}/ptz/tours/{tour_id}/stop | Stop a running tour |
PTZ auto-tracking (Hikvision only)
Section titled “PTZ auto-tracking (Hikvision only)”PUT /api/v1/cameras/{camera_id}/ptz/auto-trackingContent-Type: application/json
{ "enabled": true, "track_duration_sec": 30, "sensitivity": 70}Returns 400 if the camera has no PTZ capability.
Recording and playback
Section titled “Recording and playback”FreeSDN does not store recordings. It indexes and retrieves footage from your NVR.
Search recordings
Section titled “Search recordings”| METHOD | Path | Purpose |
|---|---|---|
GET | /recordings/search | Search DB-tracked recordings (camera, time range, type, site) |
POST | /nvrs/{nvr_id}/recordings/search | Search NVR recording tracks live (channel, time range) |
POST | /recordings/search-cross-site | Cross-site recording search across all NVRs in org |
recording_type values: continuous, motion, manual, alarm, event.
Recording playback
Section titled “Recording playback”- Search recordings to find the segment you want.
- Call
GET /api/v1/cameras/recordings/{recording_id}/playback?protocol=hls(ormp4). - For a specific instant, call
GET /{camera_id}/playback-frame?time=<ISO8601>to fetch a single JPEG frame.
Use the /cameras/playback UI page for synchronised multi-camera playback with a shared scrubber.
Recording schedules (Hikvision only)
Section titled “Recording schedules (Hikvision only)”| METHOD | Path | Purpose |
|---|---|---|
GET | /{camera_id}/recording-schedule | Get per-channel schedule (returns supported: false if NVR-managed) |
PUT | /{camera_id}/recording-schedule | Update schedule |
GET/PUT | /{camera_id}/holiday-schedule | Holiday schedule |
GET/PUT | /nvrs/{nvr_id}/holidays | NVR-level holidays |
Recording schedule templates let you define reusable schedules and apply them across cameras:
| METHOD | Path | Purpose |
|---|---|---|
GET | /recording-templates | List org and built-in templates |
POST | /recording-templates/ | Create a template |
PATCH | /recording-templates/{template_id} | Update (non-built-in only) |
DELETE | /recording-templates/{template_id} | Delete (non-built-in only) |
Smart detection (Hikvision only)
Section titled “Smart detection (Hikvision only)”All endpoints below return HTTP 400 for non-Hikvision cameras.
| METHOD | Path | Purpose |
|---|---|---|
GET/PUT | /{camera_id}/motion-detection | Motion detection configuration |
GET/PUT | /{camera_id}/privacy-masks | Privacy mask regions |
GET/PUT | /{camera_id}/line-crossing | Line-crossing detection |
GET/PUT | /{camera_id}/intrusion-detection | Field/intrusion detection |
GET/PUT | /{camera_id}/face-detection | Face detection |
GET | /{camera_id}/smart-capabilities | Probe which smart features the camera supports |
The Camera Detail → Detection tab surfaces all of these. It is hidden for non-Hikvision cameras.
LPR - license plate recognition
Section titled “LPR - license plate recognition”LPR is an integration, not built-in inference. FreeSDN grabs a snapshot and forwards it to a third-party provider. If no provider is configured, the feature is unusable.
Supported providers: plate_recognizer, openalpr, custom (any HTTP endpoint that accepts a binary JPEG POST and returns {"plate": "...", "confidence": 0.0, "vehicle_type": "..."} JSON).
PUT /api/v1/cameras/{camera_id}/lpr/configContent-Type: application/json
{ "enabled": true, "provider": "plate_recognizer", "api_url": "https://api.platerecognizer.com/v1/plate-reader/", "api_key": "...", "regions": ["us-ca"], "confidence_threshold": 0.8}api_url is SSRF-validated via safe_http_request (DNS-rebind pinned, private IP ranges blocked). The stored api_key is Fernet-encrypted; reading the config back returns has_api_key: true rather than the key value.
| METHOD | Path | Purpose |
|---|---|---|
GET | /{camera_id}/lpr/config | Get config (key masked) |
PUT | /{camera_id}/lpr/config | Configure LPR provider |
POST | /{camera_id}/lpr/recognize | One-shot LPR on a live snapshot |
GET | /lpr/reads | Search plate reads across cameras |
Plate reads are persisted as license_plate camera events and appear in the event feed.
AI scene labeling
Section titled “AI scene labeling”AI scene labeling is also an external integration. FreeSDN sends a base64 snapshot to an OpenAI-compatible vision endpoint and stores the returned tags.
Configure the endpoint either per-camera (settings.ai_vision_url) or globally via the AI_VISION_URL environment variable. The default model is gpt-4o-mini. Per-camera user-supplied URLs cannot resolve private IP ranges (SSRF-guarded). The admin-level AI_VISION_URL environment variable may be on the LAN.
A cameras.label_camera_scenes Celery task runs daily at 05:30 to sweep all cameras. You can also trigger analysis on demand:
POST /api/v1/cameras/{camera_id}/scene/analyzeAuthorization: Bearer <jwt>Labels are sanitised ([a-z0-9 _-], max 50 chars) and stored in settings["scene_labels"].
Forensic export and legal hold
Section titled “Forensic export and legal hold”FreeSDN does not record. Evidence hold is the only way to durably preserve footage - it copies a time window off the NVR to EVIDENCE_DIR (default /data/evidence) with a SHA-256 hash for chain of custody.
Hold window limit: 4 hours per archive.
Create a hold
Section titled “Create a hold”POST /api/v1/cameras/evidenceContent-Type: application/json
{ "camera_id": "...", "start_time": "2026-06-05T14:00:00Z", "end_time": "2026-06-05T14:30:00Z", "watermark": true, "note": "Incident at loading dock - case 2026-123"}The request dispatches a Celery task (cameras.archive_evidence) and returns immediately with status: pending. Poll GET /api/v1/cameras/evidence/{archive_id} until status is ready or failed.
watermark: true burns a chain-of-custody overlay (camera name, export timestamp, operator name, export ID) into the clip using FFmpeg. Non-watermarked export streams the original bytes directly.
Batch hold
Section titled “Batch hold”POST /api/v1/cameras/evidence/batchSame body as single hold, but camera_ids (array, max 32 cameras). The export grant is checked on every camera - the entire batch is rejected on the first denial.
Evidence endpoints
Section titled “Evidence endpoints”| METHOD | Path | Purpose |
|---|---|---|
POST | /evidence | Create hold (async, poll for ready) |
POST | /evidence/batch | Batch hold for ≤ 32 cameras |
GET | /evidence | List archives (filter by camera_id) |
GET | /evidence/{archive_id} | Get archive metadata and status |
GET | /evidence/{archive_id}/download | Download the MP4 (only when ready) |
GET | /evidence/bundle | Stream ZIP of ≤ 64 ready archives + SHA-256 MANIFEST.txt |
DELETE | /evidence/{archive_id} | Delete archive and file (blocked while archiving) |
Every download response carries X-Evidence-Sha256, X-Evidence-Id, and related chain-of-custody headers.
Configure evidence storage
Section titled “Configure evidence storage”Mount a persistent volume at EVIDENCE_DIR (default /data/evidence). In production this should be on durable, off-root storage.
# docker-compose.override.yml (example)services: api: volumes: - /mnt/nas/evidence:/data/evidence worker: volumes: - /mnt/nas/evidence:/data/evidenceBoth the api and worker services need access to the same path because the hold is created via the API but the export runs in a Celery worker.
Camera groups and views
Section titled “Camera groups and views”Groups let you organise cameras for display and for access grants.
| METHOD | Path | Purpose |
|---|---|---|
GET | /groups/ | List groups with member counts |
POST | /groups/ | Create group (name, color, icon, optional initial member list) |
PATCH | /groups/{group_id} | Update; camera_ids replaces all members |
DELETE | /groups/{group_id} | Soft-delete |
Views are saved multi-camera layouts for the wall display.
| METHOD | Path | Purpose |
|---|---|---|
GET | /views/ | List accessible views (owned + shared) |
POST | /views/ | Create view (name, layout, camera_ids, is_shared) |
PATCH | /views/{view_id} | Update (owner only) |
DELETE | /views/{view_id} | Delete (owner only) |
Per-camera access control
Section titled “Per-camera access control”Beyond role-based defaults you can grant individual users specific permissions per camera or per group, with optional expiry.
POST /api/v1/cameras/access/grantsContent-Type: application/json
{ "user_id": "...", "camera_id": "...", "access_level": "operator", "can_live": true, "can_playback": true, "can_ptz": true, "can_export": false, "can_configure": false, "expires_at": "2026-12-31T23:59:59Z"}Set group_id instead of camera_id to grant access to all cameras in a group. Access levels: viewer, operator, full.
Use GET /api/v1/cameras/access/check/{camera_id} to see your own effective permissions. Admins can check any user with GET /api/v1/cameras/access/check/{camera_id}/user/{user_id}.
Camera events
Section titled “Camera events”The event feed shows motion, line-crossing, intrusion, face detection, tamper, video loss, audio detection, and LPR plate reads. Events are ingested by the cameras.ingest_nvr_alerts Celery beat task (every 30 s).
| METHOD | Path | Purpose |
|---|---|---|
GET | /events/ | List events (filter by camera, type, time, acknowledged) |
GET | /events/unacknowledged/count | Count for UI badge (polled every 30 s) |
POST | /events/{event_id}/acknowledge | Acknowledge one event |
POST | /events/acknowledge/bulk | Bulk acknowledge |
Event retention defaults to 90 days (CAMERA_EVENT_RETENTION_DAYS). A daily Celery task prunes records past this window.
Camera health monitoring
Section titled “Camera health monitoring”| METHOD | Path | Purpose |
|---|---|---|
GET | /{camera_id}/health | Live bitrate, codec, FPS, resolution via adapter |
GET | /{camera_id}/health/history | Historical snapshots (1 - 168 h, max 500 rows) |
GET | /health/fleet-summary | Fleet totals, average bitrate, total bandwidth |
GET | /nvrs/health/time-drift | NTP drift across all online NVRs (Hikvision) |
Health snapshots are collected every minute by cameras.poll_camera_health and pruned after 72 hours.
NVR management
Section titled “NVR management”| METHOD | Path | Purpose |
|---|---|---|
GET | /nvrs | List NVRs |
POST | /nvrs/ | Add NVR manually |
PATCH | /nvrs/{nvr_id} | Update NVR (credentials re-encrypted on change) |
DELETE | /nvrs/{nvr_id} | Remove NVR |
GET | /nvrs/{nvr_id}/channels | List cameras/channels on NVR |
GET | /nvrs/{nvr_id}/storage | Real-time storage usage (Hikvision) |
GET | /nvrs/{nvr_id}/system-info | CPU, memory, NTP, network, storage tracks (Hikvision) |
GET | /nvrs/{nvr_id}/recording-status | Per-channel recording track status (Hikvision) |
POST | /nvrs/{nvr_id}/reboot | Reboot NVR - requires site_admin (Hikvision) |
NVR reboot emits a CRITICAL nvr.reboot.{ok|failed} event and is always audit-logged.
Browser push notifications
Section titled “Browser push notifications”Enable WebPush to receive camera event alerts in the browser without polling.
- Set
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY, andVAPID_SUBJECTin your environment. - The frontend fetches the public key from
GET /api/v1/cameras/push/vapid-keyand registers viaPOST /api/v1/cameras/push/subscribe. - Dead or expired push subscriptions (404/410 from the push gateway) are pruned automatically.
Backup and restore
Section titled “Backup and restore”The cameras module participates in the FreeSDN portable-config backup. A backup captures:
- NVRs and their channel lists
- Camera groups and views
- Recording schedule templates
Not included: footage, camera events, health telemetry, or credentials (passwords are never serialised to backup).
See Configuration Backup for procedures.
Environment variables
Section titled “Environment variables”| Variable | Default | Purpose |
|---|---|---|
GO2RTC_URL | http://go2rtc:1984 | go2rtc restreamer address (internal only) |
VAPID_PUBLIC_KEY | "" | WebPush ECDSA P-256 public key; blank disables push |
VAPID_PRIVATE_KEY | "" | WebPush private key |
VAPID_SUBJECT | mailto: | WebPush contact email |
CAMERA_EVENT_RETENTION_DAYS | 90 | Event pruning window |
EVIDENCE_DIR | /data/evidence | Durable evidence hold directory |
AI_VISION_URL | - | Fallback OpenAI-compatible vision endpoint for scene labeling |
HLS working files are written to /tmp/freesdn-hls (hardcoded). This does not need persistent storage - sessions are ephemeral.
Limitations summary
Section titled “Limitations summary”| Limitation | Detail |
|---|---|
| No self-recording | FreeSDN has no recording engine; footage lives on the NVR |
| HEVC live playback | go2rtc relays HEVC; only HEVC-capable browsers play it; 4K-HEVC live HLS cannot sustain real-time |
| Recorded HLS - Hikvision only | Non-Hikvision adapters return 501; undecodable streams return 422 |
| 4K-HEVC watermarked export stalls | libx264 re-encode of HEVC source is slow; use watermark: false for HEVC footage |
| Per-NVR connection cap | 6 concurrent MJPEG/MSE streams per NVR; fMP4 is not subject to this cap (it routes through go2rtc). Excess MJPEG requests get HTTP 429 with X-Degrade-To: snapshot; excess MSE WebSocket connections receive WS close code 1013 |
| LPR requires external provider | plate_recognizer / openalpr / custom; unconfigured = unusable |
| AI scene labeling requires external API | OpenAI-compatible vision endpoint; no in-process inference |
| Hikvision-only features | Image, smart-detection, schedules, PTZ tours, thermal, deep NVR endpoints return 400 on other vendors |
| Daily report type | Only daily_summary reports are generated; no other report types exist |
| Two-way audio transport | Endpoints open/close the adapter session only; live audio media path is not provided by this module |
Next steps
Section titled “Next steps”- Configuration Backup - understand portable-config backup scope
- Firewall and gateway - VLAN segmentation for camera traffic
- Observability - passive SNMP and syslog from NVR devices
- AI Assistant - use the AI assistant to query camera event data
- Access Control - physical door access alongside camera coverage