Headless Rust gRPC daemon to drive Bluetooth, HLS/DASH playback, and snapcast/shairport-sync/squeezelite on Raspberry Pi audio rigs.
13 kB
308 lines
1# zerod
2[](https://github.com/tsirysndr/zerod/actions/workflows/release.yml)
3
4
5
6Headless audio + Bluetooth + systemd control daemon for Linux audio appliances
7(Raspberry Pi, single-board computers running Snapcast, shairport-sync,
8squeezelite, …). One binary, one gRPC port, one TOML file.
9
10`zerod` is both:
11
12- a **server** — exposes a gRPC API over `tonic` to control BlueZ, play HLS /
13 MPEG-DASH audio streams to a configurable sink (cpal / stdout / pipe), drive
14 systemd units, and remotely read/write a fixed set of config files.
15- a **client CLI** — talks to another `zerod` over the same gRPC API.
16
17Same binary on both ends. Run `zerod` with no arguments and it boots the
18server; run `zerod stream play …` and it acts as a client.
19
20## Status
21
22Early. Pre-1.0, breaking changes expected.
23
24## Features
25
26- **Bluetooth** — scan / list / pair / connect / disconnect / remove over
27 BlueZ (Linux-only via `bluer`). Non-Linux builds compile but return
28 `Unimplemented`.
29- **A2DP sink (Pi-as-speaker)** — registers a BlueZ pairing agent so phones
30 can pair with the daemon, then delegates the audio path to
31 `bluealsa-aplay` via the existing systemd allowlist. Pairing prompts
32 surface as events; resolve them with `RespondPairing` or set
33 `auto_accept_pairings = true` for kiosk mode. Linux-only.
34- **HLS / MPEG-DASH player** — fetch + demux + decode (symphonia) → S16LE PCM.
35 Three sinks selectable per `Play` call:
36 - `cpal` — default audio device (or a named device)
37 - `stdout` — raw interleaved S16LE little-endian PCM on stdout
38 - `pipe` — same, into a named FIFO (auto-reopens on broken pipe)
39- **Spotify Connect** — spawns `librespot --backend pipe` and pipes its
40 S16LE stdout through the same sink as HLS/DASH. The phone picks the
41 daemon's `[librespot].name` from its Devices list. Linux-only; requires
42 `librespot` installed on the device (`apt install librespot`).
43- **Snapcast control** — JSON-RPC 2.0 client to a local `snapserver` over
44 TCP. List/rename clients, move them between groups, set per-client
45 volume and latency. Push notifications from snapserver flow back onto
46 the event bus so external `snapctl` changes don't go invisible.
47- **Systemd control** — start / stop / restart / reload / enable / disable /
48 status via the system D-Bus, restricted to an allowlist. Linux-only.
49- **Volume control** — get / set volume and mute / unmute against any ALSA
50 selem (Master, PCM, …) on any card via `alsa-lib`. Works on bare ALSA and
51 on PipeWire/PulseAudio via ALSA-mixer emulation. Linux-only. Plus a
52 per-stream software gain applied in the player loop for the HLS/DASH
53 session, independent of the system mixer.
54- **Server-streaming events** — `EventsService.Subscribe(kinds)` fans out
55 state transitions (stream playback, BT pairing prompts, BT
56 connect/disconnect, systemd unit state, ALSA volume, snapcast client
57 changes) without polling. Lossy under backpressure — slow subscribers
58 see a `LaggedEvent` rather than a closed stream. `zerod events tail
59 [--filter ...]` prints one JSON line per event.
60- **Remote config edit** — atomic read/write of a fixed set of files
61 (`snapserver.conf`, `shairport-sync.conf`, …), with an optional
62 reload-or-restart of the bound unit on every write.
63- **Auth** — bearer token. Three sources, in order: `zerod.toml`,
64 `ZEROD_BEARER_TOKEN` env var, or a 32-byte random one generated and logged
65 at startup.
66- **mDNS / zeroconf discovery** — the server advertises itself as
67 `_zerod._tcp.local.`, so clients on the same LAN can find it without
68 `--host`. `zerod discover` lists every responder. Pure-Rust, no Avahi
69 or Bonjour dependency.
70- **Reflection** — `tonic-reflection` is wired up, so `grpcurl
71 -plaintext localhost:50151 list` works out of the box.
72
73## Install
74
75### With Homebrew
76
77```
78brew install tsirysndr/tap/zerod
79```
80
81Works on macOS (Apple Silicon and Intel) and Linuxbrew.
82
83### From a release tarball
84
85Every tagged release (`v*` tag on `main`) publishes prebuilt binaries
86for these triples:
87
88| Platform | Tarball |
89| ---------------------------------------- | ----------------------------------------------- |
90| macOS Apple Silicon | [`zerod-aarch64-apple-darwin.tar.gz`][dl-mac-a] |
91| macOS Intel | [`zerod-x86_64-apple-darwin.tar.gz`][dl-mac-x] |
92| Linux x86_64 (NUC, server, …) | [`zerod-x86_64-unknown-linux-gnu.tar.gz`][dl-x] |
93| Linux ARM64 (Pi 3/4/5, 64-bit SBCs) | [`zerod-aarch64-unknown-linux-gnu.tar.gz`][dl-a]|
94| Linux ARM v6/v7 (Pi 1 / Zero / Pi OS 32) | [`zerod-arm-unknown-linux-gnueabihf.tar.gz`][dl-r] |
95
96[dl-mac-a]: https://github.com/tsirysndr/zerod/releases/latest/download/zerod-aarch64-apple-darwin.tar.gz
97[dl-mac-x]: https://github.com/tsirysndr/zerod/releases/latest/download/zerod-x86_64-apple-darwin.tar.gz
98[dl-x]: https://github.com/tsirysndr/zerod/releases/latest/download/zerod-x86_64-unknown-linux-gnu.tar.gz
99[dl-a]: https://github.com/tsirysndr/zerod/releases/latest/download/zerod-aarch64-unknown-linux-gnu.tar.gz
100[dl-r]: https://github.com/tsirysndr/zerod/releases/latest/download/zerod-arm-unknown-linux-gnueabihf.tar.gz
101
102```
103curl -L https://github.com/tsirysndr/zerod/releases/latest/download/zerod-aarch64-unknown-linux-gnu.tar.gz | tar xz
104sudo install zerod-aarch64-unknown-linux-gnu/zerod /usr/local/bin/
105```
106
107Each archive ships `zerod` plus `README.md`, `LICENSE`, and
108`zerod.toml.example`, and is accompanied by a `.sha256` checksum file.
109
110### From source
111
112Native build (whatever host you're on):
113
114```
115cargo build --release
116```
117
118System dependencies on Linux: `protoc` (≥ 3.15), `libasound2-dev`,
119`libdbus-1-dev`, `pkg-config`.
120
121### Cross-compiling
122
123`Cross.toml` + a per-target `Dockerfile.<triple>` are checked in. Each
124Dockerfile pins protoc 25.1 (the cross base images ship a protoc too old
125for proto3 `optional`) and installs the multiarch ALSA / D-Bus dev libs.
126
127| Use case | Triple | Command |
128| ---------------------------------------- | ----------------------------- | ------------------------------------------------------------ |
129| Raspberry Pi 1 / Zero / 2 / Pi OS 32-bit | `arm-unknown-linux-gnueabihf` | `cross build --release --target arm-unknown-linux-gnueabihf` |
130| Raspberry Pi 3 / 4 / 5 / 64-bit ARM SBCs | `aarch64-unknown-linux-gnu` | `cross build --release --target aarch64-unknown-linux-gnu` |
131| Generic Linux x86_64 (NUC, server, …) | `x86_64-unknown-linux-gnu` | `cross build --release --target x86_64-unknown-linux-gnu` |
132
133After a successful build the binary lands at
134`target/<triple>/release/zerod` — scp it to the device, drop a
135`zerod.toml` next to it (or at `/etc/zerod.toml`), and run.
136
137If you edit a Dockerfile, force `cross` to rebuild the image with
138`CROSS_REBUILD=1 cross build …` (cross caches the image per-target).
139
140## Configuration
141
142Settings live in `zerod.toml`. Search order:
143
1441. path passed via `--config`
1452. `./zerod.toml`
1463. `$XDG_CONFIG_HOME/zerod/zerod.toml` (or `~/.config/zerod/zerod.toml`)
1474. `/etc/zerod.toml`
148
149See `zerod.toml.example` for the full schema. Minimal example:
150
151```toml
152[server]
153bind = "0.0.0.0:50151" # all interfaces; use "127.0.0.1:50151" for loopback only
154bearer_token = "" # empty → ZEROD_BEARER_TOKEN env → random
155
156[systemd]
157units = ["snapserver.service", "shairport-sync.service"]
158
159[[configs]]
160key = "snapserver"
161path = "/etc/snapserver.conf"
162unit = "snapserver.service"
163```
164
165If no `zerod.toml` is found, `zerod` runs with defaults (bind
166`0.0.0.0:50151`, no systemd allowlist, no managed configs) and emits a
167warning.
168
169## Usage
170
171### Server
172
173```
174zerod # default; reads zerod.toml
175zerod serve --config /etc/zerod.toml # explicit
176ZEROD_BEARER_TOKEN=hunter2 zerod # fixed token via env
177```
178
179Bind / token / allowlist come from `zerod.toml`. A randomly-generated token
180is printed once at startup if nothing is configured.
181
182### Client
183
184With no `--host`, the client browses mDNS (`_zerod._tcp.local.`) for ~1.5s
185and connects to the only responder. If multiple servers reply, you get a
186listing and the command exits non-zero — pin one with `--name` (the server
187hostname, or whatever you set as `[mdns].name` in `zerod.toml`). Pass
188`--host` (or `ZEROD_HOST`) to bypass discovery entirely.
189
190Each global flag also reads from an env var so you can pin the target once
191per shell session and skip the flags entirely:
192
193| Flag | Env var |
194| ------------------------ | --------------------------- |
195| `--host` | `ZEROD_HOST` |
196| `--port` | `ZEROD_PORT` |
197| `--bearer-token` | `ZEROD_BEARER_TOKEN` |
198| `--name` | `ZEROD_NAME` |
199| `--discover-timeout-ms` | `ZEROD_DISCOVER_TIMEOUT_MS` |
200
201```
202# No host needed — the daemon is found via mDNS.
203export ZEROD_BEARER_TOKEN="$(cat ~/.zerod-token)"
204zerod systemd status snapserver.service
205
206# List every zerod on the LAN.
207zerod discover
208
209# Two daemons on the LAN? Pick one by name (matches [mdns].name).
210zerod --name living-room-pi system health
211
212# Skip mDNS entirely.
213export ZEROD_HOST=pi.lan
214zerod systemd status snapserver.service
215```
216
217```
218zerod bluetooth scan --timeout-secs 5
219zerod bluetooth connect AA:BB:CC:DD:EE:FF
220zerod bluetooth discoverable on --timeout 60
221zerod bluetooth respond-pairing AA:BB:CC:DD:EE:FF --accept
222
223zerod stream play https://example.com/audio.m3u8 --output cpal
224zerod stream play https://example.com/audio.mpd --output pipe --pipe-path /tmp/audio.pcm
225zerod stream spotify start # advertise as a Spotify Connect device
226zerod stream spotify stop
227zerod stream status # state, source (Hls/Dash/Spotify), output, volume
228zerod stream stop
229
230zerod a2dp enable # start bluealsa-aplay + flip discoverable
231zerod a2dp disable
232
233zerod systemd list
234zerod systemd restart snapserver.service
235
236zerod snapcast clients # list every snapclient across all groups
237zerod snapcast volume aa:bb:cc:dd:ee:ff 30
238zerod snapcast group-mute g1 true
239zerod snapcast group-stream g1 default
240
241zerod events tail # subscribe to every event, one JSON line each
242zerod events tail --filter stream.state # only stream playback transitions
243zerod events tail --filter 'bt.*' # all bluetooth events (quote the wildcard)
244
245zerod volume list # all ALSA selems on default card
246zerod volume get # Master on default card
247zerod volume set 70 # Master → 70%
248zerod volume set 50 --control PCM # specific control
249zerod volume mute
250zerod volume unmute
251
252zerod stream volume set 80 # per-stream gain (HLS/DASH/Spotify session)
253zerod stream volume get
254
255zerod config list
256zerod config get snapserver
257zerod config put snapserver ./snapserver.conf --action restart
258
259zerod --bearer-token "$(cat ~/.zerod-token)" systemd status snapserver.service
260```
261
262### gRPC directly
263
264```
265grpcurl -plaintext localhost:50151 list
266grpcurl -plaintext -H 'authorization: Bearer …' \
267 -d '{"timeout_secs": 5}' localhost:50151 zerod.v1alpha1.BluetoothService/Scan
268```
269
270## Security
271
272There is intentionally no TLS in v1. The default bind is `0.0.0.0:50151`
273(all interfaces) so the daemon is reachable from your LAN out of the box —
274which means the bearer token is the only line of defence against any host on
275that network. Always set `bearer_token` in `zerod.toml` (or
276`ZEROD_BEARER_TOKEN`) instead of relying on the random one logged at
277startup, especially when running unattended. For cross-internet access put
278`zerod` behind WireGuard / Tailscale / an SSH tunnel rather than punching
279the port through your router. If you only ever drive `zerod` locally, flip
280the bind back to `127.0.0.1:50151`.
281
282Systemd actions and config writes are gated by the allowlist in `zerod.toml`
283— `zerod` cannot be turned into a generic remote `systemctl`.
284
285## Layout
286
287```
288zerod/
289├── Cargo.toml # workspace root, also the binary
290├── src/main.rs # clap CLI (server + client modes)
291├── zerod.toml.example
292├── Cross.toml
293├── Dockerfile.arm-unknown-linux-gnueabihf
294└── crates/
295 ├── proto/ # tonic_build over .proto files (zerod.v1alpha1)
296 ├── bluetooth/ # bluer wrapper + pairing agent (target_os="linux" gated)
297 ├── stream/ # HLS/DASH + librespot source + AudioSink trait + 3 sinks
298 ├── systemd/ # zbus systemd1 client (target_os="linux" gated)
299 ├── config/ # ManagedConfig registry, atomic write
300 ├── discovery/ # mDNS register + browse over mdns-sd
301 ├── events/ # broadcast event bus shared by every subsystem
302 ├── snapcast/ # JSON-RPC 2.0 client over TCP to snapserver
303 └── server/ # tonic services + settings loader + bearer interceptor
304```
305
306## License
307
308MIT. See [LICENSE](LICENSE).