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
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 |
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@sswordtop%40sswordin theRTSP_URLvariable. - 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 copyon 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'.
- 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.
- Check NVMe Mount Permissions: If FFmpeg starts but immediately exits without writing files, your
/mnt/nvr_storagedirectory is likely owned by root. Runsudo chown -R pi:pi /mnt/nvr_storage. - Force TCP Transport: UDP RTSP streams drop frames heavily on Wi-Fi or congested switches. Always ensure
-rtsp_transport tcpis 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.
- Keep the Pi 5 8GB and NVMe storage.
- Add a Coral USB Accelerator (~$35) to the Pi 5's USB 3.0 port for hardware TPU inference.
- 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.
- 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.






