No description
  • Python 70.3%
  • HTML 13.8%
  • Shell 13.5%
  • CSS 2.4%
Find a file
2026-08-09 12:53:35 -05:00
guestbook Deploy downloads git real 2026-08-09 12:53:35 -05:00
provision Deploy downloads git real 2026-08-09 12:53:35 -05:00
tests Deploy downloads git real 2026-08-09 12:53:35 -05:00
.gitignore Initial commit 2026-08-09 16:40:17 +00:00
deploy.sh Deploy downloads git real 2026-08-09 12:53:35 -05:00
DESIGN.md Deploy downloads git real 2026-08-09 12:53:35 -05:00
README.md Deploy downloads git real 2026-08-09 12:53:35 -05:00
requirements.txt Real Init 2026-08-09 11:44:14 -05:00

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.InputStream has 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 aplay as 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.