Add idle-gallery weather/clock overlay and headless audio endpoint images

Two additions:

- Thin client: an eww weather/clock overlay over the idle-gallery slideshow.
  Time/date come from the local clock, weather from a new retained MQTT topic
  (smarthome/weather/current) an HA automation has to publish — degrades to
  clock-only if that never happens. idle-gallery.sh owns the overlay's
  visibility, mirroring how fullscreen-watcher.sh already owns the
  now-playing widget's.

- hosts/audio-endpoint/: headless, per-room-independent Spotify Connect
  appliances for rooms without a thin client, no shared/synced stream. Two
  build pipelines sharing one architecture-independent systemd
  unit/wrapper: arm64 (Raspberry Pi + HiFiBerry Amp2) via rpi-image-gen
  producing a real flashable .img, and amd64 (mini PC + USB DAC/amp) reusing
  the thin client's own live-build toolchain stripped of its graphical/kiosk
  stack. Both reuse the thin client's existing apt-first/documented-fallback
  Spotify Connect install logic rather than reinventing it. rpi-image-gen's
  exact config schema is flagged explicitly as unverified — no ARM build
  environment was available to validate it against the real tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgTwMqiho8DT6ue5S49WBP
digest-per-person-and-agendas
Amir Alexander Abdelbaki 2026-07-29 11:37:29 +02:00
parent 1d8b416ab9
commit bfca1c7eb0
20 changed files with 1061 additions and 13 deletions

22
.gitignore vendored
View File

