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.
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
| Part | Roughly | Notes |
|---|---|---|
| Raspberry Pi Zero 2 W | $15โ25 | A 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โ35 | Module 2 is fine and cheaper. The NoIR version sees in the dark if you add an IR illuminator. |
| Camera ribbon cable for Zero | $3โ6 | Easy 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โ10 | Any reputable brand. Cheap cards fail, and they fail silently. |
| USB power supply | $8โ12 | Needs 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.
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.