SmartestHome/hosts/audio-endpoint
Amir Alexander Abdelbaki ea82ee70ad Add tools/ and CoreSystemConfig.json — one source of truth for every build
Installation was six scripts each carrying its own copy of the container host's
IP, three that had to agree on IDENTITY_TOKEN, and every service URL typed by
hand with a port in it. Any one could be wrong, and the symptom was always the
same and always late: an image that boots fine and then can't reach something,
found after a 40-minute build and a reboot.

Two properties fix that class of bug:

- Nothing is written twice. No script in tools/ contains an IP, port or token.
- Anything derivable is derived. You give the subnet prefix once and one last
  octet per host; every address and service URL is computed from those.

THE TWINNED PAIR. container_host.ip_last_octet 12 and llm_host 13 mean the
container host's OLLAMA_HOST *is* http://<prefix>.13:11434 — computed in the
same build, not typed into two files and kept in sync. Move the LLM host to .21
and the container host's Ollama URL follows; change the subnet and both halves
move along with every kiosk's URLs. Neither image can be built pointing at an
address the other isn't using. Both carry the same SMARTHOME_PAIR_ID (a hash of
the config's meaning, not its bytes) so two USB sticks can be checked against
each other later.

validate-config.py runs before every build and refuses to start on an error, so
a mistake costs seconds not an hour. It catches duplicate ports (including the
music_assistant/pantry_vision 8095 clash that Compose can't see because MA runs
network_mode:host — open decision #31), both hosts on one address, duplicate
hostnames across kiosks and audio endpoints, placeholder tokens (checked before
the length check, so padding "changeme" to 32 chars doesn't pass), a private key
pasted where the public one goes, and a kiosk pointed at a disabled service.

build-all.sh is the normal entry point — the images are a set that has to agree
with itself, so building one is the exception. It builds the core pair, every
kiosk, and every audio endpoint including both architectures (amd64 live-build
ISO and arm64 rpi-image-gen img are different toolchains, not one image).

The two new host ISOs install unattended with everything burnt in, including
service env files generated from derived values — which permanently removes the
class of bug that had chores.env shipping IDENTITY_URL=http://127.0.0.1:8097.
setup-container-host.sh and setup-llm-host.sh now read every config value as
${VAR:-default} so the images configure them without editing.

That also makes every ISO a credential: Wi-Fi PSK, tokens, MQTT and HA
credentials are readable by anyone holding the stick. .gitignore covers the
filled-in CoreSystemConfig.json and build-output/.

Tested: 43 config validation/derivation checks and 44 builder checks against the
real code paths with only `lb` stubbed — every generated env file, preseed,
network config, first-boot unit and build stamp is verified, including that a
port collision refuses the build before writing anything. No ISO has been built;
`lb build` needs live-build, root and a long fetch. tools/README.md says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:22:25 +02:00
..
configs Add idle-gallery weather/clock overlay and headless audio endpoint images 2026-07-29 11:37:29 +02:00
live-build-amd64/config Add tools/ and CoreSystemConfig.json — one source of truth for every build 2026-07-31 13:22:25 +02:00
rpi-image-gen Add tools/ and CoreSystemConfig.json — one source of truth for every build 2026-07-31 13:22:25 +02:00
README.md Add tools/ and CoreSystemConfig.json — one source of truth for every build 2026-07-31 13:22:25 +02:00

README.md

Headless audio endpoint

For rooms that don't get a Sway thin client (hosts/thin-client/) but still want a speaker: a small headless appliance running its own independent Spotify Connect receiver — its own device name, its own room, no shared/synced stream. Two completely separate build pipelines, one per architecture, because the hardware paths genuinely differ (see below) — not one image with a flag.

arm64 (Raspberry Pi) amd64 (mini PC)
Audio hardware HiFiBerry Amp2 HAT (GPIO, 60W, drives 48Ω passive speakers directly) A USB DAC/amp, e.g. Fosi Audio V3 (USB Audio Class, drives passive speakers directly)
Build tool rpi-image-gen live-build (same tool hosts/thin-client/ uses)
Output A bootable .img A bootable live ISO
Per-room identity Set per physical unit, after building, via Raspberry Pi Imager Baked in at build time — one build per room

Both variants share one thing: configs/spotify-connect.service + configs/spotify-connect-start, an architecture-independent systemd unit/wrapper pair. Nothing else is shared — see each variant's own section below.

Why two separate pipelines, not one

  • The HiFiBerry Amp2 is a Raspberry Pi GPIO HAT. There's no amd64 equivalent slot to attach one to — an x86 mini PC needs a USB DAC/amp instead, plug-in hardware, not a HAT.
  • rpi-image-gen is Raspberry-Pi-specific. It doesn't target amd64. Rather than reach for a third, unfamiliar toolchain for the x86 side, the amd64 image reuses the exact tool this repo already has and has already proven for a bootable image: live-build, the same one tools/build-thin-client-iso.sh drives. Lower risk than learning a new toolchain for one variant.

Spotify Connect install: apt-first, documented fallback

Neither spotifyd nor librespot is in Debian bookworm main — same situation hosts/thin-client/live-build/config/hooks/normal/0500-spotify-connect.hook.chroot already solved for the thin client. Both variants here reuse that exact logic and package order (spotifyd then librespot): try apt-get install for each (in case a backport/third-party repo has been added), and if neither installs, stop with a documented message rather than hardcoding a release URL that would silently 404 later. If you hit that message on a real build:

  • fetch a release binary from https://github.com/librespot-org/librespot/releases (check the current tag yourself) into /usr/bin/librespot, or
  • cargo build librespot in the relevant hook script, or
  • use raspotify instead — it wraps librespot with its own packaging and systemd unit. If you go this route, drop this repo's spotify-connect.service/spotify-connect-start in favour of raspotify's own unit rather than layering both, or
  • add a third-party apt repo that carries one of the above.

Spotify Premium is required either way — Spotify Connect doesn't work on Free.

Audio output: direct ALSA, not PipeWire

Deliberately different from the thin client's PipeWire/WirePlumber stack: this is a single-purpose headless appliance with exactly one audio-producing process, so there's no multi-app mixing need PipeWire exists to solve, and neither image runs a desktop session that would start a per-session PipeWire daemon anyway. spotify-connect-start talks straight to ALSA via --device "${ALSA_DEVICE:-default}".

arm64: Raspberry Pi + HiFiBerry Amp2

⚠️ The biggest risk in this whole directory

rpi-image-gen/config/audio-endpoint.yaml and the build script were written from rpi-image-gen's own README/documentation summary, not hands-on validated — no ARM build environment or the tool itself was available while writing this. If rpi-image-gen build -c config/audio-endpoint.yaml rejects the config:

rpi-image-gen layer --list
rpi-image-gen layer --describe <name>

on the real build host is the authoritative source. Adjust the keys in audio-endpoint.yaml to match what that reports — the intent behind each section (device target, packages, files to inject, which service to enable, the build-time install hook) should carry over even if the exact key names don't.

Build

cd hosts/audio-endpoint/rpi-image-gen/scripts
./build-audio-endpoint-image-arm64.sh

Clones rpi-image-gen (to /opt/smart-home/src/rpi-image-gen by default, override with RPI_IMAGE_GEN_SRC) if not already present, then builds. Output is a .img under that clone's work/ directory.

Per-room identity

Build one generic image, not one per room. Raspberry Pi Imager's own OS Customisation dialog (gear icon, or Ctrl+Shift+X) works on any .img, including local custom ones — hostname, Wi-Fi SSID/password, and an SSH key, all set per-flash without touching the image itself. Use it on this image, once per physical unit, before writing the SD card. spotify-connect-start reads $(hostname) at every service start, so whatever hostname you set in Imager is that unit's Spotify Connect device name in the picker — no separate config file, no rebuild per room.

Hardware setup

dtoverlay=hifiberry-dacplus in /boot/firmware/config.txt (from HiFiBerry's own current docs, grouped under "DAC+ Standard/Amp2/Amp4" — worth confirming against your specific board revision before first boot) plus disabling onboard audio, so the HiFiBerry ends up as the only ALSA card (hw:0, which systemd/spotify-connect.service.d/10-alsa-device.conf pins ALSA_DEVICE to).

amd64: mini PC + USB DAC/amp

Build

cd hosts/audio-endpoint/live-build-amd64/scripts
sudo ./build-audio-endpoint-iso-amd64.sh

Same shape as tools/build-thin-client-iso.sh: installs live-build if missing, regenerates config/includes.chroot/ from the shared ../configs/ (git-ignored, generated — never hand-edit it), then runs lb config && lb build. Output is a hybrid ISO, written to a USB stick and live-booted — this image doesn't install itself to disk, same as the thin client.

Per-room identity

No Raspberry-Pi-Imager equivalent exists for a generic x86 ISO, so this variant uses the thin client's own already-proven pattern instead: IMAGE_HOSTNAME in the build script's CONFIGURATION block, baked in at build time. Edit it and re-run the script once per room — same convention as the thin client's own THINCLIENT_NAME/IMAGE_HOSTNAME. spotify-connect-start still just reads $(hostname) at start time; on this variant that value was set at build time instead of per-flash.

Hardware setup

Plug in a USB DAC/amp (e.g. Fosi Audio V3) — no driver needed, it's a standard USB Audio Class device. One manual step is required per physical unit: this repo can't know in advance which ALSA card index your specific mini PC assigns the USB device (it may also have onboard audio competing for card 0, and which one wins varies by board) — guessing one would be exactly the kind of "build against a guess" this project avoids elsewhere (gesture-config.json's camera_device, the ESP32 firmware's weather_entity_id). After first boot, over SSH:

aplay -l                      # find the USB DAC's card index, e.g. card 1
sudo mkdir -p /etc/systemd/system/spotify-connect.service.d
sudo tee /etc/systemd/system/spotify-connect.service.d/10-alsa-device.conf <<'EOF'
[Service]
Environment=ALSA_DEVICE=hw:1
EOF
sudo systemctl daemon-reload
sudo systemctl restart spotify-connect

Manual verification still outstanding

Nothing here has been built, flashed, or booted on real hardware — no Raspberry Pi, no HiFiBerry Amp2, no x86 test box, and no rpi-image-gen install were available while writing this. In rough order of what to check first:

  1. rpi-image-gen's actual config/layer schema — see the risk callout above. The single most likely thing to need hand-adjustment.
  2. The dtoverlay=hifiberry-dacplus line — cross-checked against HiFiBerry's own current docs, but not against your specific Amp2 board revision.
  3. Whether spotifyd/librespot actually install from bookworm's apt sources at all — if not, the documented fallback list above is untested against which of those routes is actually easiest today.
  4. The amd64 live-build tree's structure — checked for internal consistency against build-thin-client-iso.sh's own already-working structure, but the lb config/lb build invocation itself hasn't been run for this specific, much-smaller package/hook set.
  5. USB DAC/amp enumeration — whether aplay -l reliably shows the DAC as a distinct, stable card index across reboots on a given mini PC, or whether it shifts (some boards' USB enumeration order isn't fully deterministic).
  6. Restart=always behaviour — whether librespot/spotifyd/raspotify ever get into a crash-loop state that RestartSec=5 doesn't recover from cleanly on a headless box nobody is watching.
  7. Neither image has Home Assistant integration of any kind — no MQTT, no HA entities, nothing reachable from the smart-home control surface at all. This is a deliberate scope decision (a self-contained per-room Spotify Connect appliance, matching the "per-room independent" choice this design started from), not an oversight — if remote control/monitoring from HA is wanted later, that's a separate addition, not assumed here.