Provision a Raspberry Pi Zero 2 W as a remotely-updateable USB mass storage device.
The Pi pretends to be a USB thumb drive (via USB Gadget Mode), while you update its
contents over the network (Ansible for provisioning, rsync/SSH for day-to-day sync).
This is the prototype version: a single-image virtual USB drive that updates on command. Future versions may add A/B double-buffering, idle-aware swaps, and remote triggers.
- Pi Zero 2 W appears as a USB drive (FAT32) to any host (CNC controller, PC, etc.)
- Contents come from a folder on the Pi:
~/file_sync - Update script:
- Temporarily “unplugs” the virtual USB from the host (unbinds the gadget)
- Mounts
/usbdrive.imglocally on the Pi - Syncs files from
~/file_syncinto the image - Unmounts the image
- “Replugs” the drive to the host (re-binds the gadget), so the host sees updated contents
- Fully managed and reproducible via Ansible for initial provisioning
- No hand-editing on the Pi after the initial USB-gadget enable step
Note: The host filesystem is FAT32. The design assumes the host behaves as read-only (only reads files, does not write back). Writes from the host are not relied on or tested in this prototype.
- Raspberry Pi Zero 2 W
- Raspberry Pi OS Lite (64-bit recommended)
- Wi-Fi configured & SSH enabled
- Your workstation must have:
gitansiblesshandrsync
These two edits must be done once on the Pi before running Ansible.
sudo nano /boot/firmware/config.txtAdd this line at the bottom, after [all]:
dtoverlay=dwc2,dr_mode=peripheralsudo nano /boot/firmware/cmdline.txt/boot/firmware/cmdline.txt is a single long line.
Find rootwait and insert modules-load=dwc2 right after it, separated by a space.
console=serial0,115200 console=tty1 root=PARTUUID=... rootfstype=ext4 fsck.repair=yes rootwait quiet splash
console=serial0,115200 console=tty1 root=PARTUUID=... rootfstype=ext4 fsck.repair=yes rootwait modules-load=dwc2 quiet splash
Reboot to apply:
sudo rebootansible/
inventory/
hosts.ini
playbooks/
deploy.yml
files/
usb-gadget-setup.sh
update_usb_image.sh
gadget-init.service
Edit ansible/inventory/hosts.ini:
[masso_sync]
masso-sync.local ansible_user=piAdjust hostname / user as needed for your setup.
From the repo root:
cd ansible
ansible-playbook -i inventory/hosts.ini playbooks/deploy.ymlThe playbook will:
- Install required packages (e.g.
rsync) - Create
/usbdrive.imgif missing (FAT32, ~4 GB by default) - Create
~/file_sync - Install:
/usr/local/bin/usb-gadget-setup.sh/usr/local/bin/update_usb_image.sh/etc/systemd/system/gadget-init.service
- Enable + start
gadget-init.serviceso the USB gadget appears automatically on boot
usb-gadget-setup.sh uses configfs (/sys/kernel/config/usb_gadget) to:
- Define the USB gadget (
pi) - Attach a
mass_storagefunction to/usbdrive.img - Bind it to the Pi’s USB device controller (
/sys/class/udc/3f980000.usbon Zero 2 W)
Hardware wiring:
- PWR IN micro-USB port → 5 V power (no data)
- USB micro-USB port → host (CNC controller, Mac/PC, etc.) using a data-capable cable
Once the Pi boots:
- The gadget service (
gadget-init.service) runs and binds the USB gadget. - The host should detect a USB mass storage device with a FAT32 filesystem.
- The volume name will be whatever the FAT label is set to (you can rename it in Finder / Windows / etc., e.g.
sync-files).
On the host, this looks like a normal thumb drive; on the Pi, it is backed by /usbdrive.img.
Source folder on the Pi:
/home/pi/file_sync
Typical workflow:
-
Copy or sync your files into
~/file_sync. -
Trigger an update:
sudo /usr/local/bin/update_usb_image.sh
The update script will:
- Read the current gadget binding from
/sys/kernel/config/usb_gadget/pi/UDC - If bound, unbind the gadget (host sees USB device disappear)
- Loop-mount
/usbdrive.imgat/mnt/file_sync_image rsyncfrom~/file_sync/→/mnt/file_sync_image/with:- recursive copy
- timestamps preserved
- no attempt to change ownership / permissions (FAT32 doesn’t support them)
- Unmount
/mnt/file_sync_image - Re-bind the gadget to the original UDC (host sees USB device re-appear with updated files)
From the host’s perspective, this is equivalent to unplugging the stick, rewriting files on a PC, and plugging it back in.
#!/usr/bin/env bash
set -euo pipefail
PI_HOST="pi@masso-sync.local"
REMOTE_DIR="/home/pi/file_sync"
if [ $# -lt 1 ]; then
echo "Usage: $0 <file-to-send>"
exit 1
fi
FILE="$1"
BASENAME="$(basename "$FILE")"
echo "⌁ Copying '$FILE' to ${PI_HOST}:${REMOTE_DIR} ..."
rsync -av --progress "$FILE" "${PI_HOST}:${REMOTE_DIR}/"
echo "⌁ Triggering USB image refresh..."
ssh "$PI_HOST" "sudo /usr/local/bin/update_usb_image.sh"
echo "✓ Done. Updated USB image now visible to host."Not implemented yet in this prototype:
- A/B dual-image double-buffering
- Automatic refresh only when the host is idle
- Syncthing / Dropbox / S3-style auto-sync
- Web UI (local dashboard)
- “Force refresh” HTTP endpoint
- Pi self-updating via
ansible-pull - Smarter safety interlocks (e.g., only refresh when host is not accessing the fs)
MIT