Cloud-dependent security cameras introduce recurring subscription fees, privacy vulnerabilities, and latency. Building a local-first raspberry pi security system gives you total ownership of your footage, sub-second trigger times, and zero monthly costs. This guide walks through building a motion-triggered capture node using the Raspberry Pi 5, the IMX708-based Camera Module 3, and a 3.3V-native PIR sensor.

We are targeting the Raspberry Pi 5 (4GB variant) running Raspberry Pi OS (64-bit, Bookworm or newer). The code relies on the modern picamera2 library and gpiozero, completely bypassing the legacy picamera stack which is deprecated on current hardware.

Hardware BOM and Power Budget

Power management is the most common failure point in Pi-based security nodes. The Pi 5 and Camera Module 3 have strict voltage ripple tolerances. Do not use generic phone chargers; the camera's image signal processor (ISP) will drop frames or fail to initialize if the 5V rail dips below 4.65V under load.

Component Exact Model / Part Number Nominal Voltage Active Current Draw Est. Price (2026)
Compute Board Raspberry Pi 5 (4GB RAM) 5.0V DC ~800mA (idle) to 2.5A (load) $60.00
Camera Module Raspberry Pi Camera Module 3 (IMX708) 3.3V / 1.8V (via CSI) ~250mA (during capture) $25.00
Motion Sensor AM312 Mini PIR (3.3V Native) 3.3V DC ~15µA (standby) $3.50
Alarm Relay 5V 1-Channel Optocoupler Relay Module 5.0V DC (Coil) ~70mA (energized) $4.00
Power Supply Official Raspberry Pi 27W USB-C PD 5.0V / 5.0A N/A (Source) $12.00
Callout Tip: Why the AM312 instead of the HC-SR501?
Most legacy tutorials recommend the HC-SR501 PIR sensor. However, the HC-SR501 requires a 5V VCC and its OUT pin outputs roughly 3.3V, but can spike higher depending on the specific voltage regulator on the board, risking damage to the Pi 5's 3.3V GPIO logic. The AM312 is natively 3.3V, draws microamps, and is safe for direct GPIO connection without a voltage divider.

Wiring the PIR Sensor and Camera Module

Physical wiring requires attention to the CSI (Camera Serial Interface) ribbon cable orientation and the GPIO pinout. The Pi 5 uses the same 15-pin CSI connector as the Pi 4, but the ribbon cable must be inserted with the blue tape (or silver contacts, depending on the cable variant) facing the outside edge of the Pi 5 board.

Signal Function Pi 5 GPIO (BCM / Physical Pin) Component Pin Recommended Wire Color
PIR Power 3V3 (Physical Pin 1) AM312 VCC Red
PIR Ground GND (Physical Pin 6) AM312 GND Black
PIR Trigger Out GPIO 17 (Physical Pin 11) AM312 OUT Yellow
Relay Control GPIO 27 (Physical Pin 13) Relay IN Blue
Relay Power 5V (Physical Pin 2) Relay VCC Orange
Relay Ground GND (Physical Pin 9) Relay GND Brown

For the camera, ensure the CSI ribbon cable clicks firmly into the ZIF (Zero Insertion Force) connector. The Pi 5 has two CSI/DSI ports; use CAM/DISP 1 (the port closest to the USB-C power connector) for single-camera setups to align with default device tree mappings.

Python Control Script (picamera2 + GPIO)

The following script initializes the camera, configures a low-resolution preview stream to save resources, and switches to a full-resolution capture configuration only when the PIR sensor triggers. It includes robust error handling for camera initialization and GPIO conflicts.

Ensure you have the required libraries installed via the terminal:
sudo apt update && sudo apt install python3-picamera2 python3-gpiozero python3-libcamera

import os
import time
import logging
from datetime import datetime
from picamera2 import Picamera2, MappedArray
from gpiozero import MotionSensor, OutputDevice
from signal import pause

# --- Pin Definitions & Config ---
PIR_PIN = 17
RELAY_PIN = 27
SAVE_DIR = '/home/pi/security_captures'
SIREN_DURATION = 3.0  # Seconds to keep relay energized
COOLDOWN = 5.0        # Seconds to ignore motion after a trigger

# Setup logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

# Ensure save directory exists
os.makedirs(SAVE_DIR, exist_ok=True)

def initialize_hardware():
    """Initialize Camera and GPIO with error handling."""
    try:
        pir = MotionSensor(PIR_PIN, queue_len=1, threshold=0.5)
        relay = OutputDevice(RELAY_PIN, active_high=True)
        logging.info('GPIO initialized successfully.')
    except Exception as e:
        logging.critical(f'GPIO Initialization Failed: {e}')
        raise

    try:
        cam = Picamera2()
        # Configure a lightweight preview config
        preview_config = cam.create_preview_configuration(main={'size': (640, 480)})
        cam.configure(preview_config)
        cam.start()
        logging.info('Camera started in preview mode.')
        return cam, pir, relay
    except RuntimeError as e:
        logging.critical(f'Camera Initialization Failed: {e}')
        raise

