Raspberry Pi

Raspberry Pi Camera Motion MQTT Alerts with Picamera2

Build a motion detector with a Raspberry Pi camera and Picamera2, save a snapshot when something moves, and publish motion events with snapshot links over MQTT so dashboards, phones and automations can react.

Raspberry Pi camera module watching a doorway and sending motion alerts with snapshot links in a raspberry pi camera motion mqtt setup
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

A Raspberry Pi camera motion MQTT detector turns a camera module into an event source: instead of streaming video all day, the Pi watches a low-resolution feed, saves a snapshot when something changes and publishes a small JSON event that anything can subscribe to. In this tutorial you will build that detector with Picamera2 and Python, tune it to avoid false alarms, and deliver the events through an MQTT broker.

How the Detector Works

Picamera2 can run two streams from the camera at the same time: a full-resolution main stream and a tiny lores stream. The detector compares consecutive greyscale lores frames, which costs very little CPU even on a Pi Zero 2 W, and only touches the main stream to save a JPEG when motion is found.

The flow is:

  1. Grab the luminance plane of the 320x240 lores frame.
  2. Compute the mean squared difference from the previous frame.
  3. If the difference exceeds a threshold, start a motion event, save a full-size snapshot and publish an MQTT message with its link.
  4. Ignore further triggers during a cooldown period.
  5. After a quiet period, publish a "motion ended" event and set the retained state back to idle.

Sending images through MQTT is possible, but a broker is a poor file server: large payloads are slow, and the public test broker caps messages at 64 KB. The robust pattern is to publish an event with a link and let the image live on a web server or object storage you control.

Hardware and Software

Item Notes
Raspberry Pi 4, 5 or Zero 2 W Raspberry Pi OS Bookworm
Camera Module 2, 3 or HQ camera Pi 5 and Zero need the matching narrow ribbon cable
Picamera2 Preinstalled on Raspberry Pi OS
paho-mqtt 2.x Installed in a virtual environment

Picamera2 and NumPy come from the OS packages, so create the virtual environment with access to them:

sudo apt update
sudo apt install -y python3-picamera2 python3-numpy python3-venv
rpicam-hello --list-cameras
mkdir -p ~/motion-mqtt/snapshots && cd ~/motion-mqtt
python3 -m venv --system-site-packages .venv
source .venv/bin/activate
pip install "paho-mqtt>=2.0"

If rpicam-hello --list-cameras shows no camera, power off, reseat the ribbon cable with the contacts facing the right way and try again. The Picamera2 manual documents the stream configuration used below.

Event Topics

Topic Payload Retained
connectmq/demo/e71b04/frontdoor/motion JSON event: start or end, score, snapshot URL No
connectmq/demo/e71b04/frontdoor/motion/state active or idle Yes
connectmq/demo/e71b04/frontdoor/status online or offline (last will) Yes

A typical start event looks like this:

{
  "event": "motion_start",
  "score": 18.4,
  "snapshot": "http://192.168.1.50:8000/20261016-142233.jpg",
  "ts": 1792160553
}

The Raspberry Pi Camera Motion MQTT Script

Save as motion_mqtt.py:

#!/usr/bin/env python3
"""Detect motion with Picamera2 and publish MQTT events with snapshot links."""
import json
import os
import socket
import time
import uuid
from datetime import datetime
from pathlib import Path

import numpy as np
import paho.mqtt.client as mqtt
from picamera2 import Picamera2

BROKER = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME")
PASSWORD = os.getenv("MQTT_PASSWORD")
CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"cam-{uuid.uuid4().hex[:8]}")
PREFIX = os.getenv("MQTT_PREFIX", "connectmq/demo/e71b04/frontdoor")

THRESHOLD = float(os.getenv("MOTION_THRESHOLD", "12"))
COOLDOWN = 10       # seconds between start events
QUIET_TIME = 15     # seconds without motion before "motion_end"
SNAP_DIR = Path(os.getenv("SNAP_DIR", str(Path.home() / "motion-mqtt" / "snapshots")))
HTTP_PORT = int(os.getenv("SNAP_HTTP_PORT", "8000"))
LORES = (320, 240)


