A Raspberry Pi 5 (8GB) paired with an NVMe SSD via the official M.2 HAT+ makes a highly capable Raspberry Pi network video recorder. It can handle up to four 4K H.265 streams without dropping frames, provided you bypass the SD card, use hardware-accelerated stream copying, and manage write-cycles correctly. While off-the-shelf NVR software like Frigate is excellent for object detection, building a custom Python-based recorder gives you bare-metal control over RTSP stream segmentation, local GPIO motion logging, and exact storage allocation.

This guide walks through the exact hardware BOM, storage math, GPIO wiring, and the complete Python script required to deploy a production-grade embedded NVR on Raspberry Pi OS (Bookworm).

Project Overview & Hardware BOM

Difficulty Rating: Intermediate (Requires basic Linux CLI, Python, and GPIO wiring)
Target Board: Raspberry Pi 5 (8GB variant) running Raspberry Pi OS Bookworm (64-bit)
Estimated Build Time: 90 minutes (excluding OS flashing)

Do not attempt to run continuous NVR workloads on a standard microSD card. The constant write-cycles from RTSP stream buffering will destroy a consumer SD card in weeks. We use the PCIe 2.0 interface on the Pi 5 to mount an NVMe drive.

Component Exact Variant / Model Approx. Cost (2026) Purpose
Compute Board Raspberry Pi 5 (8GB) $80.00 Handles RTSP parsing, Python logic, and PCIe routing
Storage Interface Official Raspberry Pi M.2 HAT+ $12.00 Adapts Pi 5 PCIe FPC to M.2 M-key (2230/2242)
Storage Drive WD Blue SN580 1TB NVMe (2242) $65.00 High-endurance, DRAM-less NVMe for continuous writes
Thermal Mgmt Raspberry Pi 5 Active Cooler $5.00 Prevents SoC throttling during multi-stream decoding
Power Supply Official 27W USB-C PD PSU $12.00 Provides 5A to prevent brownouts under NVMe + GPIO load
Motion Sensor HC-SR501 PIR Sensor $3.00 Hardware-level motion trigger for event logging

Storage Sizing & Bandwidth Matrix

Before writing a single line of code, you must size your NVMe drive based on your camera's codec and bitrate. H.265 (HEVC) cuts storage requirements roughly in half compared to H.264, which is critical when recording 24/7. The table below assumes continuous recording (no motion-only gaps) and uses standard IP camera bitrates.

Resolution Codec Typical Bitrate (Mbps) Storage / Hour Storage / Day (24/7) Days per 1TB Drive
1080p (2MP) H.264 4.0 Mbps 1.80 GB 43.2 GB 23 Days
1080p (2MP) H.265 2.0 Mbps 0.90 GB 21.6 GB 46 Days
4K (8MP) H.264 12.0 Mbps 5.40 GB 129.6 GB 7 Days
4K (8MP) H.265 6.0 Mbps 2.70 GB 64.8 GB 15 Days

Source: Bitrate estimates align with FFmpeg RTSP protocol documentation and standard Axis/Hikvision stream profiles. Always add a 15% overhead buffer for network jitter and I-frame spikes.

Wiring the GPIO Motion Trigger

While software-based motion detection (pixel diffing) consumes heavy CPU cycles, a hardware PIR sensor provides a zero-latency, low-power trigger. We wire the HC-SR501 directly to the Pi 5's 40-pin header. Ensure the PIR sensor's jumper is set to 'H' (non-repeatable trigger) or 'L' (repeatable) depending on your desired software debounce logic; 'L' is recommended for NVR event logging.

HC-SR501 Pin Pi 5 Physical Pin BCM GPIO Wire Color (Std)
VCC Pin 2 5V Power Red
OUT Pin 11 GPIO 17 Yellow
GND Pin 6 Ground Black
Callout Tip: The HC-SR501 requires 5V logic for VCC, but its OUT pin outputs 3.3V when triggered, making it natively safe for the Raspberry Pi 5's 3.3V GPIO tolerance. Do not use a logic level shifter here unless you are powering the PIR from a 3.3V rail (which reduces its effective range).

