Build & deploy¶
How to take a stock Raspberry Pi Zero 2 WH from a freshly-flashed SD card to a self-launching PiWalletSV signer. This chapter is narrowly about the Pi-side install. Hardware bring-up (bonnet, camera, SPI tuning) is in Getting started; the user-facing journey is in the User manual.
If you only want to develop on a laptop, you don't need any of this —
pip install -e ".[dev]" and pytest is enough.
1. Decide what you're building¶
There are three sensible deployment shapes:
| Shape | What runs | Who it's for |
|---|---|---|
| Dev workstation | core only, pytest, no display |
Working on the protocol, fixtures, or PWA |
| Bonnet kiosk | piwallet bonnet under systemd, autostart |
Day-to-day signer, single-purpose Pi |
| Headless CLI | piwallet from a shell over SSH |
Initial vault setup, scripted backups, or running on a Pi without the bonnet |
This page covers the bonnet kiosk shape end to end. The other two share most of the same install steps and end before the systemd section.
2. Pick an OS image¶
- Raspberry Pi OS Lite (64-bit, Bookworm or later) is the tested
baseline. The 32-bit image works but
picamera2is much slower on it. - Pi OS Lite Trixie is the newest tested target (used in Hardware checkpoint #1).
- Avoid Desktop images on a Pi Zero 2 W — the X server eats the RAM and SD-card lifetime that the signer wants for itself.
Flash the SD card with rpi-imager,
not dd. The imager pre-seeds:
- the
piuser with a password (or your SSH public key), - Wi-Fi credentials,
- locale, timezone, hostname.
Set the hostname to something distinctive (piwallet-1.local).
Set the timezone correctly — the signer doesn't have a real-time
clock, and BEEF anchor verification compares header timestamps.
3. Bootstrap the Pi (recommended)¶
The checked-in bootstrap script replaces the manual apt/SPI/Blinka/pip steps below. Run it on the Pi after syncing the repo from your workstation.
Pi bootstrap (dev / checkpoint)¶
Canonical bring-up for a development or hardware-checkpoint Pi.
Production sealed images use deploy/provision-pi.sh (same Python install
and bonnet-hardware helpers).
What it does¶
scripts/bootstrap-pi-dev.sh on the Pi, after you sync the repo:
| Phase | Actions |
|---|---|
| Packages | picamera2, libzbar0t64, secp256k1/ffi dev libs, build tools, … |
| Boot | SPI + I2C, spidev.bufsiz=131072, OV5647 overlay |
| User | spi, gpio, video, i2c, dialout groups |
| Python | .venv with --system-site-packages + install-piwallet-deps.sh |
| Bonnet | Adafruit raspi-blinka.py + raspi-spi-reassign.py (via setup-bonnet-hardware.sh) |
| Verify | SPI nodes, camera, CLI import check |
Expect 1–3 reboots on a fresh SD card (boot config → Blinka → SPI reassign).
Workstation → Pi¶
From the repo root on your Mac/Linux machine:
Or manually:
./scripts/sync-to-pi.sh user@piwallet.local
ssh user@piwallet.local 'cd ~/PiWallet && bash scripts/bootstrap-pi-dev.sh'
Excludes are listed in scripts/rsync-pi-excludes.txt
(vault state, dev caches, companion PWA, docs site, hardware CAD, tests, …).
sync-to-pi.sh runs scripts/verify-pi-payload.sh
on the Pi after each sync; production provision-pi.sh runs the same check before pip.
Include scripts/ in the sync. Do not exclude it.
After the first reboot:
Options¶
| Flag | Meaning |
|---|---|
--resume |
Skip apt, boot config, venv — hardware + verify only |
--skip-apt / --skip-boot / --skip-venv |
Skip individual phases |
--skip-blinka / --skip-spi-reassign |
Skip bonnet hardware steps |
--no-reboot |
Print reboot reminder instead of rebooting |
State markers under ~/.cache/piwallet-bootstrap/:
blinka.done—import boardworks in the venvspi-reassign.done— CE0/CE1 disabled for bonnet SPIcomplete.done— full bootstrap verified
Python install (install-piwallet-deps.sh)¶
Always use this on the Pi instead of bare pip install -e ".[display,camera]".
On Pi Zero WH (32-bit, armv6l), bsv-sdk pulls coincurve>=21.0.0, whose
sdist fails to build. The install script pins coincurve==20.0.0 (piwheels
armv6 wheel) and installs bsv-sdk with --no-deps.
Smoke tests (use venv wrappers)¶
Do not run bonnet scripts with system python3 — Blinka lives in .venv.
cd ~/PiWallet
source .venv/bin/activate
./scripts/run_display_demo.sh # bonnet panel + buttons
./scripts/run_camera_qr_test.sh --once # camera + QR
.venv/bin/piwallet vault init
./scripts/run_bonnet.sh # full UI
Platform matrix¶
| Board | OS | Notes |
|---|---|---|
| Pi Zero 2 WH (recommended) | Pi OS Lite 64-bit | Production target; normal pip on aarch64 |
| Pi Zero WH (budget / legacy) | Pi OS Lite 32-bit | Works; slow pip; coincurve pin required |
| Laptop | macOS / Linux | pip install -e ".[dev]" only — no bootstrap |
Troubleshooting¶
| Symptom | Fix |
|---|---|
/dev/spidev0.0 does not exist |
Enable SPI, reboot: sudo raspi-config nonint do_spi 0 |
ModuleNotFoundError: board |
Use .venv/bin/python or source .venv/bin/activate |
Permission denied on scripts |
Re-run bootstrap (chmod) or bash scripts/... |
| Garbled display bands | Confirm cat /sys/module/spidev/parameters/bufsiz → 131072 |
coincurve metadata error on pip |
Use install-piwallet-deps.sh, not bare pip install -e |
Manual SPI reassign marker (if you ran Adafruit scripts by hand):
Sections 4–5 below are reference only if you need to debug individual steps. For a new SD card, use the bootstrap script.
4. Manual reference: first boot housekeeping¶
Power on, SSH in, then (bootstrap does this automatically):
5. Manual reference: SPI + kernel buffer¶
The bonnet uses SPI; the default spidev buffer (4 KB) is too small
for a 240×240 frame. Bootstrap sets spidev.bufsiz=131072 on the kernel
cmdline and enables SPI/I2C in config.txt.
Verify after reboot:
Bonnet display also requires Adafruit Blinka + SPI CE reassign —
handled by scripts/setup-bonnet-hardware.sh (called from bootstrap).
(See GETTING_STARTED.md
for bonnet electrical details and the display-only demo.)
6. Sync the repo (if not using sync-to-pi.sh)¶
The signer code lives at a predictable path so scripts/run_bonnet.sh
and the systemd example unit work out of the box.
Or rsync + bootstrap manually — see the include above.
sync-to-pi.sh and provision-pi.sh --src use the allowlist in
scripts/rsync-pi-includes.txt
via scripts/rsync-pi-payload.sh.
Forbidden paths are checked with
scripts/rsync-pi-excludes.txt.
and runs scripts/verify-pi-payload.sh on the Pi
after every sync. The same exclude list is enforced when
deploy/provision-pi.sh installs to /opt/piwallet.
After bootstrap completes, smoke-test:
./scripts/run_display_demo.sh
./scripts/run_camera_qr_test.sh --once
piwallet --help
piwallet mnemonic new
If the bonnet hardware is wired correctly, the display demo shows a coloured frame and the buttons cycle the picture.
7. Supported cameras (libcamera)¶
The sealed PiWalletSV image ships with the ArduCam OV5647 (fixed focus). That is the kit minimum and the only combination tested for v1.
| Sensor / module | Typical overlay | Notes |
|---|---|---|
| OV5647 (ArduCam kit) | camera_auto_detect=1 (sealed image default) or dtoverlay=ov5647 |
Sealed images use auto-detect like a stock Pi OS SD. For no-EEPROM DIY modules, set camera_auto_detect=0 and add dtoverlay=ov5647 manually. |
| IMX219 (Pi Camera v2) | dtoverlay=imx219 |
Fixed focus. |
| IMX477 (HQ Camera) | dtoverlay=imx477 |
Fixed focus. |
| IMX708 (Pi Camera Module 3) | dtoverlay=imx708 or camera_auto_detect=1 |
Autofocus sensor; hold at a consistent distance. |
| IMX296 (Global Shutter) | dtoverlay=imx296 |
Fixed focus. |
See Raspberry Pi's camera documentation for the full sensor list and wiring.
8. Pick a vault path¶
The encrypted vault is a single file. Pick a path that survives
reboots and is owned by the pi user. The convention used throughout
the docs and the systemd example is:
Create the directory and the empty vault:
vault init is interactive: it prompts for a PIN (4-12 digits),
confirms it, scrypt-derives a KEK, writes a fresh empty vault, and
exits. Re-running it is refused if the vault already exists — that's
intentional, see Operate for the wipe
procedure.
Add at least one wallet from a fresh mnemonic so the bonnet has something to display:
piwallet mnemonic new | piwallet vault \
--vault-path ~/.piwallet/vault.bin add --label "First wallet"
Save the mnemonic somewhere offline. The CLI does not store it — the vault holds the encrypted xprv only. You'll need the mnemonic to restore.
9. First-boot disclaimer state¶
The disclaimer acceptance lives in a plain JSON file (it's checked before vault unlock so it can't be in the encrypted vault):
piwallet firstboot status # not accepted
piwallet firstboot run --headless # accepts in CI/test mode
# or run on the bonnet itself:
piwallet firstboot run # interactive, hold-A to accept
Once firstboot status reports accepted for the current terms
version, the bonnet boot path will skip the disclaimer screen.
10. Run the bonnet manually¶
Verify the full boot path works under your user before wiring up systemd:
You should see the disclaimer (only if not yet accepted), the PIN
entry screen, the wallet list, and the per-wallet manage menu.
Ctrl-C (or hold the bonnet's B button) exits. The display should go
dark on exit — if it doesn't, you've hit a bug; file it.
11. Wire up systemd for autostart¶
Copy the example unit and journald drop-in into place:
sudo cp deploy/systemd/piwallet-bonnet.service.example \
/etc/systemd/system/piwallet-bonnet.service
sudo cp deploy/systemd/journald-piwallet.conf.example \
/etc/systemd/journald.conf.d/piwallet.conf
Adjust User=, WorkingDirectory=, and --vault-path if your paths
differ from /home/pi. Then:
sudo systemctl daemon-reload
sudo systemctl restart systemd-journald
sudo systemctl enable --now piwallet-bonnet
sudo systemctl status piwallet-bonnet
What the unit does:
After=network-pre.target graphical.target— wait for the basic boot graph; we don't need network (intentionally).Conflicts=getty@tty1.service— the bonnet doesn't compete with the console getty for the panel.Restart=alwayswithRestartSec=3— if the bonnet exits with a non-zero code (vault wipe, missing vault, disclaimer declined), systemd brings it back with the disclaimer / unlock flow. See Operate § Exit codes for what each code means and how to design around them.
The journald drop-in caps persistent journal size (32 MB) and rotates files at 8 MB. This matters on small SD cards — without bounds the journal can grow until the filesystem refuses writes.
USB backup (production images)¶
Sealed SD-card images are built with deploy/provision-pi.sh, which
installs the USB mount stack automatically:
Sealed SD-card images built with deploy/provision-pi.sh include USB
backup support automatically (step_usb_backup):
| Component | Purpose |
|---|---|
dosfstools, exfatprogs |
Mount FAT32/exFAT sticks |
/opt/piwallet/bin/usb-mount |
Root helper script (mount/unmount) |
/mnt/piwallet-usb |
Canonical mount point (uid=pwsv) |
piwallet-usb-mount.service |
Root Unix socket daemon (/run/piwallet/usb-mount.sock) |
piwallet-bonnet.service |
After=piwallet-usb-mount.service, ReadWritePaths=/mnt/piwallet-usb |
The bonnet runs with NoNewPrivileges=yes, so it cannot call sudo;
the mount daemon performs privileged mounts on its behalf.
Dev installs that copy only piwallet-bonnet.service.example do
not get the mount stack. Either re-run step_usb_backup from
provision-pi.sh on the Pi, or use the CLI against a manually mounted
directory (piwallet backup export --stick-root /path/to/stick).
Verify on a provisioned image:
systemctl is-active piwallet-usb-mount # active
systemctl is-active piwallet-bonnet # active
ls -la /mnt/piwallet-usb # exists, mode 755
Dev installs that use piwallet-bonnet.service.example above do not
include this stack. Either run the step_usb_backup function from
provision-pi.sh on the Pi, or use the CLI with a manually mounted
stick (CLI § piwallet backup).
12. Verify the kiosk¶
After enabling the unit:
- Reboot the Pi:
sudo reboot. - Wait ~30 seconds. The bonnet should display the disclaimer (if not already accepted) or the PIN entry screen.
- From a workstation:
ssh pi@piwallet-1.local "journalctl -u piwallet-bonnet -n 50 --no-pager"and confirm there are no tracebacks. - Power-cycle the Pi by pulling the cable. The bonnet should come back up to the same state.
If steps 2 or 3 fail, jump to Operate § Troubleshooting.
13. Update procedure¶
Day-to-day updates are a git pull + venv refresh + service restart:
ssh pi@piwallet-1.local
cd /home/pi/PiWallet
git pull
source .venv/bin/activate
bash scripts/install-piwallet-deps.sh
sudo systemctl restart piwallet-bonnet
If the update changes a wire format (CBOR keys, PW1 framing, etc.) you'll also need to update the companion PWA on whatever device you pair with. The docs site and the Protocol spec note any non-backwards-compatible changes per release.
14. What you didn't have to do¶
- No Wi-Fi access points. The signer has no inbound services and doesn't need to be on the same network as the companion.
- No NTP daemon. The signer doesn't accept inbound traffic and
doesn't need a synchronised clock for verification — header
anchors carry their own timestamps. (You still want a roughly-right
RTC time for log readability; that's covered by the OS image's
default
systemd-timesyncd.) - No remote shell on the production unit. SSH is convenient during install. For a hardened deploy, disable the SSH service after this chapter and rely on physical access for maintenance. See Security for the threat-model rationale.
15. Official SD image¶
The sealed image is built with
deploy/provision-pi.sh
on Raspberry Pi OS Lite 32-bit (Pi Zero W / Zero WH), captured with dd,
shrunk with scripts/shrink-sd-image.sh
so it fits 8 GB microSD cards, then compressed and signed. Signed .img.xz
artifacts are published on GitHub Releases — see Download.
Operator workflow (sync → provision → capture → sign → publish):
docs/includes/image-release-operator.md.
Dev and production share one payload manifest — never sync companion/, docs/,
tests/, or local vault state to the Pi.
Future work: pi-gen reproducible builds (phase8-hardening).
Until then, the manual provision + capture sequence above is canonical.