def capture_event(cam, relay):
    """Handle motion event: snap photo and trigger siren."""
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    filepath = os.path.join(SAVE_DIR, f'intrusion_{timestamp}.jpg')
    
    logging.info('Motion detected! Capturing image and triggering alarm.')
    
    # Switch to high-res capture config temporarily
    capture_config = cam.create_still_configuration(main={'size': (4608, 2592)})
    cam.switch_mode_and_capture_file(capture_config, filepath)
    
    # Trigger physical relay (siren/strobe)
    relay.on()
    time.sleep(SIREN_DURATION)
    relay.off()
    
    # Return to preview mode to save ISP resources
    preview_config = cam.create_preview_configuration(main={'size': (640, 480)})
    cam.configure(preview_config)
    cam.start()
    
    logging.info(f'Capture saved to {filepath}. System returning to standby.')

if __name__ == '__main__':
    try:
        camera, motion_sensor, alarm_relay = initialize_hardware()
        
        # Attach callback to PIR sensor
        motion_sensor.when_motion = lambda: capture_event(camera, alarm_relay)
        
        logging.info('Security system active. Waiting for motion...')
        pause()  # Keep script running efficiently
        
    except KeyboardInterrupt:
        logging.info('System shutdown requested by user.')
    except Exception as e:
        logging.error(f'Fatal system error: {e}')
    finally:
        # Cleanup GPIO and camera
        try:
            camera.stop()
            alarm_relay.off()
            logging.info('Hardware resources released.')
        except NameError:
            pass

Debugging: First Three Things to Check When It Fails

Embedded camera systems fail in highly specific ways. If your script crashes on startup, check these three exact failure modes before rewriting code.

1. The Camera is Not Detected by libcamera

Exact Error String: RuntimeError: No camera available or ERROR: *** no cameras available ***

Causes & Fixes:

  • CSI Ribbon Orientation: The most common bench mistake. The metal contacts on the ribbon cable must face the inner components of the Pi, not the outer edge of the board. Reseat the cable.
  • Missing libcamera stack: If you flashed a minimal 'Lite' OS version, the camera stack is omitted. Run sudo apt install python3-picamera2 to pull the dependencies.
  • I2C Bus Conflict: The camera uses the I2C bus for EEPROM reading. If you have another I2C device on the default bus (GPIO 2/3) pulling the lines low, the camera will fail to handshake. Disconnect other I2C sensors temporarily to isolate.

2. GPIO Pin is Already Claimed

Exact Error String: gpiozero.exc.GPIOPinInUse: pin 17 is already in use

Causes & Fixes:

  • Zombie Processes: You hit Ctrl+C on a previous run, but the Python process didn't terminate cleanly, leaving the GPIO lock engaged. Run sudo killall python3 or identify the PID via lsof | grep gpio and kill it.
  • PWM Audio Conflict: On some Pi OS configurations, the onboard audio PWM claims specific GPIO pins. Disable onboard audio in /boot/firmware/config.txt by adding dtparam=audio=off if you are using conflicting pins.

3. Storage Exhaustion from Rapid Triggering

Exact Error String: OSError: [Errno 28] No space left on device

Causes & Fixes:

  • Missing Cooldown / PIR Noise: The AM312 is highly sensitive. If placed near an HVAC vent, heat convection will cause continuous triggering, filling your SD card with 8MB JPEGs in hours. Increase the COOLDOWN variable in the script and implement a cron job to purge files older than 48 hours.
  • SD Card Wear-Leveling Failure: Continuous writing to a standard microSD card will corrupt the FAT32/ext4 partition table. For a production security node, boot the Pi from an external USB 3.0 SSD, or configure a RAM disk (tmpfs) for temporary captures and use rsync to offload to a NAS hourly.

Scaling the Build: Simplify or Extend

Depending on your deployment environment, you may need to adjust the complexity of this node.

How to Simplify (The 'Snapshot' Approach)

If you don't need a physical siren and want to eliminate the relay wiring entirely, strip the OutputDevice logic from the script. Instead of running the Python script as a persistent daemon, use the libcamera-still CLI tool triggered directly by a hardware interrupt via a lightweight bash script and cron. This reduces RAM usage to under 100MB, allowing the build to run reliably on a Raspberry Pi Zero 2 W instead of a Pi 5.

How to Extend (NVR and MQTT Integration)

For a multi-node property setup, standalone SD card storage doesn't scale. Extend the Python script to publish an MQTT message to a central broker (like Mosquitto on a Home Assistant server) the moment motion_sensor.when_motion fires. Alternatively, bypass the custom Python script entirely and install Frigate NVR. Frigate leverages the Pi 5's hardware video encoder (H.265) to perform continuous object detection (person/vehicle classification) using the Coral TPU, turning this hardware into a professional-grade, local AI security appliance.

For deeper integration with the Pi's camera stack, refer to the official Picamera2 manual and the GPIO Zero documentation for advanced callback routing.