The Python NVR Recording Script

This script uses gpiozero (the standard for Pi OS Bookworm) to monitor the PIR sensor, and subprocess to manage an FFmpeg instance. FFmpeg handles the heavy lifting: pulling the RTSP stream over TCP and segmenting it into 5-minute MP4 chunks directly onto the NVMe drive. This avoids the frame-drop issues associated with OpenCV's VideoCapture buffer limits.

Prerequisites: Install FFmpeg and gpiozero via terminal:
sudo apt update && sudo apt install ffmpeg python3-gpiozero

import subprocess
import logging
import csv
import time
import os
from datetime import datetime
from gpiozero import MotionSensor
from signal import pause

# --- CONFIGURATION ---
RTSP_URL = "rtsp://admin:password123@192.168.1.50:554/Streaming/Channels/101"
OUTPUT_DIR = "/mnt/nvr_storage/segments"
EVENT_LOG = "/mnt/nvr_storage/events_log.csv"
SEGMENT_TIME = 300  # 5 minutes per file

# --- SETUP LOGGING ---
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[logging.FileHandler("/mnt/nvr_storage/nvr_system.log"), logging.StreamHandler()]
)
logger = logging.getLogger("NVR_Core")

# --- HARDWARE INIT ---
# Target: Raspberry Pi 5 (Bookworm). Pin 11 = BCM GPIO 17
pir = MotionSensor(17, queue_len=1, threshold=0.5)

def ensure_directories():
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    if not os.path.exists(EVENT_LOG):
        with open(EVENT_LOG, 'w', newline='') as f:
            writer = csv.writer(f)
            writer.writerow(["Timestamp", "Event_Type", "Sensor_State"])

def log_motion_event(state):
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with open(EVENT_LOG, 'a', newline='') as f:
        writer = csv.writer(f)
        writer.writerow([timestamp, "PIR_Motion", state])
    logger.info(f"Motion Event Logged: {state}")

def start_ffmpeg_recording():
    """Launches FFmpeg as a subprocess to record RTSP to segmented MP4s."""
    ensure_directories()
    
    # FFmpeg command array for safe subprocess execution
    cmd = [
        'ffmpeg', '-y',
        '-rtsp_transport', 'tcp',      # Force TCP to prevent UDP packet loss
        '-i', RTSP_URL,
        '-c', 'copy',                  # Stream copy (no CPU transcoding)
        '-f', 'segment',
        '-segment_time', str(SEGMENT_TIME),
        '-strftime', '1',
        '-reset_timestamps', '1',
        f'{OUTPUT_DIR}/cam1_%Y%m%d_%H%M%S.mp4'
    ]
    
    logger.info("Starting FFmpeg RTSP Recording Process...")
    try:
        # stderr=subprocess.PIPE allows us to catch FFmpeg auth/network errors
        process = subprocess.Popen(
            cmd, 
            stdout=subprocess.DEVNULL, 
            stderr=subprocess.PIPE,
            text=True
        )
        return process
    except Exception as e:
        logger.critical(f"Failed to launch FFmpeg: {e}")
        return None

if __name__ == "__main__":
    logger.info("Raspberry Pi NVR System Initializing...")
    
    # Bind PIR callbacks
    pir.when_motion = lambda: log_motion_event("DETECTED")
    pir.when_no_motion = lambda: log_motion_event("CLEARED")
    
    # Start continuous recording
    ffmpeg_proc = start_ffmpeg_recording()
    
    if ffmpeg_proc:
        try:
            logger.info("System Online. Monitoring streams and GPIO...")
            # Keep main thread alive while FFmpeg runs in background
            while ffmpeg_proc.poll() is None:
                time.sleep(5)
                # Optional: Add NVMe thermal throttling checks here
            
            # If FFmpeg exits unexpectedly, capture the error
            stderr_output = ffmpeg_proc.communicate()[1]
            logger.error(f"FFmpeg exited prematurely. Error: {stderr_output}")
            
        except KeyboardInterrupt:
            logger.info("Shutdown signal received. Terminating FFmpeg...")
            ffmpeg_proc.terminate()
            ffmpeg_proc.wait()
            logger.info("NVR System safely stopped.")

