diff --git a/.gitignore b/.gitignore index 59c3a4a..6624f5f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 6b8bea3..607c035 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/project-plan.md b/docs/project-plan.md index 95179b0..d1ec2c9 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -90,6 +90,14 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato |---|---|---| | Waveshare ESP32-S3-Touch-LCD-1.85C, **V2 revision** | €35–45 | 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) | €15–35 | arm64 variant | +| HiFiBerry Amp2 HAT | ~€45 | 60W Class D, drives 4–8Ω 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) | €0–130 | 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 11–14) +## 4. Open decisions (Phases 11–15) 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. diff --git a/hosts/audio-endpoint/README.md b/hosts/audio-endpoint/README.md new file mode 100644 index 0000000..909eaf3 --- /dev/null +++ b/hosts/audio-endpoint/README.md @@ -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 4–8Ω 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 +``` + +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. diff --git a/hosts/audio-endpoint/configs/spotify-connect-start b/hosts/audio-endpoint/configs/spotify-connect-start new file mode 100755 index 0000000..b825613 --- /dev/null +++ b/hosts/audio-endpoint/configs/spotify-connect-start @@ -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 diff --git a/hosts/audio-endpoint/configs/spotify-connect.service b/hosts/audio-endpoint/configs/spotify-connect.service new file mode 100644 index 0000000..d019113 --- /dev/null +++ b/hosts/audio-endpoint/configs/spotify-connect.service @@ -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 diff --git a/hosts/audio-endpoint/live-build-amd64/config/hooks/normal/0100-spotify-connect.hook.chroot b/hosts/audio-endpoint/live-build-amd64/config/hooks/normal/0100-spotify-connect.hook.chroot new file mode 100755 index 0000000..47a8894 --- /dev/null +++ b/hosts/audio-endpoint/live-build-amd64/config/hooks/normal/0100-spotify-connect.hook.chroot @@ -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." diff --git a/hosts/audio-endpoint/live-build-amd64/config/package-lists/audio-endpoint.list.chroot b/hosts/audio-endpoint/live-build-amd64/config/package-lists/audio-endpoint.list.chroot new file mode 100644 index 0000000..49a9c0f --- /dev/null +++ b/hosts/audio-endpoint/live-build-amd64/config/package-lists/audio-endpoint.list.chroot @@ -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. diff --git a/hosts/audio-endpoint/live-build-amd64/scripts/build-audio-endpoint-iso-amd64.sh b/hosts/audio-endpoint/live-build-amd64/scripts/build-audio-endpoint-iso-amd64.sh new file mode 100755 index 0000000..7dbab27 --- /dev/null +++ b/hosts/audio-endpoint/live-build-amd64/scripts/build-audio-endpoint-iso-amd64.sh @@ -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." diff --git a/hosts/audio-endpoint/rpi-image-gen/config.txt.d/hifiberry-amp2.txt b/hosts/audio-endpoint/rpi-image-gen/config.txt.d/hifiberry-amp2.txt new file mode 100644 index 0000000..7c55103 --- /dev/null +++ b/hosts/audio-endpoint/rpi-image-gen/config.txt.d/hifiberry-amp2.txt @@ -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 diff --git a/hosts/audio-endpoint/rpi-image-gen/config/audio-endpoint.yaml b/hosts/audio-endpoint/rpi-image-gen/config/audio-endpoint.yaml new file mode 100644 index 0000000..6bb7ba1 --- /dev/null +++ b/hosts/audio-endpoint/rpi-image-gen/config/audio-endpoint.yaml @@ -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 .yaml` invocation. If a real build rejects +# this file: +# rpi-image-gen layer --list +# rpi-image-gen layer --describe +# 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 diff --git a/hosts/audio-endpoint/rpi-image-gen/scripts/build-audio-endpoint-image-arm64.sh b/hosts/audio-endpoint/rpi-image-gen/scripts/build-audio-endpoint-image-arm64.sh new file mode 100755 index 0000000..86864b1 --- /dev/null +++ b/hosts/audio-endpoint/rpi-image-gen/scripts/build-audio-endpoint-image-arm64.sh @@ -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 .img /dev/" +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)" diff --git a/hosts/audio-endpoint/rpi-image-gen/scripts/install-spotify-connect.sh b/hosts/audio-endpoint/rpi-image-gen/scripts/install-spotify-connect.sh new file mode 100755 index 0000000..a55bfc9 --- /dev/null +++ b/hosts/audio-endpoint/rpi-image-gen/scripts/install-spotify-connect.sh @@ -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)." diff --git a/hosts/audio-endpoint/rpi-image-gen/systemd/spotify-connect.service.d/10-alsa-device.conf b/hosts/audio-endpoint/rpi-image-gen/systemd/spotify-connect.service.d/10-alsa-device.conf new file mode 100644 index 0000000..4aca2d5 --- /dev/null +++ b/hosts/audio-endpoint/rpi-image-gen/systemd/spotify-connect.service.d/10-alsa-device.conf @@ -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 diff --git a/hosts/thin-client/README.md b/hosts/thin-client/README.md index 6289f17..b0193d7 100644 --- a/hosts/thin-client/README.md +++ b/hosts/thin-client/README.md @@ -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`. diff --git a/hosts/thin-client/configs/eww/eww.scss b/hosts/thin-client/configs/eww/eww.scss index 2e87329..97f8fae 100644 --- a/hosts/thin-client/configs/eww/eww.scss +++ b/hosts/thin-client/configs/eww/eww.scss @@ -1,5 +1,5 @@ -// Styling for the now-playing widget. Installed to -// /home//.config/eww/eww.scss by build-thin-client-iso.sh. +// Styling for the now-playing and idle-gallery weather-overlay widgets. Installed +// to /home//.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; +} diff --git a/hosts/thin-client/configs/eww/eww.yuck b/hosts/thin-client/configs/eww/eww.yuck index 1bf2407..b81be5b 100644 --- a/hosts/thin-client/configs/eww/eww.yuck +++ b/hosts/thin-client/configs/eww/eww.yuck @@ -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//.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)) diff --git a/hosts/thin-client/configs/eww/weather-json b/hosts/thin-client/configs/eww/weather-json new file mode 100755 index 0000000..5bda161 --- /dev/null +++ b/hosts/thin-client/configs/eww/weather-json @@ -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()) diff --git a/hosts/thin-client/configs/idle-gallery/idle-gallery.sh b/hosts/thin-client/configs/idle-gallery/idle-gallery.sh index 52ac9cc..e95e782 100644 --- a/hosts/thin-client/configs/idle-gallery/idle-gallery.sh +++ b/hosts/thin-client/configs/idle-gallery/idle-gallery.sh @@ -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 diff --git a/hosts/thin-client/scripts/build-thin-client-iso.sh b/hosts/thin-client/scripts/build-thin-client-iso.sh index 0fad51f..ae917b0 100755 --- a/hosts/thin-client/scripts/build-thin-client-iso.sh +++ b/hosts/thin-client/scripts/build-thin-client-iso.sh @@ -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"