def local_ip():
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.connect(("8.8.8.8", 80))
        return s.getsockname()[0]


BASE_URL = os.getenv("SNAP_BASE_URL", f"http://{local_ip()}:{HTTP_PORT}")


def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code.is_failure:
        print(f"Connection refused: {reason_code}")
        return
    print(f"Connected as {CLIENT_ID}")
    client.publish(f"{PREFIX}/status", "online", qos=1, retain=True)
    client.publish(f"{PREFIX}/motion/state", "idle", qos=1, retain=True)


client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
client.on_connect = on_connect
client.will_set(f"{PREFIX}/status", "offline", qos=1, retain=True)
client.reconnect_delay_set(min_delay=1, max_delay=60)
if USERNAME:
    client.username_pw_set(USERNAME, PASSWORD)
if PORT == 8883:
    client.tls_set()
client.connect(BROKER, PORT, keepalive=30)
client.loop_start()

picam2 = Picamera2()
config = picam2.create_video_configuration(
    main={"size": (1280, 720), "format": "RGB888"},
    lores={"size": LORES, "format": "YUV420"},
)
picam2.configure(config)
picam2.start()
time.sleep(2)  # let exposure and white balance settle

SNAP_DIR.mkdir(parents=True, exist_ok=True)
w, h = LORES
previous = None
active = False
last_start = 0.0
last_motion = 0.0


def publish_event(event, score, snapshot=None):
    payload = {"event": event, "score": round(score, 1), "ts": int(time.time())}
    if snapshot:
        payload["snapshot"] = snapshot
    client.publish(f"{PREFIX}/motion", json.dumps(payload), qos=1)
    print(payload)


try:
    while True:
        frame = picam2.capture_buffer("lores")
        current = frame[: w * h].reshape(h, w).astype(np.int16)
        if previous is not None:
            score = float(np.square(current - previous).mean())
            now = time.time()
            if score > THRESHOLD:
                last_motion = now
                if not active and now - last_start > COOLDOWN:
                    name = datetime.now().strftime("%Y%m%d-%H%M%S") + ".jpg"
                    picam2.capture_file(str(SNAP_DIR / name))
                    publish_event("motion_start", score, f"{BASE_URL}/{name}")
                    client.publish(f"{PREFIX}/motion/state", "active", qos=1, retain=True)
                    active = True
                    last_start = now
            elif active and now - last_motion > QUIET_TIME:
                publish_event("motion_end", score)
                client.publish(f"{PREFIX}/motion/state", "idle", qos=1, retain=True)
                active = False
        previous = current
        time.sleep(0.1)
except KeyboardInterrupt:
    pass
finally:
    picam2.stop()
    client.publish(f"{PREFIX}/status", "offline", qos=1, retain=True).wait_for_publish(2)
    client.loop_stop()
    client.disconnect()

The astype(np.int16) conversion matters: subtracting two unsigned 8-bit arrays wraps around and produces huge false differences. Taking only the first w * h bytes of the YUV420 buffer gives you the greyscale luminance plane, which is all motion detection needs.

Serving the Snapshots

For a quick local setup, serve the snapshot folder with Python's built-in web server in a second terminal:

python3 -m http.server 8000 --directory ~/motion-mqtt/snapshots

The links in each event now open on any device on your network. This server has no authentication, so keep it on your LAN only. For access from outside, upload snapshots from your own script to storage you control and set SNAP_BASE_URL to that location; the MQTT event format does not change. Also add a cron job or systemd timer to delete old images, or the SD card will fill up.

Testing and Tuning

Watch events from any machine:

mosquitto_sub -h broker.connectmq.com -p 1883 -i viewer-e71b04 \
  -t 'connectmq/demo/e71b04/frontdoor/#' -v

Wave a hand in front of the lens and you should see motion_start with a snapshot link, then motion_end after 15 quiet seconds. Tuning is mostly about the threshold:

Symptom Adjustment
Events from clouds, flicker or auto-exposure changes Raise MOTION_THRESHOLD
People walking past are missed Lower the threshold or the sleep interval
Constant start and end events Increase QUIET_TIME
Alerts at night are noisy Add an IR light or disable alerts by schedule
Trees or a road in frame cause triggers Crop the lores array to a region of interest before comparing