Debugging RTSP & FFmpeg Failures

When deploying IP cameras on local networks, FFmpeg will inevitably throw errors related to routing, authentication, or codec mismatches. Below are the exact error strings you will encounter, ranked by frequency, and how to resolve them.

1. "Server returned 401 Unauthorized"

Exact Error String: rtsp://192.168.1.50:554/stream1: Server returned 401 Unauthorized

  • Cause A (Most Likely): Your RTSP password contains special characters (like @, #, or ?) that break the URL parser.
  • Fix: URL-encode the password. Change p@ssword to p%40ssword in the RTSP_URL variable.
  • Cause B: The camera requires Digest authentication, but FFmpeg is defaulting to Basic.
  • Fix: Add '-rtsp_flags', 'prefer_tcp' and ensure your camera firmware is updated to accept standard RTSP auth.

2. "Connection refused"

Exact Error String: [tcp @ 0x55a1b2c3] Connection to tcp://192.168.1.50:554 failed: Connection refused

  • Cause A: The camera is on a different VLAN or subnet, and the Pi cannot route to it.
  • Cause B: The camera manufacturer uses a non-standard RTSP port (e.g., 8554 or 10554).
  • Fix: Ping the camera IP from the Pi terminal. If ping succeeds, check the camera's web UI for the exact RTSP port and update the URL (e.g., rtsp://user:pass@IP:8554/...).

3. "Could not find tag for codec none"

Exact Error String: [mp4 @ 0x7f8a1c] Could not find tag for codec none in stream #0, codec not currently supported in container

  • Cause: You are using -c copy on a stream that includes an unsupported audio track (like G.711a or G.726) which the MP4 container cannot mux.
  • Fix: Strip the audio track by adding '-an' to the FFmpeg command array, or transcode the audio by changing '-c', 'copy' to '-c:v', 'copy', '-c:a', 'aac'.
The First 3 Things to Check When It Fails:
  1. Verify the Stream in VLC: Before blaming the Pi, open VLC Media Player on your PC, go to Media > Open Network Stream, and paste your exact RTSP URL. If it fails in VLC, it's a camera/network issue, not a Pi issue.
  2. Check NVMe Mount Permissions: If FFmpeg starts but immediately exits without writing files, your /mnt/nvr_storage directory is likely owned by root. Run sudo chown -R pi:pi /mnt/nvr_storage.
  3. Force TCP Transport: UDP RTSP streams drop frames heavily on Wi-Fi or congested switches. Always ensure -rtsp_transport tcp is in your FFmpeg command.

Extending or Simplifying the Build

Not every deployment requires a PCIe NVMe drive and custom Python scripting. Here is how to scale this architecture based on your actual site requirements.

How to Simplify (The Budget / Single-Cam Build)

If you are only recording a single 1080p H.265 stream (approx. 22GB/day), you can eliminate the M.2 HAT+ and NVMe drive. Instead, use a SanDisk High Endurance 256GB microSD card (specifically designed for dashcams and NVRs). You can also drop the PIR sensor and rely purely on the camera's internal motion detection via ONVIF triggers, removing the GPIO wiring entirely. This drops the BOM cost from ~$177 to under $110.

How to Extend (The AI Object Detection Build)

If you need to know what is moving (e.g., distinguishing a human from a stray cat), Python pixel-diffing won't cut it.

  1. Keep the Pi 5 8GB and NVMe storage.
  2. Add a Coral USB Accelerator (~$35) to the Pi 5's USB 3.0 port for hardware TPU inference.
  3. Replace the custom Python script with Docker-hosted Frigate NVR. Frigate integrates directly with the Coral TPU and uses the Pi's hardware decoding to analyze RTSP streams for specific objects, logging events to an MQTT broker.
  4. Add a managed PoE switch to power your IP cameras directly from a single UPS backup.

By matching your storage matrix to the codec output of your cameras, and leveraging the Pi 5's PCIe lane for NVMe write endurance, this embedded NVR design will run for years without the SD card corruption or frame-buffer drops that plague older Pi 4 implementations.