โ† All projects Real robots ยท Intermediate

A camera that texts you when a package arrives

A small camera by your door that watches for a delivery, sends you a photo and a description of what turned up, and doesn't wake you for the neighbour's cat.

You'll have at the end: a Pi Zero running from a shelf near your door, checking a camera every few seconds, and messaging you a photo with a one-line description when something is actually delivered.

Why this one

This is the first project where the model's judgment replaces code you could never reasonably write yourself. Detecting motion is a dozen lines of arithmetic. Deciding whether the thing that moved is a parcel, a cat, a shadow at 4pm, or the postman walking past without stopping is not something you can express in rules โ€” and a decade ago this project would have meant training a custom classifier on thousands of labelled photos.

Now it's a prompt. That shift is the whole point of the exercise.

Parts

PartRoughlyNotes
Raspberry Pi Zero 2 W$15โ€“25A Pi 4 or 5 works and is easier to set up, just larger and hungrier. Don't use an original Pi Zero โ€” too slow.
Pi Camera Module 3$25โ€“35Module 2 is fine and cheaper. The NoIR version sees in the dark if you add an IR illuminator.
Camera ribbon cable for Zero$3โ€“6Easy to miss: the Zero uses a narrower connector than full-size Pis, so the cable in the camera box won't fit.
microSD card, 16GB+$6โ€“10Any reputable brand. Cheap cards fail, and they fail silently.
USB power supply$8โ€“12Needs to be a real 5V/2.5A supply, not a spare phone charger from a drawer.

Prices vary a lot by region and retailer โ€” treat these as rough bands. Total is typically $45โ€“70 if you're starting from nothing.

Step 1 โ€” Set up the Pi

Flash Raspberry Pi OS Lite (64-bit) with the Raspberry Pi Imager. Before writing, click the gear icon and set the hostname, your Wi-Fi details, and enable SSH โ€” this saves you having to attach a keyboard and monitor at all.

Boot it, then from your own machine:

ssh pi@raspberrypi.local
sudo apt update && sudo apt install -y python3-picamera2 python3-pip
pip install anthropic requests --break-system-packages

โš  Connect the camera ribbon with the Pi powered off. The connector is fragile: lift the plastic tab, slide the cable in with the contacts facing the board, press the tab back. Hot-plugging it can kill the camera.

Check it works:

libcamera-still -o test.jpg

If that writes a file, you're ready. If it complains about no cameras available, the ribbon is in backwards or not seated.

Step 2 โ€” How the detection works

Sending every frame to a vision model would cost a fortune and be pointlessly slow. So the flow is a cheap gate in front of an expensive judgment:

1. Grab a low-resolution frame every three seconds. 2. Compare it numerically to the previous one. If little has changed, throw it away โ€” this is nearly free and handles 99% of frames. 3. If enough has changed, wait a few seconds for whatever it is to settle, take a full-resolution photo, and send that to the model. 4. Only message you if the model says it's a delivery.

This "cheap filter, expensive judgment" structure is worth internalising. It's how you keep an AI project affordable, and it applies far beyond cameras.

Step 3 โ€” The code

Save as doorcam.py on the Pi.

import os, time, base64, io, requests
import numpy as np
from picamera2 import Picamera2
import anthropic

TELEGRAM_TOKEN = os.environ["TELEGRAM_TOKEN"]
TELEGRAM_CHAT  = os.environ["TELEGRAM_CHAT"]
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

CHECK_EVERY     = 3      # seconds between motion checks
MOTION_THRESHOLD = 12    # mean pixel difference that counts as motion
SETTLE_SECONDS  = 4      # let the scene settle before the real photo
COOLDOWN        = 120    # don't report again for this long

QUESTION = """Look at this photo of the area by a front door.

Is there a delivered package, parcel, box or envelope left on the ground or step?

Reply with ONE line in exactly this format:
YES | short description of what was delivered
or
NO | what you see instead

A person merely standing there is NO. A pet or animal is NO. A parcel being
carried is NO โ€” it must have been put down. Shadows and lighting changes are NO."""


def analyse(jpeg_bytes):
    reply = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=150,
        messages=[{
            "role": "user",
            "content": [
                {"type": "image", "source": {
                    "type": "base64", "media_type": "image/jpeg",
                    "data": base64.standard_b64encode(jpeg_bytes).decode()}},
                {"type": "text", "text": QUESTION},
            ],
        }],
    )
    return reply.content[0].text.strip()


def notify(jpeg_bytes, caption):
    requests.post(
        f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendPhoto",
        data={"chat_id": TELEGRAM_CHAT, "caption": caption},
        files={"photo": ("door.jpg", jpeg_bytes, "image/jpeg")},
        timeout=30,
    ).raise_for_status()


