Your frame may already do this
picframe3 does this by itself, so on a Raspberry Pi 4 or 5 you no longer need anything below. The article stays here for frames still running the older software.
Tested with
A Raspberry Pi 5 with OS Bookworm (November 2024), running pi3d PictureFrame. All other Pis from model Pi Zero 2 W upwards should work fine with pi3d. This is not a statement about picframe3, which needs a Pi 4 or newer and does not need this script at all.
Have you ever wanted a digital picture frame Raspberry Pi setup that automatically detects a USB stick with photos, copies its contents, and integrates seamlessly with your Pi3D PictureFrame?
That’s precisely what I describe in this article!
If your frame runs picframe3 on a Pi 4 or Pi 5, you do not need this script. picframe3 watches its picture folders itself and notices new photographs within seconds, whether they arrived over Syncthing, over the file share or on a USB stick. There is no pyudev daemon to install, no HTTP call to make and no configuration.yaml — picframe3 has no such file. If you want the stick’s photographs to be the only thing showing, use the frame’s folder filter, in the web UI or from Home Assistant, and clear it again afterwards. The 2026 build guide has the details.
What follows is for pi3d frames — the Pi 2, Pi 3 and Zero 2 W, and any Pi 4 or 5 you have chosen to leave on the old stack. Everything here, including use_http and the venv_picframe paths, is pi3d-specific.
This feature was requested several times in the past. But it wasn’t until reader Ian C. came up with a solution that got me going.
Building on Ian’s work, I have modified the script so that no reboot or any file modification of the existing setup is necessary.
How this script works
The script listens for USB “events”. When you plug in a USB stick, it immediately recognizes it and gets going.
To access the files on the USB stick, the script mounts it to a specific folder on the Raspberry Pi, in this case, ~/mnt/usb. This is the USB’s temporary home while the script runs its course.
Once the USB is mounted, the script scans it for image files with extensions like .jpg, .jpeg, or .heic. Any matching files are copied to a folder called ~/Pictures/fromUSB.
After copying the files, the script tells the Pi3D PictureFrame software to switch to the subdirectory fromUSB via an HTTP command. The software dynamically switches to this subdirectory, so your USB photos, and only those, start showing up immediately.
When you remove the USB stick, the “fromUSB” directory is deleted, and the display directory is changed back to your main ~/Pictures folder. It then waits for another USB stick to be inserted, which may or may not happen. Ask Godot how that feels.
I have repeatedly inserted and removed the USB stick, and the script seems rock solid.
The only point to remember is to insert the USB stick after PictureFrame is up and running, not before. But that’s how it should be anyway, because who turns their Raspberry Pi off anyway?
Requirements
This solution assumes that you have installed Pi3D PictureFrame as described here or here.
Here is what else you need to do:
Activate HTTP in configuration.yaml.
For this to work, just set use_http to True like below. Most people won’t have to change anything else.
http:
use_http: True # default=False. Set True to enable http NB THIS SERVER IS FOR LOCAL NETWORK AND SHOULD NOT BE EXPOSED TO EXTERNAL ACCESS
path: "/home/your-username/picframe_data/html" # path to where html files are located
port: 9000 # port used to serve pages by http server < 1024 requires root which is *bad* idea
auth: false # default=False. Set True if enable basic auth for http
username: admin # username for basic auth
password: null # password for basic auth. If set null generate random password in file basic_auth.txt in parent http directory
use_ssl: False
keyfile: "path/to/key.pem" # private-key
certfile: "path/to/cert.pem" # server certificate
Next, install two Python packages
source venv_picframe/bin/activate
pip install pyudev psutil
Activating the virtual environment first is not optional any more: since Bookworm, a pip install outside one is refused with error: externally-managed-environment.
and do a sudo reboot at the end.
The Python script
Make sure that you are in your home directory and create a new file with
nano usb_stick.py
(No sudo — it is a file in your own home directory.)
Paste the content below in the file.
#!/usr/bin/env python3
import pyudev
import os
import shutil
import urllib.request
import subprocess
import psutil # For identifying processes using the mount point
from time import sleep
# Constants
USB_MOUNT_POINT = os.path.expanduser("~/mnt/usb") # Mount point for the USB stick
PICTURES_ROOT = os.path.expanduser("~/Pictures") # Root directory for Pi3D PictureFrame
USB_PICTURES_DIR = "fromUSB" # Subdirectory for USB pictures
DEFAULT_PICTURES_SUBDIR = "" # Default subdirectory for Pi3D
VALID_EXTENSIONS = (".jpg", ".jpeg", ".heic", ".JPG", ".JPEG", ".HEIC")
PI3D_CONTROL_URL = "http://localhost:9000/?subdirectory=" # Pi3D control URL
def delete_usbpictures():
"""Delete the USB pictures directory if it exists."""
destination_path = os.path.join(PICTURES_ROOT, USB_PICTURES_DIR)
if os.path.exists(destination_path):
shutil.rmtree(destination_path)
print(f"Deleted existing directory: {destination_path}")
def find_usb_device(max_retries=5, retry_delay=1):
"""Find the first unmounted USB device with a valid file system."""
for attempt in range(max_retries):
print(f"Checking for USB devices (attempt {attempt + 1}/{max_retries})...")
result = subprocess.run(["lsblk", "-o", "NAME,FSTYPE,MOUNTPOINT", "-n"], capture_output=True, text=True)
print("lsblk output:")
print(result.stdout)
for line in result.stdout.splitlines():
parts = line.strip().split()
if len(parts) >= 2 and parts[1] in ["vfat", "exfat", "ntfs", "ext4"]:
device_name = parts[0].strip("└─")
mount_point = parts[2] if len(parts) > 2 else ""
if not mount_point:
print(f"Detected USB device: /dev/{device_name}")
return f"/dev/{device_name}"
sleep(retry_delay)
print("No unmounted USB devices detected.")
return None
def mount_usb():
"""Mount the USB stick to the predefined location."""
if os.path.ismount(USB_MOUNT_POINT):
print(f"USB stick already mounted at {USB_MOUNT_POINT}. Skipping mount.")
return True
os.makedirs(USB_MOUNT_POINT, exist_ok=True)
usb_device = find_usb_device()
if not usb_device:
print("No USB device found.")
return False
result = subprocess.run(["sudo", "mount", usb_device, USB_MOUNT_POINT], capture_output=True, text=True)
if result.returncode == 0:
print(f"Mounted USB stick at {USB_MOUNT_POINT}")
return True
else:
print(f"Failed to mount USB stick: {result.stderr}")
return False
def unmount_usb():
"""Attempt to unmount the USB device and reset the main directory."""
# Check if the USB mount point is actually mounted
if os.path.ismount(USB_MOUNT_POINT):
try:
# Attempt to unmount the USB using sudo
subprocess.check_call(["sudo", "umount", USB_MOUNT_POINT])
print(f"Unmounted USB stick from {USB_MOUNT_POINT}.")
except subprocess.CalledProcessError as e:
print(f"Failed to unmount USB stick at {USB_MOUNT_POINT}: {e}")
else:
print(f"No USB stick mounted at {USB_MOUNT_POINT}. Skipping unmount.")
# Reset Pi3D to the main directory
try:
url = "http://localhost:9000/?subdirectory="
response = urllib.request.urlopen(url)
print("Successfully reverted to the main directory.")
print(f"Response: {response.read().decode('utf-8')}")
except Exception as e:
print(f"Failed to revert to the main directory: {e}")
def copy_photos_to_usbpictures():
"""Copy valid photos from USB to the designated directory."""
delete_usbpictures()
destination_path = os.path.join(PICTURES_ROOT, USB_PICTURES_DIR)
os.makedirs(destination_path, exist_ok=True)
files_copied = 0
print(f"Scanning mounted USB directory: {USB_MOUNT_POINT}")
for root, dirs, files in os.walk(USB_MOUNT_POINT):
print(f"Scanning directory: {root}")
files = [f for f in files if not f.startswith("._")]
for file in files:
print(f"Found file: {file}")
if file.lower().endswith(VALID_EXTENSIONS):
src_file = os.path.join(root, file)
dest_file = os.path.join(destination_path, os.path.relpath(src_file, USB_MOUNT_POINT))
os.makedirs(os.path.dirname(dest_file), exist_ok=True)
try:
shutil.copy2(src_file, dest_file)
files_copied += 1
print(f"Copied: {src_file} -> {dest_file}")
except Exception as e:
print(f"Failed to copy {src_file}: {e}")
if files_copied > 0:
print(f"Successfully copied {files_copied} files.")
else:
print("No valid files found to copy.")
def change_subdirectory(subdir_name):
"""Update the subdirectory in Pi3D PictureFrame."""
if subdir_name in os.listdir(PICTURES_ROOT):
full_url = PI3D_CONTROL_URL + subdir_name
try:
urllib.request.urlopen(full_url)
print(f"Successfully changed subdirectory to: {subdir_name}")
except Exception as e:
print(f"Failed to change subdirectory to: {subdir_name}. Error: {e}")
else:
print(f"Subdirectory {subdir_name} does not exist. Skipping change.")
# Monitor USB events
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by(subsystem='usb')
print("Listening for USB events...")
# Handle USB already inserted at boot
if os.path.ismount(USB_MOUNT_POINT):
print(f"USB stick detected at boot, treating as newly inserted.")
copy_photos_to_usbpictures()
change_subdirectory(USB_PICTURES_DIR)
# Monitor for new USB events
for device in iter(monitor.poll, None):
if device.action == "add":
print(f"USB device added: {device.device_path}")
sleep(2)
if mount_usb():
copy_photos_to_usbpictures()
change_subdirectory(USB_PICTURES_DIR)
elif device.action == "remove":
print(f"USB device removed: {device.device_path}")
change_subdirectory(DEFAULT_PICTURES_SUBDIR)
delete_usbpictures()
unmount_usb()
Save and close.
Make it executable with
chmod +x usb_stick.py
I experimented for a few hours to make the script as robust as possible and could not crash the file even after twenty USB in/out hotplugging.
Automatically start at boot
The last step is to start the script in the background, where it will wait like a sleeper cell until someone with a USB drive comes to visit you and shares his photographic magic.
Or you might wish to have a particular theme day without damaging your carefully curated photo library. In that case, you could just put your photos on a USB stick. Of course, you can also do this.
To be prepared for whatever may come at you, create a new service file for your script with
sudo nano /etc/systemd/system/usb_stick.service
Paste the following content into the file:
[Unit]
Description=USB Stick Management Script
After=network.target
[Service]
ExecStart=/home/your-username/venv_picframe/bin/python /home/your-username/usb_stick.py
WorkingDirectory=/home/your-username
Restart=always
User=your-username
[Install]
WantedBy=multi-user.target
Replace your-username with your actual user name — Raspberry Pi OS no longer creates a default pi account, so User=pi and /home/pi/… are no longer safe defaults.
One thing this article never mentioned and should have: the script calls sudo mount and sudo umount, but the service above runs as an ordinary user with no terminal. Unless that user may run those two commands without a password, the mount silently fails and nothing happens when you plug a stick in — which is a horrible thing to debug. Give it an explicit rule:
sudo visudo -f /etc/sudoers.d/usb_stick
and put one line in it, with your own user name:
your-username ALL=(root) NOPASSWD: /usr/bin/mount, /usr/bin/umount
I am flagging this because it follows from how the script is written, not because I have re-tested the whole thing on a current OS — so if it still does not mount, check journalctl -u usb_stick.service and the exact paths of mount and umount on your Pi with which mount.
Reload the Systemd Daemon, enable and start the service with
sudo systemctl daemon-reload
sudo systemctl enable usb_stick.service
sudo systemctl start usb_stick.service
You can check if everything works with
sudo systemctl status usb_stick.service
On boot, your Raspberry Pi will now wait until a USB media drive comes along.
Conclusion
Thanks for the inspiration to Ian C., and let me know your particular use case for this USB option.
And if you are moving that frame to picframe3 at some point: this whole service is one of the things you get to delete.
Was this article helpful?
Thank you for your support and motivation.