@ -65,5 +65,27 @@ gallery-credentials
firmware/esp32-s3-touch-lcd-1.85c/secrets.yaml
firmware/**/.esphome/
# audio-endpoint (amd64): same generated-includes.chroot / live-build-artifacts
# handling as hosts/thin-client/live-build/ above.
hosts/audio-endpoint/live-build-amd64/config/includes.chroot/
hosts/audio-endpoint/live-build-amd64/auto/
hosts/audio-endpoint/live-build-amd64/.build/
hosts/audio-endpoint/live-build-amd64/cache/
hosts/audio-endpoint/live-build-amd64/chroot/
hosts/audio-endpoint/live-build-amd64/chroot.files
hosts/audio-endpoint/live-build-amd64/chroot.packages.*
hosts/audio-endpoint/live-build-amd64/binary/
hosts/audio-endpoint/live-build-amd64/binary.*
hosts/audio-endpoint/live-build-amd64/*.iso
hosts/audio-endpoint/live-build-amd64/*.log
hosts/audio-endpoint/live-build-amd64/*.contents
hosts/audio-endpoint/live-build-amd64/*.files
hosts/audio-endpoint/live-build-amd64/*.packages
# audio-endpoint (arm64): rpi-image-gen's own build/work output, if ever cloned
# or built inside the repo tree instead of the default /opt/smart-home/src path.
hosts/audio-endpoint/rpi-image-gen/work/
hosts/audio-endpoint/rpi-image-gen/rpi-image-gen/
__pycache__/
*.pyc

View File

@ -19,6 +19,8 @@ hosts/
scripts/ Host setup / bootstrap scripts
llm-host/ Ollama + GPU host setup (separate physical machine)
thin-client/ Sway kiosk/media-station ISO (live-build) + thinclient-agent
audio-endpoint/ Headless Spotify Connect appliance for rooms with no thin
client — arm64 (rpi-image-gen) + amd64 (live-build) images
firmware/
ruview/ RuView ESP32-S3 CSI presence node configs
esphome-ble-proxy/ ESPHome configs for Bermuda BLE proxy nodes
@ -49,10 +51,11 @@ admin-canvas/ On-demand sys-admin-llm display surface for the thin
- [ ] CalDAV / Nextcloud calendar integration notes
- [ ] Identity correlation flow (Node-RED)
- [x] Sway thin-client ISO (live-build) + thinclient-agent — built, not yet boot-tested on real hardware; RDP replaced by wayvnc (resolved), remaining open items (mic-enabled rooms, exact hardware target, wayvnc password provisioning) in `docs/project-plan.md` §4
- [ ] Thin-client follow-ups in progress: fullscreen-aware now-playing widget (cover art + controls), minimal Firefox chrome + uBlock Origin/SponsorBlock, persistent audio-output selection, outbound RDP/VNC client (`rdp-vnc.json`), HA mobile-app browser remote control (text input + mouse buttons), capture-card ("receiver box") video source selection on a new `5:capture` workspace — built, not yet tried against real capture-card hardware, see `hosts/thin-client/README.md`
- [ ] Thin-client follow-ups in progress: fullscreen-aware now-playing widget (cover art + controls), minimal Firefox chrome + uBlock Origin/SponsorBlock, persistent audio-output selection, outbound RDP/VNC client (`rdp-vnc.json`), HA mobile-app browser remote control (text input + mouse buttons), capture-card ("receiver box") video source selection on a new `5:capture` workspace, idle-gallery weather/clock overlay (clock always, weather via a new `smarthome/weather/current` MQTT topic an HA automation has to publish) — built, not yet tried against real capture-card hardware or a real weather automation, see `hosts/thin-client/README.md`
- [x] Quarter-daily digest engine (mail/Signal/Telegram/Discord/WhatsApp, news, financial ingestion; LLM synthesis; digest-canvas SDK) — built and wired into `setup-container-host.sh` (`ENABLE_DIGEST_ENGINE`, off by default), not yet run against real credentials; household/calendar ingest (CalDAV/Grocy) still needs a real data source wired in, see `docs/project-plan.md` §4
- [x] admin-canvas + admin-web (sys-admin-llm on-demand display surface for the thin clients) — built and wired into `setup-container-host.sh` (`ENABLE_ADMIN_CANVAS`, off by default); the HA-side tool/rest_command wiring and the specific entities it surfaces (e.g. power-monitoring) are still undecided, see `docs/project-plan.md` §4
- [ ] ESP32-S3-Touch-LCD-1.85C-V2 voice satellite + status display (`firmware/esp32-s3-touch-lcd-1.85c/`) — ESPHome config written and passes `esphome config`, not yet flashed to real hardware; `media_player`/`weather` entity IDs still need to be chosen, see `docs/project-plan.md` §4
- [ ] Headless audio endpoint (`hosts/audio-endpoint/`) — per-room independent Spotify Connect appliance for rooms without a thin client, arm64 (Raspberry Pi + HiFiBerry Amp2, rpi-image-gen) and amd64 (mini PC + USB DAC/amp, live-build) build pipelines written, **neither built/flashed/booted on real hardware** — rpi-image-gen's exact config schema in particular is unverified, see `hosts/audio-endpoint/README.md`
## Quick start

View File

@ -90,6 +90,14 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato
|---|---|---|
| Waveshare ESP32-S3-Touch-LCD-1.85C, **V2 revision** | €3545 | Round 360×360 LCD + dual mic (ES7210, with echo-cancellation reference path) + speaker (ES8311) + 8Ω 2W speaker. **Must be V2** — V1 has no AEC circuit and different audio pins, see `firmware/esp32-s3-touch-lcd-1.85c/README.md`. An alternative to Home Assistant Voice PE for rooms that also want a status display, not a wholesale replacement of §1.11's mic-enabled-room hardware |
### 1.13 Headless audio endpoint hardware (Phase 15)
| Item | Est. Price (EUR) | Notes |
|---|---|---|
| Raspberry Pi (Zero 2 W is enough for one Spotify Connect stream; any 40-pin-header Pi works) | €1535 | arm64 variant |
| HiFiBerry Amp2 HAT | ~€45 | 60W Class D, drives 48Ω passive speakers directly — GPIO HAT, Raspberry-Pi-only |
| Passive bookshelf speakers (e.g. Micca MB42X G2) | ~€100/pair | Same pairing already recommended for the thin-client rooms |
| *(amd64 alternative)* Spare x86 mini PC + USB DAC/amp (e.g. Fosi Audio V3, ~€130) | €0130 | No GPIO HAT involved — a USB Audio Class device instead, for rooms getting a repurposed mini PC rather than a Raspberry Pi |
---
## 2. Software (all open source / self-hosted)
@ -144,6 +152,9 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato
| Admin canvas static serving | **admin-web** (nginx:alpine) | Serves the admin canvas's rendered JSON + uploaded media read-only to the thin client — same role as digest-web, separate instance |
| Admin canvas rendering | **canvas-sdk** (custom, vendored, duplicated from digest-canvas SDK) | Same window/panel chrome and glow theme, minus the globe, plus `stat`/`image`/`video`/`chart` window kinds (the `chart` kind is a dependency-free inline-SVG bar/sparkline) |
| Voice/display satellite firmware | **ESPHome** (custom config, `firmware/esp32-s3-touch-lcd-1.85c/`) | Display (LVGL), on-device wake word (`micro_wake_word`, `okay_nabu`), `voice_assistant` streaming into the existing Phase 3 Assist pipeline, and a media/cover-art-over-idle-weather-time-date priority display |
| Headless audio endpoint (arm64) | **rpi-image-gen** (custom config, `hosts/audio-endpoint/rpi-image-gen/`) | Raspberry Pi Foundation's current custom-image tool; builds a flashable `.img` with a per-room Spotify Connect receiver + HiFiBerry Amp2 audio |
| Headless audio endpoint (amd64) | **live-build** (custom config, `hosts/audio-endpoint/live-build-amd64/`) | Reuses the thin client's own build tool, stripped of the whole graphical/kiosk stack — headless boot straight to the same per-room Spotify Connect receiver |
| Headless audio endpoint software | **librespot** or **spotifyd** (apt-first, documented fallback) | Independent, per-room Spotify Connect — no Snapcast, no shared/synced stream; each endpoint is its own device in Spotify's picker, same model as the thin client's own per-room spotifyd/librespot (Phase 11.6) |
---
@ -272,6 +283,19 @@ New hardware: §1.12, **V2 revision specifically** — V1 has no echo-cancellati
6. **Nothing under this repo builds the HA side** here either — the device is a standard ESPHome device once flashed and adopted (Settings → Devices → Add Device → ESPHome), and plugs into whichever Assist pipeline Phase 3 already has configured. No new add-on, no new container.
7. Validated so far with `esphome config voice-display.yaml` (ESPHome's own schema validator — passes cleanly), **not flashed to real hardware**. See the itemized unverified list in `firmware/esp32-s3-touch-lcd-1.85c/README.md`.
### Phase 15 — Headless audio endpoint (rooms without a thin client)
New hardware: §1.13. Two separate build pipelines, arm64 and amd64 — see below for why.
1. New top-level `hosts/audio-endpoint/` directory. **Per-room independent Spotify Connect, not a synced whole-house stream**: each endpoint is its own Spotify Connect device with its own name, exactly mirroring the thin client's own per-room spotifyd/librespot receiver (Phase 11.6). No Snapcast, no shared source, no container-host changes — a self-contained appliance per room, by explicit choice over the alternative (a single synced multi-room source) considered and rejected during planning.
2. **Two build pipelines, not one image with a flag**: the HiFiBerry Amp2 (arm64's assumed audio hardware) is a Raspberry Pi GPIO HAT with no amd64 equivalent — an x86 mini PC instead uses a USB DAC/amp (e.g. Fosi Audio V3, USB Audio Class, no driver needed). arm64 uses **rpi-image-gen** (the Raspberry Pi Foundation's current officially-recommended tool for custom images, superseding pi-gen for this use case) to produce a real flashable `.img`. amd64 reuses **the thin client's own live-build toolchain** rather than a third, unfamiliar one — a new, much smaller live-build tree with the entire graphical/kiosk stack stripped out, headless boot straight to `multi-user.target`.
3. **Spotify Connect install is apt-first with a documented fallback, reused identically on both variants** from `hosts/thin-client/live-build/config/hooks/normal/0500-spotify-connect.hook.chroot`'s own already-established logic (neither `spotifyd` nor `librespot` is in Debian bookworm main) — not two independent judgment calls about the same package.
4. **Audio output is direct ALSA, not PipeWire**, on both variants — deliberately different from the thin client, since this is a single-purpose headless appliance with no multi-app mixing need and no desktop session to run a per-user PipeWire daemon in the first place.
5. **Per-room identity differs by variant, each using whichever mechanism its own platform actually supports well**: arm64 builds one generic image and sets hostname/Wi-Fi per physical unit via Raspberry Pi Imager's OS Customisation dialog (works on any `.img`, no rebuild needed); amd64 has no equivalent tool for a generic x86 ISO, so it bakes `IMAGE_HOSTNAME` in at build time instead, one build per room, matching the thin client's own `THINCLIENT_NAME` convention. Both converge on the same mechanism at the software layer: `spotify-connect-start` reads `$(hostname)` fresh at every service start, regardless of which point in the pipeline set it.
6. **rpi-image-gen's exact config/layer YAML schema is the single highest-risk unverified part of this phase** — written from the tool's documented structure, not hands-on validated (no ARM build environment available). See `hosts/audio-endpoint/README.md`'s prominent callout before a real build.
7. Neither image gives Home Assistant any control or visibility over these endpoints — no MQTT, no HA entities. Deliberate scope decision matching the "per-room independent" choice, not an oversight; revisit as a separate addition if remote control/monitoring from HA is wanted later.
8. Nothing built or flashed on real hardware — no Raspberry Pi, no HiFiBerry Amp2, no x86 test box available. See the itemized unverified list in `hosts/audio-endpoint/README.md`.
### Testing checklist before calling any phase "done"
- Does the reactive path (presence → light on) work with the LLM host powered off? (It must.)
- Does a bad/slow LLM response ever block a light switch? (It must not.)
@ -301,10 +325,12 @@ New hardware: §1.12, **V2 revision specifically** — V1 has no echo-cancellati
- Does the idle weather/time/date cycle ever show *over* an active media page, or does media ever fail to take priority the instant playback starts? (It must not — media priority is the one hard behavioral requirement of Phase 14.)
- Does the voice-state visualizer ever replace or hide the underlying page's content instead of overlaying it? (It must not — it's a `top_layer` overlay by design, never a page swap.)
- Is the ESP32-S3-Touch-LCD-1.85C firmware ever flashed onto a V1 board? (It must not be — V1 has no AEC circuit and different audio pins; this phase's config assumes V2 throughout.)
- Does a headless audio endpoint's Spotify Connect device name ever fail to match the room it's actually in? (It must not — `spotify-connect-start` always reads `$(hostname)`, which is set per-room by Raspberry Pi Imager on arm64 or `IMAGE_HOSTNAME` at build time on amd64; a copy-pasted/reused image for two rooms is a configuration mistake this software has no way to detect, same class of risk already called out for the ESP32 firmware's `media_player_entity_id`.)
- Does the headless audio endpoint ever gain a shared/synced audio path, an MQTT connection, or an HA entity without a deliberate new decision to add one? (It must not — Phase 15 is scoped to per-room independent Spotify Connect only, on purpose.)
---
## 4. Open decisions (Phases 1114)
## 4. Open decisions (Phases 1115)
These need a decision before their respective implementation steps can be built — everything above is written to accommodate any answer, but nothing should be built against an unresolved item.
@ -322,3 +348,4 @@ These need a decision before their respective implementation steps can be built
12. **wayvnc ships with no password**`start-wayvnc` fails closed on a sentinel value (`CHANGEME-SET-ON-FIRST-BOOT`) rather than serving unauthenticated VNC; a real password must be generated on the booted machine before wayvnc will start (see `hosts/thin-client/README.md`).
13. **The sys-admin-llm's HA-side wiring has no real data source or tool definition yet** (new, Phase 13) — same shape as open decision #9 above: `admin-canvas/README.md` documents the expected `rest_command:`/tool-call contract and worked example JSON, but which HA entities/history back something like "the kitchen outlet's power draw" is unresolved, and no metering-capable Zigbee smart plug is in this plan's hardware list (§1.4) yet. Needs a hardware decision (a power-monitoring outlet) and an actual HA tool/script, neither of which exists in this repo by design — see the Phase 13 "nothing under this repo builds the HA side" note.
14. **The voice/display satellite has no real hardware verification, and two of its data sources are unpicked** (new, Phase 14) — `firmware/esp32-s3-touch-lcd-1.85c/voice-display.yaml` passes ESPHome's own config validator but has never been flashed to a physical unit; the display init sequence, the AEC audio path, and wake-word sensitivity are all adapted/assumed, not measured (see the itemized list in that directory's README). Separately, `media_player_entity_id` and `weather_entity_id` are placeholders — which media player this unit should mirror needs a decision if the household ends up with more than one active at a time.
15. **The headless audio endpoint has no real hardware verification on either architecture** (new, Phase 15) — neither the arm64 (`rpi-image-gen`) nor amd64 (`live-build`) pipeline has been run to completion, let alone flashed and booted. rpi-image-gen's exact config/layer YAML schema in particular was written from documentation only, with no ARM build environment available to validate it against the real tool — see `hosts/audio-endpoint/README.md`'s prominent risk callout. Also still open: whether `spotifyd` or `librespot` actually installs cleanly from current bookworm apt sources at all (the fallback list is documented, not exercised), and how stable USB DAC/amp ALSA enumeration turns out to be on a real amd64 mini PC.

View File

@ -0,0 +1,184 @@
# 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](https://github.com/raspberrypi/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 `hosts/thin-client/scripts/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](https://github.com/dtcooper/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:
```sh
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
```sh
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
```sh
cd hosts/audio-endpoint/live-build-amd64/scripts
sudo ./build-audio-endpoint-iso-amd64.sh
```
Same shape as `hosts/thin-client/scripts/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:
```sh
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.

View File

@ -0,0 +1,45 @@
#!/bin/sh
# Starts whichever Spotify Connect backend actually got installed on this image —
# librespot or spotifyd. Installed to /usr/local/bin/spotify-connect-start,
# invoked by spotify-connect.service. Exactly one of these binaries exists on any
# given image (see the apt-first/documented-fallback install logic in both build
# pipelines' hooks, mirroring hosts/thin-client's own
# 0500-spotify-connect.hook.chroot); this wrapper is what lets the systemd unit
# stay one static file regardless of which one it turned out to be.
#
# DEVICE NAME: always $(hostname), read fresh at every start, never baked in.
# Per-room identity is set differently on each variant — Raspberry Pi Imager's OS
# Customisation (arm64, per-flash) or IMAGE_HOSTNAME at build time (amd64,
# per-build) — see hosts/audio-endpoint/README.md's "Per-room identity" sections.
# Reading the hostname here rather than a baked-in name is what lets one shared
# unit/wrapper work correctly either way.
#
# ALSA DEVICE: ${ALSA_DEVICE:-default}. The arm64 image sets this to a fixed
# `hw:0` via a systemd drop-in (safe once onboard audio is disabled and the
# HiFiBerry is the only card left — see the hifiberry-amp2 config.txt fragment).
# The amd64 image ships with no override at all — a generic mini PC's USB DAC
# enumeration index isn't something this repo can know in advance (it may also
# have onboard audio competing for card 0, and which one wins varies by board),
# so 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). See hosts/audio-endpoint/README.md's amd64
# section for the one-time `aplay -l` + systemd drop-in step this leaves for
# whoever sets up that specific box.
set -eu
NAME="$(hostname)"
DEVICE="${ALSA_DEVICE:-default}"
if command -v librespot >/dev/null 2>&1; then
exec librespot --name "$NAME" --backend alsa --device "$DEVICE" \
--bitrate 320 --initial-volume 100 --device-type speaker
fi
if command -v spotifyd >/dev/null 2>&1; then
exec spotifyd --no-daemon --device-name "$NAME" --backend alsa \
--device "$DEVICE" --bitrate 320 --initial-volume 100
fi
echo "spotify-connect-start: neither librespot nor spotifyd is installed on this" >&2
echo " image — see hosts/audio-endpoint/README.md's Spotify Connect install section." >&2
exit 1

View File

@ -0,0 +1,31 @@
# Spotify Connect receiver — shared between both audio-endpoint image variants
# (arm64/Raspberry Pi and amd64/mini PC). Installed to
# /etc/systemd/system/spotify-connect.service by each variant's build
# tooling — see hosts/audio-endpoint/README.md.
#
# Deliberately architecture-independent and backend-independent: ExecStart points
# at the shared spotify-connect-start wrapper (also in this directory) rather than
# hardcoding librespot or spotifyd directly, since which one is actually present
# depends on the apt-first/documented-fallback install logic each variant's own
# hook runs (mirroring hosts/thin-client's 0500-spotify-connect.hook.chroot) — one
# static unit works regardless of which backend that resolved to.
[Unit]
Description=Spotify Connect receiver (headless audio endpoint)
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/spotify-connect-start
Restart=always
RestartSec=5
User=spotify-connect
Group=audio
SupplementaryGroups=audio
# No filesystem access needed beyond ALSA device nodes and the network.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,38 @@
#!/bin/sh
# Installs a Spotify Connect backend + enables the shared spotify-connect.service
# unit into the audio-endpoint image (amd64). The only hook this image needs,
# hence 0100 — mirrors the numbering convention (and, below, the exact
# apt-first/documented-fallback logic and package order) of
# hosts/thin-client/live-build/config/hooks/normal/0500-spotify-connect.hook.chroot.
# Not a divergent reimplementation of that hook's reasoning, the same one reused.
set -eu
INSTALLED=""
for pkg in spotifyd librespot; do
if apt-get install -y --no-install-recommends "$pkg" 2>/dev/null; then
INSTALLED="$pkg"
break
fi
done
if [ -z "$INSTALLED" ]; then
echo "0100-spotify-connect: neither spotifyd nor librespot is available from the"
echo " configured apt sources. Spotify Connect will NOT work on this image."
echo " See hosts/audio-endpoint/README.md's Spotify Connect install section for"
echo " the manual fallback routes (a release binary, a cargo build, raspotify,"
echo " or a third-party apt repo) — same options documented for the arm64 image."
exit 0
fi
echo "0100-spotify-connect: installed ${INSTALLED} from apt."
# Dedicated unprivileged system account — see spotify-connect.service's own
# User=/Group= lines. --no-create-home: this account never needs a home
# directory, just membership in 'audio' for ALSA device access.
if ! id spotify-connect >/dev/null 2>&1; then
useradd --system --no-create-home --gid audio spotify-connect
fi
systemctl enable spotify-connect.service 2>/dev/null || \
echo "0100-spotify-connect: could not enable spotify-connect.service — check it landed at /etc/systemd/system/ via includes.chroot."

View File

@ -0,0 +1,33 @@
# Headless audio-endpoint package list (amd64, live-build .list.chroot format:
# one package per line). No graphical/kiosk stack at all — this boots straight
# to multi-user.target, unlike hosts/thin-client's image.
# --- Audio ---
# Plain ALSA, not PipeWire — see hosts/audio-endpoint/README.md's "Audio
# output" section for why this is deliberately different from the thin
# client's PipeWire/WirePlumber stack.
alsa-utils
# --- Networking (same reasoning as thin-client's own list: wired-preferred
# but some rooms will be WiFi-only) ---
network-manager
# --- Out-of-band admin ---
openssh-server
sudo
# --- Misc ---
ca-certificates
curl
openssl
less
vim-tiny
# NOTE: the actual Spotify Connect backend (spotifyd or librespot) is
# deliberately NOT listed here — installed via
# config/hooks/normal/0100-spotify-connect.hook.chroot's own apt-first/
# documented-fallback logic, mirroring hosts/thin-client/live-build/config/
# hooks/normal/0500-spotify-connect.hook.chroot exactly. Neither package is in
# Debian bookworm main, so a static list line here would silently fail the
# whole build the day it's unavailable rather than degrading to a documented
# placeholder the way the hook does.

View File

@ -0,0 +1,142 @@
#!/usr/bin/env bash
#
# Builds the amd64 headless audio-endpoint ISO (mini PC + USB DAC/amp) with
# live-build — the same tool hosts/thin-client/scripts/build-thin-client-iso.sh
# drives, reused here rather than a new toolchain, per
# hosts/audio-endpoint/README.md's reasoning. Unlike that image, this one has
# no graphical/kiosk stack at all: it boots straight to multi-user.target with
# the shared spotify-connect.service running.
#
# One build per room: unlike the arm64 image (one generic .img, per-room
# identity set later via Raspberry Pi Imager), there is no equivalent
# post-build customisation tool for a generic x86 ISO, so IMAGE_HOSTNAME below
# is baked in at build time — same convention as the thin client's own
# THINCLIENT_NAME/IMAGE_HOSTNAME. Re-run this script once per room, changing
# IMAGE_HOSTNAME each time.
set -euo pipefail
# ---------------------------------------------------------------------------
# CONFIGURATION — edit before running, then re-run per room
# ---------------------------------------------------------------------------
DEBIAN_RELEASE="bookworm" # Matches the container/thin-client hosts' OS
IMAGE_HOSTNAME="audio-endpoint-livingroom" # <-- EDIT per room; also the Spotify
# Connect device name (see README)
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIVE_BUILD_AMD64_DIR="$(dirname "$SCRIPT_DIR")"
AUDIO_ENDPOINT_DIR="$(dirname "$LIVE_BUILD_AMD64_DIR")"
SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
LIVE_BUILD_DIR="$LIVE_BUILD_AMD64_DIR"
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/audio-endpoint.list.chroot"
# ---------------------------------------------------------------------------
# Sanity checks
# ---------------------------------------------------------------------------
if [[ $EUID -ne 0 ]]; then
echo "Warning: not running as root. 'lb build' needs root to bootstrap and chroot,"
echo " and will fail partway through. Re-run with: sudo $0"
fi
if ! command -v lb &> /dev/null; then
if [[ $EUID -eq 0 ]]; then
echo "--- Installing live-build ---"
apt-get update
apt-get install -y live-build
else
echo "live-build is not installed and this script is not running as root." >&2
echo " Install it first: sudo apt-get install live-build" >&2
exit 1
fi
fi
if [[ ! -f "$PACKAGE_LIST" ]]; then
echo "Missing package list: $PACKAGE_LIST" >&2
exit 1
fi
for f in spotify-connect.service spotify-connect-start; do
if [[ ! -f "$SHARED_CONFIGS_DIR/$f" ]]; then
echo "Missing $SHARED_CONFIGS_DIR/$f — is this script running from a full checkout?" >&2
exit 1
fi
done
# ---------------------------------------------------------------------------
# 1. Regenerate includes.chroot from the shared configs/
# ---------------------------------------------------------------------------
echo "--- Regenerating $INCLUDES ---"
rm -rf "$INCLUDES"
install -d \
"$INCLUDES/etc/systemd/system" \
"$INCLUDES/usr/local/bin"
install -m 0644 "${SHARED_CONFIGS_DIR}/spotify-connect.service" \
"$INCLUDES/etc/systemd/system/spotify-connect.service"
install -m 0755 "${SHARED_CONFIGS_DIR}/spotify-connect-start" \
"$INCLUDES/usr/local/bin/spotify-connect-start"
# ---------------------------------------------------------------------------
# 2. lb config
# ---------------------------------------------------------------------------
cd "$LIVE_BUILD_DIR"
chmod +x config/hooks/normal/*.hook.chroot
if [[ -e .build ]]; then
echo "--- Previous build found, running 'lb clean' (package cache is kept) ---"
lb clean
fi
echo "--- Running lb config ---"
# live-build auto-discovers config/package-lists/*.list.chroot and
# config/hooks/normal/*.hook.chroot; there is no flag to point at them
# individually — same convention as hosts/thin-client's own build script.
lb config \
--distribution "$DEBIAN_RELEASE" \
--architectures amd64 \
--linux-flavours amd64 \
--archive-areas "main contrib non-free-firmware" \
--binary-images iso-hybrid \
--debian-installer none \
--iso-application "SmartestHome Audio Endpoint" \
--iso-publisher "SmartestHome" \
--iso-volume "smarthome-audio-endpoint" \
--memtest none \
--bootappend-live "boot=live components quiet hostname=${IMAGE_HOSTNAME}"
# ---------------------------------------------------------------------------
# 3. lb build
# ---------------------------------------------------------------------------
echo "--- Running lb build (this takes a while and needs network) ---"
lb build
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
echo
echo "=== Done ==="
echo "ISO written to:"
echo " ${ISO_PATH}"
echo
echo "What's in it:"
echo " Hostname / Spotify Connect device name: ${IMAGE_HOSTNAME}"
echo " No graphical stack — boots straight to multi-user.target"
echo " spotify-connect.service — see hosts/audio-endpoint/README.md"
echo
echo "Next steps:"
echo " 1. Write the ISO to a USB stick (dd, Rufus, Balena Etcher, whatever you"
echo " normally use) and leave it live-booting from that stick, same as the"
echo " thin client — this image does not install itself to disk."
echo " 2. Attach the USB DAC/amp and passive speakers, connect Ethernet or"
echo " configure Wi-Fi via nmtui after first boot (no display attached, so"
echo " do this over SSH — openssh-server is enabled)."
echo " 3. First boot checklist (see README.md for the full unverified list):"
echo " systemctl status spotify-connect"
echo " aplay -l # find the USB DAC's card index — see README's amd64 section"
echo " # confirm '${IMAGE_HOSTNAME}' shows up as a Spotify Connect device"
echo " 4. Building for another room? Edit IMAGE_HOSTNAME at the top of this"
echo " script and re-run — each room needs its own build."

View File

@ -0,0 +1,14 @@
# HiFiBerry Amp2 — appended to /boot/firmware/config.txt (current Raspberry Pi OS
# location, not the old /boot/config.txt) by the arm64 build tooling.
#
# dtoverlay value is from HiFiBerry's own current docs, grouped under "DAC+
# Standard/Amp2/Amp4" — still worth confirming against your specific board
# revision before first boot, see hosts/audio-endpoint/README.md.
dtoverlay=hifiberry-dacplus
# Disables the Pi's onboard audio so the HiFiBerry becomes the only ALSA card —
# and therefore predictably `hw:0`, which is what spotify-connect.service.d/
# 10-alsa-device.conf pins ALSA_DEVICE to. Without this line, onboard audio and
# the HiFiBerry would both be present and enumeration order would decide which
# one is card 0.
dtparam=audio=off

View File

@ -0,0 +1,62 @@
# rpi-image-gen build config for the headless audio-endpoint image (arm64).
#
# UNVERIFIED AGAINST THE REAL TOOL — see hosts/audio-endpoint/README.md's
# prominent callout. rpi-image-gen's exact layer/config YAML schema was not
# hands-on validated while writing this (no ARM build environment or the tool
# itself available). This is a best-effort, structurally sound attempt based on
# rpi-image-gen's documented config/layer/device/image directory structure and
# `rpi-image-gen build -c <config>.yaml` invocation. If a real build rejects
# this file:
# rpi-image-gen layer --list
# rpi-image-gen layer --describe <name>
# on the actual build host is the authoritative source — adjust the *keys*
# below to match what that reports. The intent behind each section (device
# target, packages, files to inject, which service to enable, and the
# build-time hook) should carry over even if the exact key names don't.
image:
name: audio-endpoint
arch: arm64
device:
# Any 40-pin-header Raspberry Pi works — Zero 2 W is the cheapest that's
# enough for a single Spotify Connect stream. Change if targeting a
# different specific model.
board: raspberrypi-zero2w
os: bookworm
# Base packages the image needs regardless of which Spotify Connect backend
# ends up installed (see scripts/install-spotify-connect.sh, run via the
# hook below) — alsa-utils for the userspace ALSA tools, no PipeWire/desktop
# stack at all (see hosts/audio-endpoint/README.md's "Audio output" section).
packages:
- alsa-utils
# Files copied into the target rootfs. Paths are relative to this config file.
files:
- src: ../../configs/spotify-connect.service
dst: /etc/systemd/system/spotify-connect.service
- src: ../../configs/spotify-connect-start
dst: /usr/local/bin/spotify-connect-start
mode: "0755"
- src: ../systemd/spotify-connect.service.d/10-alsa-device.conf
dst: /etc/systemd/system/spotify-connect.service.d/10-alsa-device.conf
- src: ../config.txt.d/hifiberry-amp2.txt
dst: /boot/firmware/config.txt
append: true
# Runs chrooted into the target rootfs at build time — the apt-first/
# documented-fallback Spotify Connect install (see that script's own header)
# plus enabling the resulting unit. This is the single most likely key name
# to need adjusting against the real tool's hook mechanism.
hooks:
chroot:
- ../scripts/install-spotify-connect.sh
# Left at the tool's own default — Raspberry Pi Imager's OS Customisation
# dialog overrides this per physical unit after the image is built (see
# hosts/audio-endpoint/README.md's "Per-room identity" section). Do not
# hardcode a room name here; spotify-connect-start reads $(hostname) at
# service-start time, whatever it ends up being set to per unit.
hostname:
set: audio-endpoint

View File

@ -0,0 +1,91 @@
#!/usr/bin/env bash
#
# Builds the arm64 headless audio-endpoint image (Raspberry Pi + HiFiBerry
# Amp2) with rpi-image-gen. Read hosts/audio-endpoint/README.md before running
# this — in particular the callout that rpi-image-gen's exact config/layer
# schema (config/audio-endpoint.yaml) was not hands-on validated while writing
# this script; a real build may need that file adjusted first.
#
# Output: a real bootable .img. Each physical unit then gets its own
# hostname/Wi-Fi via Raspberry Pi Imager's OS Customisation dialog when
# flashing — see the README's "Per-room identity" section. This script builds
# ONE generic image, not one per room.
set -euo pipefail
# ---------------------------------------------------------------------------
# CONFIGURATION — edit if needed
# ---------------------------------------------------------------------------
RPI_IMAGE_GEN_SRC="${RPI_IMAGE_GEN_SRC:-/opt/smart-home/src/rpi-image-gen}"
RPI_IMAGE_GEN_REPO="https://github.com/raspberrypi/rpi-image-gen.git"
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RPI_IMAGE_GEN_DIR="$(dirname "$SCRIPT_DIR")"
AUDIO_ENDPOINT_DIR="$(dirname "$RPI_IMAGE_GEN_DIR")"
SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
BUILD_CONFIG="$RPI_IMAGE_GEN_DIR/config/audio-endpoint.yaml"
# ---------------------------------------------------------------------------
# Sanity checks
# ---------------------------------------------------------------------------
for f in spotify-connect.service spotify-connect-start; do
if [ ! -f "$SHARED_CONFIGS_DIR/$f" ]; then
echo "Missing $SHARED_CONFIGS_DIR/$f — is this script running from a full checkout?" >&2
exit 1
fi
done
if [ ! -f "$BUILD_CONFIG" ]; then
echo "Missing $BUILD_CONFIG" >&2
exit 1
fi
if ! command -v git >/dev/null 2>&1; then
echo "git is required to fetch rpi-image-gen. Install it first: sudo apt-get install git" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# 1. Fetch rpi-image-gen if not already present
# ---------------------------------------------------------------------------
if [ ! -d "$RPI_IMAGE_GEN_SRC" ]; then
echo "--- Cloning rpi-image-gen into $RPI_IMAGE_GEN_SRC ---"
git clone --depth 1 "$RPI_IMAGE_GEN_REPO" "$RPI_IMAGE_GEN_SRC"
else
echo "--- rpi-image-gen already present at $RPI_IMAGE_GEN_SRC, skipping clone ---"
fi
# ---------------------------------------------------------------------------
# 2. Build
# ---------------------------------------------------------------------------
echo "--- Running rpi-image-gen build ---"
echo " Config: $BUILD_CONFIG"
cd "$RPI_IMAGE_GEN_SRC"
# UNVERIFIED: assumes a ./rpi-image-gen entry point at the repo root, matching
# common conventions for this class of tool (e.g. pi-gen's own build.sh). If
# the real tool exposes a different entry point (a pip-installed console
# script, a Python module, etc.), fix this one line — everything else in this
# script and config/audio-endpoint.yaml is unaffected either way.
./rpi-image-gen build -c "$BUILD_CONFIG"
echo
echo "=== Done (if rpi-image-gen's invocation above matched the real tool) ==="
echo "Image should be under $RPI_IMAGE_GEN_SRC/work/ — check rpi-image-gen's own"
echo "build output above for the exact path; this wrapper does not parse it."
echo
echo "Next steps:"
echo " 1. Flash with Raspberry Pi Imager (GUI) or:"
echo " rpi-imager --cli <path-to-image>.img /dev/<sd-card-device>"
echo " 2. BEFORE writing, use Imager's OS Customisation (gear icon / Ctrl+Shift+X)"
echo " to set THIS unit's hostname (= its Spotify Connect device name — see"
echo " the README's 'Per-room identity' section), Wi-Fi SSID/password, and"
echo " an SSH key if you want remote access. This is per physical unit, not"
echo " per build — the same .img is reused for every room."
echo " 3. Attach the HiFiBerry Amp2 HAT and passive speakers before first boot."
echo " 4. First boot checklist (see README.md for the full unverified list):"
echo " systemctl status spotify-connect"
echo " # confirm the room's hostname shows up as a Spotify Connect device"
echo " aplay -l # confirm the HiFiBerry is card 0 (onboard audio disabled)"

View File

@ -0,0 +1,54 @@
#!/bin/sh
# Installs a Spotify Connect backend into the audio-endpoint image (arm64).
# Intended to run chrooted into the target rootfs during the rpi-image-gen
# build — see config/audio-endpoint.yaml's script-hook reference. The exact
# rpi-image-gen integration-point key for "run this chrooted at build time" is
# one of the things flagged unverified in hosts/audio-endpoint/README.md;
# adjust *that wiring* if the tool's actual hook mechanism differs, not this
# script's own logic.
#
# Mirrors hosts/thin-client/live-build/config/hooks/normal/
# 0500-spotify-connect.hook.chroot's exact reasoning and package order: neither
# spotifyd nor librespot is in Debian bookworm main, so try apt first (in case
# a backport/third-party repo has been added) and otherwise stop at a
# documented placeholder — never hardcode a release URL that would silently
# 404 later.
set -eu
INSTALLED=""
for pkg in spotifyd librespot; do
if apt-get install -y --no-install-recommends "$pkg" 2>/dev/null; then
INSTALLED="$pkg"
break
fi
done
if [ -z "$INSTALLED" ]; then
echo "install-spotify-connect: neither spotifyd nor librespot is available from"
echo " the configured apt sources. Spotify Connect will NOT work on this image."
echo " To add it, pick one and re-run the build with the step filled in here:"
echo " a) fetch a release binary from"
echo " https://github.com/librespot-org/librespot/releases (check the"
echo " current tag yourself) into /usr/bin/librespot, or"
echo " b) cargo-build librespot in this hook, or"
echo " c) use raspotify instead (https://github.com/dtcooper/raspotify) — it"
echo " wraps librespot with its own packaging and systemd unit; if you use"
echo " it, drop this repo's spotify-connect.service/spotify-connect-start"
echo " in favour of its own unit rather than layering both, or"
echo " d) add a third-party apt repo that carries one of the above."
echo " Spotify Premium is required either way."
exit 0
fi
echo "install-spotify-connect: installed ${INSTALLED} from apt."
# Dedicated unprivileged system account — see spotify-connect.service's own
# User=/Group= lines. --no-create-home: this account never needs a home
# directory, just membership in 'audio' for ALSA device access.
if ! id spotify-connect >/dev/null 2>&1; then
useradd --system --no-create-home --gid audio spotify-connect
fi
systemctl enable spotify-connect.service 2>/dev/null || \
echo "install-spotify-connect: could not enable spotify-connect.service — check it landed at /etc/systemd/system/ (see config/audio-endpoint.yaml's file list)."

View File

@ -0,0 +1,8 @@
# arm64-specific override for the shared spotify-connect.service (see
# hosts/audio-endpoint/configs/). The HiFiBerry Amp2 is the only ALSA card once
# onboard audio is disabled (config.txt.d/hifiberry-amp2.txt), so hw:0 is safe
# and deterministic here. The amd64 image ships with no equivalent override —
# a generic mini PC's USB DAC enumeration index isn't knowable in advance, see
# hosts/audio-endpoint/README.md's amd64 section for that one-time manual step.
[Service]
Environment=ALSA_DEVICE=hw:0

View File

@ -15,6 +15,7 @@ What ends up on the image:
| Browser | Firefox ESR in kiosk mode on the digest workspace, pointed at `digest-web` |
| Media | mpv + mpv-mpris, playerctl, PipeWire/WirePlumber |
| Capture-card input ("receiver box") | Any USB/PCIe HDMI capture card plugged into the box, selectable in HA, shown via mpv on `5:capture` — see below |
| Idle-gallery overlay | Clock/date always, weather once `smarthome/weather/current` is published — see below |
| Game streaming | Steam Link (Flathub flatpak) forced onto Xwayland |
| Voice | wyoming-satellite + openWakeWord — **opt-in, off by default** |
| Gesture control | Camera hand tracking (MediaPipe) — **opt-in, off by default, see below** |
@ -207,6 +208,56 @@ project has no way to push a secret from one machine to the other, so both sides
set by hand from the same value. Also fill in `GALLERY_SMB_HOST` in
`build-thin-client-iso.sh` (the container host's LAN IP) before rebuilding.
### Weather/clock overlay
The slideshow gets a text overlay in the top-right corner: time and date always (no
network needed — it's just the box's own clock), plus temperature/condition/location
once that data exists (see below). It never covers the whole panel — a solid card
over someone's photo would defeat the point of a slideshow — just on-screen text with
a shadow, photo-frame style. `idle-gallery.sh` owns showing and hiding it, exactly
when its own slideshow is running; it never appears anywhere else (Steam Link, the
digest/admin canvases, capture-card viewing, or an ordinary video).
Time/date come from `date`(1) via eww's `defpoll` — nothing to configure. Weather
does **not exist yet on a stock image** — nothing in this repo publishes to it, same
"needs a real decision, not built against a guess" rule as the digest's household/
calendar sourcing and the ESP32 voice-display firmware's `weather_entity_id`. It
reads one retained MQTT topic, `smarthome/weather/current`, a small JSON object:
```json
{"temperature": "8°C", "condition": "Partly cloudy", "location": "Vienna"}
```
Household-wide, not per-thin-client (every thin client's overlay shows the same
reading) — same reasoning as `smarthome/digest/viewed` in
`thinclient_agent/main.py`: weather is one household-wide fact, not something scoped
to whichever room asked. Wire it up with a small Home Assistant automation (nothing
under this repo builds the HA side, same convention as everywhere else):
```yaml
automation:
- alias: "Publish weather to thin clients"
trigger:
- platform: state
entity_id: weather.home # your real weather entity
action:
- service: mqtt.publish
data:
topic: smarthome/weather/current
retain: true
payload: >-
{
"temperature": "{{ state_attr('weather.home', 'temperature') }}°C",
"condition": "{{ states('weather.home') }}",
"location": "Vienna"
}
```
Until that automation exists (or while MQTT is unreachable), the overlay just shows
the clock — `configs/eww/weather-json` degrades to `{"available": false}` rather than
blocking or showing stale data, and the weather line simply doesn't render (see
`eww.yuck`'s `:visible {weatherinfo.available}`).
## Capture-card / receiver-box viewing
Any USB or PCIe HDMI/AV capture card plugged into this machine can be picked as a
@ -451,3 +502,10 @@ None of this has been run on hardware. In rough order:
whatever specific chipset a future tester's dongle happens to use — the
logic is chipset-agnostic in design but only reasoned about, not run
against real `v4l2-ctl` output from more than one card model.
21. Weather/clock overlay — the `date`(1)-driven clock/date needs no verification
beyond confirming the box's own timezone is right, but the MQTT half is
untested against a real Home Assistant automation: whether `weather-json`
reconnects cleanly after the container host (and therefore Mosquitto) comes
back up from being powered off, and whether the overlay's text is legible
against a bright/high-contrast real photo rather than the dark backgrounds
assumed while choosing the text-shadow-only styling in `eww.scss`.

View File

@ -1,5 +1,5 @@
// Styling for the now-playing widget. Installed to
// /home/<KIOSK_USERNAME>/.config/eww/eww.scss by build-thin-client-iso.sh.
// Styling for the now-playing and idle-gallery weather-overlay widgets. Installed
// to /home/<KIOSK_USERNAME>/.config/eww/eww.scss by build-thin-client-iso.sh.
//
// eww compiles SCSS itself (bundled grass), so nothing on the image needs a Sass
// toolchain. eww.scss rather than eww.css because every eww release supports the
@ -48,3 +48,40 @@ $muted: #9a9aa6;
border-radius: 6px;
}
}
// Idle-gallery weather/clock overlay. Text-shadow rather than a background card
// a solid panel over someone's photo would fight the point of a photo slideshow, so
// this reads like on-screen text (photo-frame convention) instead of a floating box.
.wo-root {
color: $fg;
font-family: sans-serif;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 12px rgba(0, 0, 0, 0.6);
}
.wo-time {
font-size: 46px;
font-weight: 700;
color: $fg;
}
.wo-date {
font-size: 16px;
font-weight: 500;
color: $muted;
}
.wo-weather {
margin-top: 6px;
}
.wo-location,
.wo-temp,
.wo-condition {
font-size: 15px;
color: $fg;
}
.wo-sep {
font-size: 15px;
color: $muted;
}

View File

@ -1,13 +1,15 @@
;; Now-playing widget for the thin client. Installed to
;; Two on-screen widgets for the thin client: now-playing and the idle-gallery
;; weather/clock overlay. Installed to
;; /home/<KIOSK_USERNAME>/.config/eww/eww.yuck by build-thin-client-iso.sh.
;;
;; Data source: /usr/local/bin/now-playing-json, a separate `playerctl --follow`
;; process, NOT thinclient_agent's MprisBridge. The agent is a *system* service that
;; deliberately outlives the compositor, whereas this widget is session-scoped and dies
;; with sway; wiring the widget to the agent would mean either restarting the agent
;; whenever the widget restarts, or opening a second inbound control channel into it,
;; which the security boundary in mqtt_discovery.py rules out. The cost is one extra
;; playerctl subscription, which is negligible.
;; Data sources are separate `deflisten`/`defpoll` processes, NOT thinclient_agent —
;; now-playing uses /usr/local/bin/now-playing-json (`playerctl --follow`), the
;; weather overlay uses /usr/local/bin/weather-json (MQTT). The agent is a *system*
;; service that deliberately outlives the compositor, whereas both widgets are
;; session-scoped and die with sway; wiring either to the agent would mean either
;; restarting the agent whenever a widget restarts, or opening a second inbound
;; control channel into it, which the security boundary in mqtt_discovery.py rules
;; out. The cost is one extra subscription each, which is negligible.
;;
;; The :onclick strings below are fixed constants in this file. Nothing from MQTT, HA,
;; or player metadata is ever interpolated into them.
@ -52,3 +54,49 @@
:exclusive false
:focusable false
(np-root))
;; --- Idle-gallery weather/clock overlay --------------------------------------
;;
;; Time/date/weekday are computed locally (no Home Assistant round-trip needed for
;; those — the box already has a clock) via `date`(1); only temperature/condition/
;; location come from weather-json's MQTT subscription, see that script's own
;; docstring for the smarthome/weather/current data contract. A missing/never-
;; published weather reading degrades to just hiding the weather line — the clock
;; and date keep showing regardless, same "never blank the whole thing over one
;; missing part" rule this repo applies everywhere (digest render.js, admin-canvas
;; render.js, idle-gallery.sh's own fallback chain).
(defpoll clock_time :interval "20s" :initial "--:--" "date +'%H:%M'")
(defpoll clock_weekday :interval "60s" :initial "" "date +'%A'")
(defpoll clock_date :interval "60s" :initial "" "date +'%-d %B'")
(deflisten weatherinfo
:initial '{"available":false,"temperature":"","condition":"","location":""}'
"/usr/local/bin/weather-json")
(defwidget weather-overlay-root []
(box :class "wo-root" :orientation "vertical" :space-evenly false :halign "end"
(label :class "wo-time" :text clock_time)
(label :class "wo-date" :text "${clock_weekday}, ${clock_date}")
(box :class "wo-weather" :orientation "horizontal" :space-evenly false :halign "end"
:visible {weatherinfo.available}
(label :class "wo-location" :text weatherinfo.location)
(label :class "wo-sep" :text " · " :visible {weatherinfo.location != ""})
(label :class "wo-temp" :text weatherinfo.temperature)
(label :class "wo-sep" :text " · ")
(label :class "wo-condition" :text weatherinfo.condition))))
;; Same :exclusive false/:stacking "overlay" shape as now-playing, and the same
;; ownership split: visibility is entirely owned by idle-gallery.sh, which is the
;; one thing that actually knows when its own slideshow is the thing on screen —
;; opened right before the slideshow's mpv is launched, closed in stop_slideshow().
;; No :visible gate at the window level the way now-playing has one, because there
;; is no "open but should still hide" sub-case here: once idle-gallery owns the
;; screen, showing at least the clock is always correct.
(defwindow weather-overlay
:monitor 0
:geometry (geometry :x "32px" :y "32px" :anchor "top right"
:width "440px" :height "140px")
:stacking "overlay"
:exclusive false
:focusable false
(weather-overlay-root))

View File

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Feeds the eww weather-overlay widget one JSON object per change on
`smarthome/weather/current`. Installed to /usr/local/bin/weather-json, consumed
by eww's `deflisten` (see configs/eww/eww.yuck).
Session-scoped and independent of thinclient_agent — same reasoning as
now-playing-json: this dies with sway, thinclient_agent is a system service that
must survive it, and wiring the two together would mean either a second inbound
control channel into the agent (which mqtt_discovery.py's security boundary
rules out) or restarting the agent whenever this widget restarts. MQTT
credentials are just re-read from the same config.env thinclient_agent itself
reads; this script never publishes anything and never touches that file.
DATA CONTRACT: `smarthome/weather/current`, retained, a small JSON object
`{"temperature": "...", "condition": "...", "location": "..."}` — published by a
Home Assistant automation this repo does not build, see
hosts/thin-client/README.md's "Idle-gallery weather overlay" section for the
worked example. Household-wide, not per-thin-client, same reasoning as
thinclient_agent/main.py's DIGEST_VIEWED_TOPIC: weather is one household-wide
fact, not something scoped to whichever room happens to be asking.
Degrades to {"available": false} — the clock/date still show, only the weather
line disappears, see eww.yuck — whenever MQTT is unreachable or nothing has ever
been published to the topic yet. Never blocks the rest of the widget on it.
"""
from __future__ import annotations
import json
import os
import sys
import paho.mqtt.client as mqtt
CONFIG_PATH = os.environ.get("THINCLIENT_AGENT_CONFIG", "/etc/thinclient-agent/config.env")
TOPIC = "smarthome/weather/current"
def load_config(path: str) -> dict[str, str]:
values: dict[str, str] = {}
try:
with open(path, encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip('"').strip("'")
except OSError:
pass
return values
def emit(state: dict) -> None:
sys.stdout.write(json.dumps(state) + "\n")
sys.stdout.flush()
def unavailable() -> dict:
return {"available": False, "temperature": "", "condition": "", "location": ""}
def parse(payload: bytes) -> dict:
try:
data = json.loads(payload.decode("utf-8", "replace"))
except ValueError:
return unavailable()
if not isinstance(data, dict):
return unavailable()
return {
"available": True,
"temperature": str(data.get("temperature") or ""),
"condition": str(data.get("condition") or ""),
"location": str(data.get("location") or ""),
}
def make_client() -> mqtt.Client:
callback_api = getattr(mqtt, "CallbackAPIVersion", None)
if callback_api is not None:
return mqtt.Client(callback_api.VERSION1, client_id="weather-json")
return mqtt.Client(client_id="weather-json")
def main() -> int:
config = load_config(CONFIG_PATH)
broker_host = config.get("MQTT_BROKER_HOST", "")
if not broker_host:
# No broker configured at all — emit once and idle rather than spin
# retrying a connection that was never going to happen.
emit(unavailable())
return 0
broker_port = int(config.get("MQTT_BROKER_PORT") or 1883)
client = make_client()
if config.get("MQTT_USERNAME"):
client.username_pw_set(config["MQTT_USERNAME"], config.get("MQTT_PASSWORD") or None)
def on_connect(_client, _userdata, _flags, rc):
if rc == 0:
client.subscribe(TOPIC, qos=1)
else:
emit(unavailable())
def on_disconnect(_client, _userdata, rc):
emit(unavailable())
def on_message(_client, _userdata, message):
emit(parse(message.payload))
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
emit(unavailable())
# connect_async, never a blocking connect(): a thin client with the container
# host powered off must still show its clock, same "never stall the session
# on a remote service" rule Phase 11.10 applies everywhere else on this image.
client.connect_async(broker_host, broker_port, keepalive=60)
client.loop_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -27,8 +27,26 @@ blank() {
swaymsg "output * power off" >/dev/null 2>&1 || true
}
# Weather/clock overlay (configs/eww/eww.yuck's weather-overlay window) — this
# script is the sole owner of its visibility, the same way fullscreen-watcher.sh
# owns the now-playing widget's. Best-effort throughout: an image built without
# eww (0800-eww-widget.hook.chroot's documented placeholder path) or a session
# where the daemon has not started yet must never turn a slideshow failure into a
# script failure, so every step here is allowed to silently no-op.
show_weather_overlay() {
command -v eww >/dev/null 2>&1 || return 0
eww daemon >/dev/null 2>&1 || true
eww open weather-overlay >/dev/null 2>&1 || true
}
hide_weather_overlay() {
command -v eww >/dev/null 2>&1 || return 0
eww close weather-overlay >/dev/null 2>&1 || true
}
stop_slideshow() {
pkill -u "$(id -u)" -f "mpv .*${MARKER}" 2>/dev/null || true
hide_weather_overlay
}
if [ "${1:-start}" = "stop" ]; then
@ -93,3 +111,8 @@ mpv --fs --no-osc --idle=no --image-display-duration="$DISPLAY_SECONDS" \
--loop-playlist=inf --shuffle "--playlist=$PLAYLIST" \
>/tmp/idle-gallery-mpv.log 2>&1 &
disown
# After mpv is actually launched, not before — a failed launch above (this whole
# block is under `set -e`, so mpv itself failing to start would already have exited
# the script) must never leave the overlay showing over a blank/off panel.
show_weather_overlay

View File

@ -239,6 +239,7 @@ install -m 0644 "${CONFIGS_DIR}/eww/eww.yuck" "$INCLUDES/home/${KIOSK_U
install -m 0644 "${CONFIGS_DIR}/eww/eww.scss" "$INCLUDES/home/${KIOSK_USERNAME}/.config/eww/eww.scss"
install -m 0755 "${CONFIGS_DIR}/eww/fullscreen-watcher.sh" "$INCLUDES/usr/local/bin/fullscreen-watcher"
install -m 0755 "${CONFIGS_DIR}/eww/now-playing-json" "$INCLUDES/usr/local/bin/now-playing-json"
install -m 0755 "${CONFIGS_DIR}/eww/weather-json" "$INCLUDES/usr/local/bin/weather-json"
# Firefox: the browsable-window launcher, plus the shared chrome/prefs template both
# it and digest-browser copy into their own (separate) profile directories at launch —
@ -375,12 +376,14 @@ echo " ${ISO_PATH}"
echo
echo "What's in it:"
echo " greetd : autologin as '${KIOSK_USERNAME}' straight into Sway on vt1"
echo " Sway : workspaces 1:web / 2:digest / 3:media, no bars, no lock screen"
echo " Sway : workspaces 1:web / 2:digest / 3:media / 4:admin / 5:capture, no bars, no lock screen"
echo " wayvnc : 0.0.0.0:5900, auth REQUIRED, refuses to start without a password"
echo " thinclient-agent: system service, MQTT ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
echo " Firefox kiosk : ${DIGEST_WEB_URL}/full.html?detail_level=full"
echo " Firefox (general): minimal chrome, uBlock Origin + SponsorBlock preinstalled"
echo " Now-playing : eww widget, hidden while anything visual is on screen"
echo " Weather overlay : eww widget over the idle-gallery slideshow (clock always,"
echo " weather once smarthome/weather/current is published — see README)"
echo " Audio output : HA select, persisted to audio-config.json"
echo " Remote desktop : Remmina (RDP+VNC) out to other machines, configured via rdp-vnc.json"
echo " HA input remote : type text / move+click the pointer via ydotool, no VNC needed"