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.

AdapterTierNotes
Hikvision (ISAPI)ProductionFull feature set - image settings, smart detection, schedules, PTZ tours, thermal, deep NVR system endpoints
ONVIF (generic fallback shim)Fallback shimUsed 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 ProtectAvailableConnected 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.

PermissionGrants
cameras.viewCamera list, live streams, snapshots, event feed, stream-token minting
cameras.manageAdd / edit / remove cameras; adapter config writes; evidence hold (with site_admin)
cameras.ptzPTZ movement, presets, tours, auto-tracking, two-way audio
cameras.playbackView recorded footage, search recordings
cameras.exportEvidence batch hold (≤ 32 cameras) and evidence bundle download; requires site_admin role
cameras.nvrConfigure NVR/DVR devices, run import and channel sync
cameras.accessGrant 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.

METHODPathPurpose
POST/api/v1/cameras/discovery/scanProbe 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.

METHODPathPurpose
POST/api/v1/cameras/nvrs/test-connectionTest connectivity; return device info
POST/api/v1/cameras/nvrs/discoverList all channels on an NVR without importing
POST/api/v1/cameras/nvrs/importImport NVR + selected channels (idempotent re-sync)
POST/api/v1/cameras/nvrs/import-cameraImport a standalone IP camera (not NVR)
POST/api/v1/cameras/nvrs/{nvr_id}/syncRe-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.

TransportEndpointNotes
Snapshot pollingGET /{camera_id}/snapshotJPEG, Cache-Control: no-store; auth via Bearer, ?token=, or cookie
MJPEG proxyGET /{camera_id}/stream/mjpegNative ISAPI httpPreview; shared snapshot-cache fallback; adaptive FPS (see below)
fMP4 via go2rtcGET /{camera_id}/live/stream.mp4go2rtc fans out one RTSP connection to N viewers; plays HEVC in capable browsers
MSE WebSocket via go2rtcWS /{camera_id}/live/mseSub-second latency; 20 s frame-idle timeout; fail-closed on indeterminate authz
HLS (live)POST /{camera_id}/stream/hls/startFFmpeg RTSP → HLS; quality presets low / medium / high / source
HLS (recorded)POST /{camera_id}/playback/hls/startNVR-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
≤ 810
≤ 165
≤ 323
≤ 642
> 641

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.

PresetCodecBitrateResolution
lowlibx264500 kbps640 × 360
mediumlibx2641500 kbps1280 × 720
highlibx2643000 kbpsno scale
sourcecopy (H.264) or transcode (H.265)nativenative

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.

METHODPathPurpose
POST/{camera_id}/ptz?action=up&speed=50Move / zoom / preset - see query parameters below
GET/{camera_id}/ptz/presetsList 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 parameterRequiredValuesDefault
actionYesup | down | left | right | zoom_in | zoom_out | stop | preset-
speedNointeger 1 - 10050
presetNointeger 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.

METHODPathPurpose
GET/{camera_id}/ptz/toursList 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}/startStart a tour
POST/{camera_id}/ptz/tours/{tour_id}/stopStop 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.

METHODPathPurpose
GET/recordings/searchSearch DB-tracked recordings (camera, time range, type, site)
POST/nvrs/{nvr_id}/recordings/searchSearch NVR recording tracks live (channel, time range)
POST/recordings/search-cross-siteCross-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.

METHODPathPurpose
GET/{camera_id}/recording-scheduleGet per-channel schedule (returns supported: false if NVR-managed)
PUT/{camera_id}/recording-scheduleUpdate schedule
GET/PUT/{camera_id}/holiday-scheduleHoliday schedule
GET/PUT/nvrs/{nvr_id}/holidaysNVR-level holidays

Recording schedule templates let you define reusable schedules and apply them across cameras:

METHODPathPurpose
GET/recording-templatesList 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.

METHODPathPurpose
GET/PUT/{camera_id}/motion-detectionMotion detection configuration
GET/PUT/{camera_id}/privacy-masksPrivacy mask regions
GET/PUT/{camera_id}/line-crossingLine-crossing detection
GET/PUT/{camera_id}/intrusion-detectionField/intrusion detection
GET/PUT/{camera_id}/face-detectionFace detection
GET/{camera_id}/smart-capabilitiesProbe 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.

METHODPathPurpose
GET/{camera_id}/lpr/configGet config (key masked)
PUT/{camera_id}/lpr/configConfigure LPR provider
POST/{camera_id}/lpr/recognizeOne-shot LPR on a live snapshot
GET/lpr/readsSearch 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.

METHODPathPurpose
POST/evidenceCreate hold (async, poll for ready)
POST/evidence/batchBatch hold for ≤ 32 cameras
GET/evidenceList archives (filter by camera_id)
GET/evidence/{archive_id}Get archive metadata and status
GET/evidence/{archive_id}/downloadDownload the MP4 (only when ready)
GET/evidence/bundleStream 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.

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

METHODPathPurpose
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).

METHODPathPurpose
GET/events/List events (filter by camera, type, time, acknowledged)
GET/events/unacknowledged/countCount for UI badge (polled every 30 s)
POST/events/{event_id}/acknowledgeAcknowledge one event
POST/events/acknowledge/bulkBulk acknowledge

Event retention defaults to 90 days (CAMERA_EVENT_RETENTION_DAYS). A daily Celery task prunes records past this window.

METHODPathPurpose
GET/{camera_id}/healthLive bitrate, codec, FPS, resolution via adapter
GET/{camera_id}/health/historyHistorical snapshots (1 - 168 h, max 500 rows)
GET/health/fleet-summaryFleet totals, average bitrate, total bandwidth
GET/nvrs/health/time-driftNTP drift across all online NVRs (Hikvision)

Health snapshots are collected every minute by cameras.poll_camera_health and pruned after 72 hours.

METHODPathPurpose
GET/nvrsList 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}/channelsList cameras/channels on NVR
GET/nvrs/{nvr_id}/storageReal-time storage usage (Hikvision)
GET/nvrs/{nvr_id}/system-infoCPU, memory, NTP, network, storage tracks (Hikvision)
GET/nvrs/{nvr_id}/recording-statusPer-channel recording track status (Hikvision)
POST/nvrs/{nvr_id}/rebootReboot 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.

VariableDefaultPurpose
GO2RTC_URLhttp://go2rtc:1984go2rtc restreamer address (internal only)
VAPID_PUBLIC_KEY""WebPush ECDSA P-256 public key; blank disables push
VAPID_PRIVATE_KEY""WebPush private key
VAPID_SUBJECTmailto:WebPush contact email
CAMERA_EVENT_RETENTION_DAYS90Event pruning window
EVIDENCE_DIR/data/evidenceDurable 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.

LimitationDetail
No self-recordingFreeSDN has no recording engine; footage lives on the NVR
HEVC live playbackgo2rtc relays HEVC; only HEVC-capable browsers play it; 4K-HEVC live HLS cannot sustain real-time
Recorded HLS - Hikvision onlyNon-Hikvision adapters return 501; undecodable streams return 422
4K-HEVC watermarked export stallslibx264 re-encode of HEVC source is slow; use watermark: false for HEVC footage
Per-NVR connection cap6 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 providerplate_recognizer / openalpr / custom; unconfigured = unusable
AI scene labeling requires external APIOpenAI-compatible vision endpoint; no in-process inference
Hikvision-only featuresImage, smart-detection, schedules, PTZ tours, thermal, deep NVR endpoints return 400 on other vendors
Daily report typeOnly daily_summary reports are generated; no other report types exist
Two-way audio transportEndpoints open/close the adapter session only; live audio media path is not provided by this module