- Python 70.3%
- HTML 13.8%
- Shell 13.5%
- CSS 2.4%
| guestbook | ||
| provision | ||
| tests | ||
| .gitignore | ||
| deploy.sh | ||
| DESIGN.md | ||
| README.md | ||
| requirements.txt | ||
K&E Audio Guestbook
A vintage phone wired to a Raspberry Pi 3B+. Lift the handset and it starts recording immediately, plays a greeting over the top, and keeps recording until the handset goes back down. Recordings land on the SD card and back up to Cloudflare R2 whenever the Pi is on the home network.
See DESIGN.md for the full design, and provision/README.md for turning a fresh Raspberry Pi OS Trixie image into a working unit.
Status
All five build phases are implemented. Nothing has run against real hardware yet — see Not yet verified.
Layout
Two processes, deliberately independent: nothing the WebUI does — a bad setting, a crash, a huge download — can stop the Pi recording.
| Path | What it is |
|---|---|
| guestbook/recorder.py | The call state machine and service entry point |
| guestbook/audio.py | Capture, greeting playback, device probing |
| guestbook/hook.py | Hook switch, GPIO and keyboard-simulated |
| guestbook/leds.py | Recording lamp, RGB status lamp, shutdown button |
| guestbook/status.py | Heartbeat the WebUI reads |
| guestbook/commands.py | Command spool the WebUI writes |
| guestbook/web.py | WebUI |
| guestbook/recordings.py | Listing, download, zip, delete |
| guestbook/network.py | nmcli wrappers for the two wifi profiles |
| guestbook/sync.py | Cloudflare R2 credentials and sync status |
| guestbook/system.py | Host facts and power control |
| guestbook/update.py | Self-update from the git repository |
| guestbook/config.py | Config loading, defaults, atomic saves |
| provision/ | First-boot provisioning for a fresh Pi image |
| tests/ | 133 tests — no Pi or sound card required |
The WebUI
Reachable at http://guestbook.local in either network mode. On first visit it
forces a password to be set before anything is reachable — these are guests'
private recordings and the Pi sits on whatever network it is handed.
Pages: live status, recordings (play/download/zip/delete), greeting upload, settings, network, backup, system.
Three things worth knowing about:
- Simulate a call on the Settings page drives the recorder exactly as the hook switch would, producing a real recording. Useful for proving the audio path before the phone is wired, and again before guests arrive.
- Play it on the phone on the Greeting page auditions the greeting through the handset speaker rather than the browser, which is the only way to set the level where the guest will actually hear it.
- Update on the System page pulls the latest code and restarts the services, so a fix can be shipped to a Pi you can only reach through its own hotspot. See Updating from the WebUI.
Developing without a Pi
The hook switch has a keyboard stand-in, so the whole loop can be driven from a laptop. Recording still needs a working input device; without one it logs the failure and carries on rather than crashing.
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
Then run it with a local config, pressing Enter to lift and replace the handset:
.venv/bin/python -m guestbook.recorder --simulate --config ./dev-config.json
To see what audio devices PortAudio can find:
.venv/bin/python -m guestbook.recorder --devices
Tests
.venv/bin/python -m unittest discover -s tests -v
133 tests, no hardware needed. Only the genuinely absent things are stubbed — PortAudio, nmcli, systemd. Everything else is real: the capture tests write WAVs that the assertions read back, the WebUI tests run against a real temp data tree so downloads, zips and uploads are exercised end to end, and the update tests fetch between real git repositories in a temp directory.
Not yet verified
Everything below is written and tested but has never touched real hardware:
sd.InputStreamhas never successfully opened. The dev Mac has no audio input, so the capture path is proven only downstream of PortAudio's callback. First thing to check on the Pi.- Full duplex on the USB card. Capture and greeting playback open the sound
card as two independent streams. USB audio class devices normally handle this
fine; if the Sabrent card refuses, route the greeting through
aplayas a subprocess instead. - Every GPIO pin. LED colours, blink patterns and the hold-to-shutdown button are tested through a fake sink, not real pins.
- nmcli profile writes. Read paths degrade correctly with nmcli absent, but the write paths have not run against a real NetworkManager.
- rclone against R2. Credential handling is tested; an actual upload is not.
Getting it onto a Pi
A fresh card — one file onto the boot partition. Flash with Raspberry Pi Imager first (hostname, user, SSH, home WiFi), then:
cp provision/guestbook-firstboot.sh /Volumes/bootfs/
Add one line to user-data on that same partition, under runcmd::
- bash /boot/firmware/guestbook-firstboot.sh --install-hook && systemctl start --no-block guestbook-firstboot.service
Boot the Pi and it builds itself, cloning the application from the repository
into /opt/guestbook. It needs to reach the git host on that first boot; if it
cannot, it provisions everything else and says so, and ./deploy.sh fills in
the code afterwards.
./provision/stage-to-bootfs.sh does the copy, writes a guestbook.conf and
edits user-data in one command. Full walkthrough, including the settings you
should change first, in provision/README.md.
An already-provisioned Pi, to ship a code change:
git push && ./deploy.sh guestbook.local
deploy.sh does not copy the working tree. It tells the Pi to pull the branch
you are on, so what runs there is always a commit you can name — and it is the
same operation the System page's Update button performs.
Two consequences, both deliberate:
- It refuses when the current branch has unpushed commits, and warns about uncommitted changes. Neither can reach the Pi, and a deploy that silently ships nothing is worse than one that stops.
- A Pi that is not a git checkout yet — one provisioned before the switch to cloning, or platform-only because first boot could not reach the git host — is adopted into one on the first run, then updates normally after that.
It restarts and enables the services itself using the login user's sudo rather
than the service user's narrower rules, so it also works on a Pi provisioned
before any of this existed. APP_DIR and SVC_USER can be overridden in the
environment for an install that lives somewhere else.
Updating from the WebUI
Update on the System page runs the same thing deploy.sh triggers, but
from the Pi's own side and without needing this machine at all: it fetches
the repository, hard-resets
/opt/guestbook to the tip of main, reinstalls dependencies if
requirements.txt changed, and restarts both services. Check for updates
first shows what is waiting without applying anything.
That matters when the Pi is hosting its own hotspot at an event — you may not be able to SSH to it, but the WebUI is right there.
Two things to know:
- It is a hard reset, not a merge. Anything edited directly on the Pi is discarded. The page warns before the button does anything.
- A Pi that is not a git checkout gets adopted into one. Provisioning
clones, so this only applies to units built before that changed. The first
update runs
git init, adds the remote and resets onto the branch — and the page says so first. The virtualenv is left alone.
The repository and branch come from the checkout's own origin when it has
one, so a Pi deployed from a fork keeps tracking that fork. Otherwise they come
from update.repo_url / update.branch in /etc/guestbook/config.json.
The update log is written to /var/lib/guestbook/update-progress.json and the
System page follows it live, including across the WebUI restarting itself at
the end.
On a Pi provisioned before this feature existed, the sudoers rule does not
yet let the WebUI restart itself, so the first update will land the code but
report that it could not restart guestbook-web.service. Either re-run
provisioning, or add the one command once:
ssh guestbook.local "sudo sed -i 's#systemctl restart guestbook.service#systemctl restart guestbook.service, /usr/bin/systemctl restart guestbook-web.service#' /etc/sudoers.d/guestbook && sudo visudo -cf /etc/sudoers.d/guestbook"
It can also be driven from a shell, which is the quickest way to see what is going on if an update misbehaves:
ssh guestbook.local "sudo -u guestbook /opt/guestbook/venv/bin/python -m guestbook.update --check"
On the Pi
Provisioning installs guestbook.service, which runs the recorder against
/etc/guestbook/config.json.
sudo systemctl status guestbook.service
sudo journalctl -u guestbook.service -f
If the Logs page comes back empty
The journal is root:systemd-journal 0640, so the unprivileged guestbook
user cannot read it unless it is in that group — and journalctl reports the
refusal by exiting 0 with no output, which looks exactly like a service that
has never logged anything. Provisioning now grants the group; a card built
before that needs it once:
ssh guestbook.local "sudo usermod -aG systemd-journal guestbook && sudo systemctl restart guestbook-web.service"
The WebUI detects this case and prints the same fix on the Logs page rather than showing a blank pane.
If the phone behaves backwards
If recording starts when you hang up, the hook switch polarity is inverted.
That is a config change, not a rewiring job — flip gpio.hook_normally_open in
/etc/guestbook/config.json and restart the service.