Headless Rust gRPC daemon to drive Bluetooth, HLS/DASH playback, and snapcast/shairport-sync/squeezelite on Raspberry Pi audio rigs.
0

Configure Feed

Select the types of activity you want to include in your feed.

zerod / README.md
13 kB 308 lines
1# zerod 2[![release](https://github.com/tsirysndr/zerod/actions/workflows/release.yml/badge.svg)](https://github.com/tsirysndr/zerod/actions/workflows/release.yml) 3 4![zerod help preview](.github/assets/preview.png) 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).