Skip to content

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.

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

The live-view UI tries transports in order: MSE WebSocket → progressive fMP4 → HLS → MJPEG → snapshot polling, degrading automatically on failure or codec incompatibility.

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:

  1. org_admin, admin, and super_admin bypass per-camera grants entirely.
  2. Explicit camera grant for the user (respecting expires_at TTL).
  3. Group grants (OR-merge boolean flags; highest level wins).
  4. Org-role defaults: viewer → live only; operator → live + playback + PTZ; site_admin → all including export + configure.
  5. 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.

  1. Go to Cameras → Add Camera.
  2. Fill in name, site, IP address, port (default 554), vendor, credentials, and RTSP main/sub stream URLs.
  3. Save. The password is Fernet-encrypted at rest. RTSP URLs and the IP address are SSRF-validated on write.

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.

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:

  1. Call test-connection with host, port, username, password, and vendor to verify reachability.
  2. Call discover to preview channels.
  3. Call import with selected_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).

FreeSDN supports multiple live transports. The UI selects the best one automatically.

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.

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-token
Authorization: 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.

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.

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.

The fMP4 and MSE transports require the go2rtc restreamer sidecar. Enable it with the cameras Compose profile:

Terminal window
docker compose --env-file .env.pro --profile cameras up -d

Set 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 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=50
Authorization: Bearer <jwt>

Every PTZ action emits a camera.ptz_{action}.{ok|failed} HIGH-priority event on the event bus.

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
PUT /api/v1/cameras/{camera_id}/ptz/auto-tracking
Content-Type: application/json
{
"enabled": true,
"track_duration_sec": 30,
"sensitivity": 70
}

Returns 400 if the camera has no PTZ capability.

FreeSDN does not store recordings. It indexes and retrieves footage from your NVR.

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.

  1. Search recordings to find the segment you want.
  2. Call GET /api/v1/cameras/recordings/{recording_id}/playback?protocol=hls (or mp4).
  3. 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.

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)

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 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/config
Content-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 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/analyze
Authorization: Bearer <jwt>

Labels are sanitised ([a-z0-9 _-], max 50 chars) and stored in settings["scene_labels"].

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.

POST /api/v1/cameras/evidence
Content-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.

POST /api/v1/cameras/evidence/batch

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

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.

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/evidence

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

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)

Beyond role-based defaults you can grant individual users specific permissions per camera or per group, with optional expiry.

POST /api/v1/cameras/access/grants
Content-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}.

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.

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.

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.

Enable WebPush to receive camera event alerts in the browser without polling.

  1. Set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT in your environment.
  2. The frontend fetches the public key from GET /api/v1/cameras/push/vapid-key and registers via POST /api/v1/cameras/push/subscribe.
  3. Dead or expired push subscriptions (404/410 from the push gateway) are pruned automatically.

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.

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.

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

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.