def main():
    cam = Picamera2()
    # Two streams: tiny one for motion maths, big one for the model.
    cam.configure(cam.create_still_configuration(
        main={"size": (1280, 960)},
        lores={"size": (320, 240), "format": "YUV420"},
    ))
    cam.start()
    time.sleep(2)  # let exposure settle

    previous = None
    last_alert = 0
    print("Watching the door...")

    while True:
        frame = cam.capture_array("lores")[:240, :320].astype(np.int16)

        if previous is not None:
            difference = np.abs(frame - previous).mean()

            if difference > MOTION_THRESHOLD and time.time() - last_alert > COOLDOWN:
                print(f"Motion ({difference:.1f}) โ€” looking properly")
                time.sleep(SETTLE_SECONDS)

                buffer = io.BytesIO()
                cam.capture_file(buffer, format="jpeg")
                photo = buffer.getvalue()

                try:
                    verdict = analyse(photo)
                except Exception as error:
                    print("Model call failed:", error)
                    previous = frame
                    continue

                print("  โ†’", verdict)
                if verdict.upper().startswith("YES"):
                    description = verdict.split("|", 1)[-1].strip()
                    notify(photo, f"๐Ÿ“ฆ Delivery: {description}")
                    last_alert = time.time()

                # Re-baseline so the parcel itself isn't permanent "motion".
                previous = cam.capture_array("lores")[:240, :320].astype(np.int16)
                time.sleep(CHECK_EVERY)
                continue

        previous = frame
        time.sleep(CHECK_EVERY)


if __name__ == "__main__":
    main()

Step 4 โ€” Run it, then make it permanent

Test in the foreground first, with your three secrets exported as before. Walk in front of the camera, put a box down, and watch the console โ€” you'll see the difference values, which tells you whether MOTION_THRESHOLD suits your doorway.

Once it behaves, make it a service so it survives reboots. Create /etc/systemd/system/doorcam.service:

[Unit]
Description=Door camera
After=network-online.target

[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi
Environment="TELEGRAM_TOKEN=..." "TELEGRAM_CHAT=..." "ANTHROPIC_API_KEY=..."
ExecStart=/usr/bin/python3 /home/pi/doorcam.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now doorcam
journalctl -u doorcam -f    # watch the logs

Tuning it for your door

The single number that matters is MOTION_THRESHOLD. Run in the foreground for an hour and watch the printed difference values. A doorway with trees and moving shadows might idle around 8; an indoor hallway around 1. Set the threshold comfortably above the idle noise, not just barely.

COOLDOWN stops it messaging you five times about the same parcel. Two minutes is usually right; raise it if the postman lingers.

When it goes wrong

Constant false triggers in daylight. Moving shadows. Raise the threshold, and if that isn't enough, crop the analysis to just the step โ€” slice the array to the region you care about before comparing.

It misses deliveries. Usually the settle time is too short and the photo catches the courier mid-turn, so the parcel is obscured. Try six or seven seconds.

Nothing at all at night. A standard camera module sees nothing in the dark. You need the NoIR version plus an infrared illuminator; the model reads greyscale IR images fine.

Model says YES to your doormat. Add a line to the question naming what's permanently there: "A doormat, plant pot and boot scraper are always present and are not deliveries."

Wi-Fi drops and it dies. The systemd Restart=always covers it, but wrap notify() in a try/except too so a failed send doesn't take down the loop.

What this costs to run

The motion check is local and free. You only pay when something moves โ€” typically a handful of vision calls a day, each a fraction of a cent. Realistically under a dollar a month, and electricity for a Pi Zero is a couple of dollars a year. If your threshold is badly tuned and it fires constantly, that's when it gets expensive, which is the real reason to tune it.

Make it yours

Watch for something else entirely. The only thing specific to parcels is the QUESTION. Point it at a driveway and ask about cars, a bird feeder and ask which species, a garage and ask whether the door was left open. Same code.

Keep a log. Append every verdict and photo path to a SQLite file and you have a searchable history: "what got delivered last Tuesday".

Two-stage cheaper. Run a small local object detector first and only call the vision model when it sees a box-like shape. More setup, but it cuts calls to near zero.

Say it out loud. Add a speaker and have it announce deliveries in the house. This is also the bridge to the voice assistant project later in this path.

What you just learned: put a cheap, local, deterministic filter in front of every expensive AI call. Nearly every affordable AI system in production is shaped this way.
You got to the end

Did you build it?

Ticking it off keeps your pathway honest and moves the next build to the top of every page.

One new build sheet a week

A new build sheet every Thursday, written by a person who built it. No hype, no roundups.