Skip to content

CCTV / NVR Integration

Use when: Device is an IP camera, DVR, or NVR that streams video over RTSP and reports camera events (motion, video loss, tamper, analytics) via HTTP long-poll, proprietary SDK callbacks, or ONVIF events.

Framework provides: Connection lifecycle, event routing, status reporting, command dispatch, time synchronization, go2rtc stream API.

You implement: HTTP/SDK protocol, RTSP URL construction, event translation, PTZ and output commands.


Device Hierarchy

NVR / Controller         ← connection root, IP + credentials
  ├── Channel × N        ← one per camera input, address = device channel ID
  └── Output × M         ← optional alarm relay outputs, address = port number

NVR / Controller is the connection root. Implements IDeviceConnection and optionally ITimeSynchronized. Typically one NVR per adapter instance.

Channel represents a physical camera input on the NVR. Its protocol address is the channel identifier used by the device API. Channels are discovered dynamically at connect time via an initialization sequence, not pre-configured by the operator.

Output represents a physical alarm relay output port. Optional — only present on devices that expose relay control. Its protocol address is the port number.


Architecture

┌─────────────────────────────────────────────────────────────────┐
│                         Your Code                                │
├─────────────────────────────────────────────────────────────────┤
│  Transport              ← HttpClient lifecycle, auth            │
│  Protocol               ← REST calls via Enqueue (serialized)   │
│  StreamManager          ← persistent HTTP long-poll event feed  │
│  AlertRouting           ← vendor events → PQ domain events      │
│  Commands/              ← live, playback, PTZ, snapshot, output │
│  StatusPolling          ← per-channel online/offline via REST   │
│  StreamRegistry         ← thread-safe set of active RTSP aliases│
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                  Platform (external sidecar)                     │
├─────────────────────────────────────────────────────────────────┤
│  go2rtc                 ← RTSP → WebRTC bridge                  │
│  PQ Server reconciliation ← periodic idle-stream cleanup        │
└─────────────────────────────────────────────────────────────────┘

The adapter does not stream video itself. It registers RTSP source URLs with go2rtc under named aliases. The browser connects to go2rtc directly via WebRTC. See concepts/video-streaming.md for the full streaming model.


Typical File Structure

Communication/
  Transport.cs                 ← TransportBase<TAddr, TEvent>, HttpClient ownership
  Protocol.cs                  ← ProtocolBase<TEvent>, REST ops via Enqueue
  <Name>StreamManager.cs       ← persistent HTTP long-poll, event parsing loop
  InitializationSequence.cs    ← post-connect: channel discovery + initial status

Devices/
  <Nvr>.cs                     ← IDeviceConnection, ITimeSynchronized
  <Channel>.cs                 ← IProtocolAddressable<TAddr>
  <Output>.cs                  ← IProtocolAddressable<TAddr> (if outputs present)

Events/
  <Name>Routing.cs             ← ConfigureRoutes: vendor events → PQ events

Commands/
  ChannelCommands.cs           ← live / playback / PTZ / snapshot
  OutputCommands.cs            ← activate / deactivate (if outputs present)

Streaming/
  StreamRegistry.cs            ← thread-safe alias tracker
  (no reconciliation service — idle-stream cleanup is server-owned, see concepts/video-streaming.md)

StatusPolling.cs               ← PollStatus: per-channel online/offline
AdapterRegistrations.cs        ← DI registrations

Event Flow

HTTP alertStream  →  StreamManager  →  Transport callback
  →  EventRouter  →  AlertRouting  →  resolve Channel by address
  →  SetStatus / PublishEvent / TeardownStream
pq.command.video.live  →  ChannelCommands
  →  Protocol.BuildRtspUrl
  →  pq.StreamApi.Register(alias, rtspUrl)
  →  StreamRegistry.Add(alias)
  →  return alias to client

Connection Lifecycle

  1. Connect → authenticate → verify device → Bind(address) → start alertStream
  2. InitializationSequence → enumerate channels (via NVR API) → poll initial per-channel status
  3. Framework SignalConnected
  4. AlertStream watchdog timeout (no data received) → NotifyDisconnectedStreamRegistry.Clear() → framework SignalDisconnected → auto-reconnect with exponential backoff

Command Surface

Category Commands
Live streaming pq.command.video.live
Playback pq.command.video.playback, .playback.seek, .playback.pause, .playback.resume
PTZ pq.command.camera.ptz.move, .ptz.stop, pq.command.camera.preset.activate, .preset.store
Snapshot pq.command.video.snapshot
Output relay pq.command.output.activate, .deactivate

Status

The key video-specific status slot is channel signal health:

Slot name Active state Cleared when
VideoSignal video.signal.lost Camera comes back online

Both the alertStream path and the status poll path must set/clear this slot. They are two independent code paths for the same transition.


Key Design Decisions

Client-driven stream reconnect. When a camera goes offline, the adapter tears down the go2rtc stream. It never re-registers the stream on its own. The client detects the stall and re-sends pq.command.video.live to restart the stream. This keeps the adapter stateless with respect to who is watching.

Streams created on demand. The adapter only registers a go2rtc stream when a client requests live or playback. There is no pre-registration on connect.

HTTP client timeout for event stream. The connection used for the persistent event stream must use Timeout.InfiniteTimeSpan. A finite timeout will silently kill the event feed.


Common Pitfalls

  • Taking the adapter own stream reconnection after disconnect — client owns the reconnect loop
  • Forgetting to tear down go2rtc streams on camera disconnect — leaks RTSP connections on the NVR
  • Missing Channel 0 suppression — NVRs often emit system-level events on a virtual channel that does not map to a real camera; these must be suppressed
  • Single HTTP client shared between event stream and REST calls — the infinite-timeout client must not be used for REST calls; use separate clients or a connection pool
  • Analytics events that never send an "inactive" state — they stay active indefinitely without a timeout-based clear mechanism

Good First Deliverable

Connect to the NVR, discover channels, publish one pq.event.video.motion.detected from the event stream.


See Also