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

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.