Print the score for a few minutes with nothing moving to see your scene's noise floor, then set the threshold comfortably above it.

Camera Detection vs a PIR Sensor

A passive infrared (PIR) sensor is the other common way to detect movement, and it is worth knowing when each fits. A PIR sensor reacts to body heat, uses almost no power and ignores changes in light, so it produces fewer false alarms from shadows and clouds. It cannot tell you what moved, though, and it has no picture to attach. Frame differencing with the camera reacts to any visual change, including a curtain in the wind, but it gives you a snapshot and lets you restrict detection to a precise region of the image.

The two combine well. Wire a PIR sensor to a spare GPIO pin, and only accept a camera trigger when the PIR line is also high within the same second or two. The camera then confirms and documents what the PIR detected, and the MQTT event can include both signals so subscribers can decide how much to trust it. Keep that confirmation logic on the Pi so the published event stays simple: one motion_start, one motion_end, one link.

Reacting to Motion Events

Because the detector only publishes events, the reaction lives elsewhere and can change without touching the camera code. A Node-RED flow can send a push notification; a small Python subscriber can email the snapshot, as shown in MQTT email alerts with Python; and a second Pi can switch on a floodlight using the Raspberry Pi relay MQTT controller by subscribing to the motion/state topic. Home Assistant can also consume the retained state as a binary sensor. If you want a lower-cost camera node for a second door, the ESP32-CAM over MQTT guide uses the same event idea on a microcontroller.

Privacy and Production Setup

Motion events reveal when people are at home, so they should never travel over a public broker in a real installation. The public MQTT test broker at broker.connectmq.com is readable by everyone and is meant only for the first test. For a real camera, use a private broker from ConnectMQ with TLS and generated credentials:

export MQTT_PORT=8883
export MQTT_USERNAME=YOUR_USERNAME
export MQTT_PASSWORD=YOUR_PASSWORD
export MQTT_CLIENT_ID=YOUR_CLIENT_ID
export MQTT_PREFIX=home/frontdoor
python motion_mqtt.py

With a managed MQTT broker, your phone, a home server and the camera all connect outbound to the same TLS endpoint, so you do not need port forwarding. Run the detector and the snapshot server as systemd services so they start after a power cut; the Raspberry Pi MQTT tutorial includes a ready service template.

Get Started with ConnectMQ

Give your camera a secure place to send its alerts. Create a free Serverless broker on ConnectMQ, no credit card required, and connect with TLS on port 8883. The portal's live topic explorer shows every motion event and retained state as it happens, which makes tuning thresholds much easier.

Frequently Asked Questions

Can I send the snapshot image itself over MQTT?

You can publish JPEG bytes as a payload, but it is inefficient and hits message size limits quickly. Publishing a link to the image and storing the file elsewhere scales much better.

How much CPU does Picamera2 motion detection use?

Very little, because the comparison runs on a 320x240 greyscale stream. Even a Raspberry Pi Zero 2 W can run it alongside the MQTT client.

Why do I get motion events when nothing is moving?

Auto-exposure changes, flickering lights and moving shadows change pixel values. Raise the threshold, crop to a region of interest, or lock exposure with Picamera2 controls.

Does this work with the Raspberry Pi Camera Module 3?

Yes. Picamera2 supports Camera Module 2, Camera Module 3 and the HQ camera; the code does not change.

Related articles

Pico W vs Pico 2 W for MQTT: RP2040 vs RP2350 Performance, Memory and TLS

The Pico 2 W keeps the Pico W's footprint and Wi-Fi chip but swaps the RP2040 for the faster RP2350. This comparison looks at what that means for MQTT devices: CPU, memory, TLS, security features, software support, and which board to buy for which project.

8 min read

Raspberry Pi RFID MQTT Access Logging with the RC522 Reader

Wire an RC522 RFID reader to a Raspberry Pi, read tags in Python, check them against a local allowlist and publish every access attempt as an MQTT event that loggers, dashboards and alerting tools can consume.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.