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_01WgTwMqiho8DT6ue5S49WBPdigest-per-person-and-agendas
parent
1d8b416ab9
commit
bfca1c7eb0
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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."
|
||||
|
|
@ -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.
|
||||
|
|
@ -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."
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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)."
|
||||
|
|
@ -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
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue