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.

Add A2DP sink mode — BlueZ pairing agent + bluealsa delegation

Registers a BlueZ pairing agent at server boot when
`[bluetooth.a2dp].enabled = true`. The agent's RequestConfirmation
callback publishes `BluetoothPairingRequest` onto the event bus and
either auto-accepts (kiosk mode) or parks on a per-address oneshot
waiting for `BluetoothService.RespondPairing` — 30s timeout falls back
to rejection so a stuck prompt doesn't leak the channel.
AuthorizeService accepts the A2DP Source UUID
(`0000110a-…`) and emits `BluetoothA2dpConnected` so phones can stream
audio without a second confirm after pairing.

Adapter setup at boot: optional alias, set_pairable(true), and
discoverable + discoverable_timeout when requested.

New RPCs: `SetDiscoverable`, `RespondPairing`, `A2dpEnable`,
`A2dpDisable`. A2dpEnable preflights `bluealsa-aplay.service` via the
existing systemd allowlist and surfaces a clear "install
bluez-alsa-utils" error if the unit is missing. The unit is
auto-appended to the systemd allowlist whenever a2dp is enabled, so
users don't need to remember to list it under [systemd].

Non-Linux: start_agent is a silent no-op so the server still boots
with `a2dp.enabled = true` on macOS for development; RPC-level paths
return "linux only".

bluer pinned to =0.17.4 exact — the Agent callback shape varies
across minor versions and a silent rewire of the pairing flow is
worse than a build break.

Verified: host build green; cross --target arm-unknown-linux-gnueabihf
green; macOS smoke covers disabled-by-config, stub fall-through,
preflight error path, and auto-allowlist.

+940 -10
+1
Cargo.lock
··· 4269 4269 "anyhow", 4270 4270 "bluer", 4271 4271 "futures", 4272 + "once_cell", 4272 4273 "serde", 4273 4274 "tokio", 4274 4275 "tracing",
+5 -1
crates/bluetooth/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 anyhow = { workspace = true } 8 + once_cell = { workspace = true } 8 9 serde = { workspace = true } 9 10 tracing = { workspace = true } 10 11 zerod-events = { path = "../events" } 11 12 12 13 [target.'cfg(target_os = "linux")'.dependencies] 13 - bluer = { version = "0.17", features = ["bluetoothd"] } 14 + # Agent callback shape varies across bluer minor versions — pin exact 15 + # so a `cargo update` doesn't silently rewire the pairing flow. Verify 16 + # with `cargo doc -p bluer --open` before bumping. 17 + bluer = { version = "=0.17.4", features = ["bluetoothd"] } 14 18 futures = { workspace = true } 15 19 tokio = { workspace = true }
+239
crates/bluetooth/src/agent.rs
··· 1 + //! BlueZ pairing agent + adapter setup for A2DP-sink mode. 2 + //! 3 + //! On `start`, this: 4 + //! * sets the adapter alias (so the phone sees a friendly name), 5 + //! * makes the adapter pairable and (optionally) discoverable, 6 + //! * registers a BlueZ pairing agent with bluer. 7 + //! 8 + //! The agent's `RequestConfirmation` callback publishes a 9 + //! `BluetoothPairingRequest` event and either auto-accepts 10 + //! (kiosk mode) or parks on a per-address oneshot waiting for a 11 + //! `respond_pairing(address, accept)` call. A 30s timeout falls back 12 + //! to rejection so a stuck prompt doesn't leak the channel. 13 + //! 14 + //! `AuthorizeService` auto-allows the A2DP Source UUID 15 + //! (`0000110a-…`) so phones can stream audio without a second confirm 16 + //! after pairing. 17 + 18 + use anyhow::{anyhow, Context, Result}; 19 + use bluer::agent::{Agent, AgentHandle, ReqError, RequestConfirmation}; 20 + use once_cell::sync::Lazy; 21 + use std::collections::HashMap; 22 + use std::sync::atomic::{AtomicBool, Ordering}; 23 + use std::sync::Mutex; 24 + use std::time::Duration; 25 + use tokio::sync::oneshot; 26 + 27 + use crate::A2dpConfig; 28 + 29 + /// A2DP Source profile UUID (BlueZ "AudioSource"). Phones expose this 30 + /// when they want to send audio to us. 31 + const A2DP_SOURCE_UUID: &str = "0000110a-0000-1000-8000-00805f9b34fb"; 32 + 33 + static AGENT_HANDLE: Lazy<Mutex<Option<AgentHandle>>> = Lazy::new(|| Mutex::new(None)); 34 + static SESSION: Lazy<Mutex<Option<bluer::Session>>> = Lazy::new(|| Mutex::new(None)); 35 + static AUTO_ACCEPT: AtomicBool = AtomicBool::new(false); 36 + static PENDING_PAIRINGS: Lazy<Mutex<HashMap<String, oneshot::Sender<bool>>>> = 37 + Lazy::new(|| Mutex::new(HashMap::new())); 38 + 39 + pub async fn start(cfg: A2dpConfig) -> Result<()> { 40 + if AGENT_HANDLE.lock().unwrap().is_some() { 41 + tracing::debug!("bluetooth: agent already registered; ignoring duplicate start"); 42 + return Ok(()); 43 + } 44 + AUTO_ACCEPT.store(cfg.auto_accept_pairings, Ordering::SeqCst); 45 + 46 + let session = bluer::Session::new().await.context("bluer session")?; 47 + let adapter = session 48 + .default_adapter() 49 + .await 50 + .context("bluer default_adapter")?; 51 + adapter.set_powered(true).await.context("set_powered")?; 52 + if !cfg.adapter_alias.is_empty() { 53 + if let Err(e) = adapter.set_alias(cfg.adapter_alias.clone()).await { 54 + tracing::warn!("bluetooth: set_alias({}) failed: {e}", cfg.adapter_alias); 55 + } 56 + } 57 + if let Err(e) = adapter.set_pairable(true).await { 58 + tracing::warn!("bluetooth: set_pairable(true) failed: {e}"); 59 + } 60 + if cfg.discoverable_on_boot { 61 + if let Err(e) = adapter 62 + .set_discoverable_timeout(cfg.discoverable_timeout_secs) 63 + .await 64 + { 65 + tracing::warn!("bluetooth: set_discoverable_timeout failed: {e}"); 66 + } 67 + if let Err(e) = adapter.set_discoverable(true).await { 68 + tracing::warn!("bluetooth: set_discoverable(true) failed: {e}"); 69 + } else { 70 + tracing::info!( 71 + "bluetooth: adapter discoverable (timeout={}s, alias=\"{}\")", 72 + cfg.discoverable_timeout_secs, 73 + cfg.adapter_alias, 74 + ); 75 + } 76 + } 77 + 78 + let agent = Agent { 79 + request_default: true, 80 + request_confirmation: Some(Box::new(|req: RequestConfirmation| { 81 + Box::pin(async move { handle_request_confirmation(req).await }) 82 + })), 83 + authorize_service: Some(Box::new(|req: bluer::agent::AuthorizeService| { 84 + Box::pin(async move { 85 + let uuid = req.service.to_string(); 86 + if uuid.eq_ignore_ascii_case(A2DP_SOURCE_UUID) { 87 + tracing::info!( 88 + "bluetooth: authorize A2DP source from {} (uuid {})", 89 + req.device, 90 + uuid, 91 + ); 92 + zerod_events::publish(zerod_events::Event::BluetoothA2dpConnected { 93 + address: req.device.to_string(), 94 + name: String::new(), 95 + }); 96 + Ok(()) 97 + } else if AUTO_ACCEPT.load(Ordering::SeqCst) { 98 + tracing::debug!( 99 + "bluetooth: auto-authorize service {} from {}", 100 + uuid, 101 + req.device, 102 + ); 103 + Ok(()) 104 + } else { 105 + tracing::debug!( 106 + "bluetooth: rejecting service {} from {} (auto_accept=false)", 107 + uuid, 108 + req.device, 109 + ); 110 + Err(ReqError::Rejected) 111 + } 112 + }) 113 + })), 114 + ..Default::default() 115 + }; 116 + 117 + let handle = session 118 + .register_agent(agent) 119 + .await 120 + .context("bluer register_agent")?; 121 + *AGENT_HANDLE.lock().unwrap() = Some(handle); 122 + // Keep the session alive — dropping it unregisters all proxies. 123 + *SESSION.lock().unwrap() = Some(session); 124 + tracing::info!( 125 + "bluetooth: pairing agent registered (auto_accept={})", 126 + cfg.auto_accept_pairings, 127 + ); 128 + Ok(()) 129 + } 130 + 131 + pub async fn stop() -> Result<()> { 132 + let _ = AGENT_HANDLE.lock().unwrap().take(); // dropping unregisters 133 + let _ = SESSION.lock().unwrap().take(); 134 + // Reject any still-parked pairing prompts so their callbacks unwind. 135 + let pending: Vec<_> = { 136 + let mut g = PENDING_PAIRINGS.lock().unwrap(); 137 + g.drain().collect() 138 + }; 139 + for (_addr, tx) in pending { 140 + let _ = tx.send(false); 141 + } 142 + Ok(()) 143 + } 144 + 145 + pub async fn set_discoverable(on: bool, timeout_secs: u32) -> Result<()> { 146 + let session = bluer::Session::new().await.context("bluer session")?; 147 + let adapter = session 148 + .default_adapter() 149 + .await 150 + .context("bluer default_adapter")?; 151 + adapter 152 + .set_discoverable_timeout(timeout_secs) 153 + .await 154 + .context("set_discoverable_timeout")?; 155 + adapter 156 + .set_discoverable(on) 157 + .await 158 + .context("set_discoverable")?; 159 + Ok(()) 160 + } 161 + 162 + pub fn respond_pairing(address: &str, accept: bool) -> Result<()> { 163 + let tx = PENDING_PAIRINGS 164 + .lock() 165 + .unwrap() 166 + .remove(address) 167 + .ok_or_else(|| anyhow!("no pending pairing prompt for {address}"))?; 168 + let _ = tx.send(accept); 169 + Ok(()) 170 + } 171 + 172 + async fn handle_request_confirmation(req: RequestConfirmation) -> Result<(), ReqError> { 173 + let address = req.device.to_string(); 174 + let passkey = req.passkey; 175 + zerod_events::publish(zerod_events::Event::BluetoothPairingRequest { 176 + address: address.clone(), 177 + passkey: Some(passkey), 178 + }); 179 + 180 + if AUTO_ACCEPT.load(Ordering::SeqCst) { 181 + tracing::info!( 182 + "bluetooth: auto-accepting pairing from {} (passkey={})", 183 + address, 184 + passkey, 185 + ); 186 + zerod_events::publish(zerod_events::Event::BluetoothDeviceChanged { 187 + address: address.clone(), 188 + name: String::new(), 189 + paired: true, 190 + connected: false, 191 + trusted: false, 192 + }); 193 + return Ok(()); 194 + } 195 + 196 + let (tx, rx) = oneshot::channel(); 197 + { 198 + let mut g = PENDING_PAIRINGS.lock().unwrap(); 199 + // If a prior prompt is still pending for the same address, reject 200 + // it so the new one wins — phones retry quickly. 201 + if let Some(old) = g.insert(address.clone(), tx) { 202 + let _ = old.send(false); 203 + } 204 + } 205 + 206 + tracing::info!( 207 + "bluetooth: waiting for RespondPairing from client (address={}, passkey={})", 208 + address, 209 + passkey, 210 + ); 211 + 212 + match tokio::time::timeout(Duration::from_secs(30), rx).await { 213 + Ok(Ok(true)) => { 214 + tracing::info!("bluetooth: pairing accepted by client for {}", address); 215 + zerod_events::publish(zerod_events::Event::BluetoothDeviceChanged { 216 + address: address.clone(), 217 + name: String::new(), 218 + paired: true, 219 + connected: false, 220 + trusted: false, 221 + }); 222 + Ok(()) 223 + } 224 + Ok(Ok(false)) => { 225 + tracing::info!("bluetooth: pairing rejected by client for {}", address); 226 + Err(ReqError::Rejected) 227 + } 228 + Ok(Err(_)) | Err(_) => { 229 + // Timeout or oneshot dropped — make sure we don't leak the 230 + // entry if the timeout branch fired first. 231 + PENDING_PAIRINGS.lock().unwrap().remove(&address); 232 + tracing::warn!( 233 + "bluetooth: pairing for {} timed out without response — rejecting", 234 + address, 235 + ); 236 + Err(ReqError::Rejected) 237 + } 238 + } 239 + }
+47 -1
crates/bluetooth/src/lib.rs
··· 14 14 pub rssi: Option<i32>, 15 15 } 16 16 17 + /// A2DP-sink configuration handed to [`start_agent`] at boot. 18 + #[derive(Debug, Clone, Default)] 19 + pub struct A2dpConfig { 20 + pub auto_accept_pairings: bool, 21 + pub adapter_alias: String, 22 + pub discoverable_on_boot: bool, 23 + pub discoverable_timeout_secs: u32, 24 + } 25 + 26 + #[cfg(target_os = "linux")] 27 + mod agent; 28 + 17 29 #[cfg(target_os = "linux")] 18 30 mod imp { 19 31 use super::BluetoothDevice; ··· 145 157 }); 146 158 Ok(()) 147 159 } 160 + 161 + pub async fn start_agent(cfg: super::A2dpConfig) -> Result<()> { 162 + super::agent::start(cfg).await 163 + } 164 + 165 + pub async fn stop_agent() -> Result<()> { 166 + super::agent::stop().await 167 + } 168 + 169 + pub async fn set_discoverable(on: bool, timeout_secs: u32) -> Result<()> { 170 + super::agent::set_discoverable(on, timeout_secs).await 171 + } 172 + 173 + pub async fn respond_pairing(address: &str, accept: bool) -> Result<()> { 174 + super::agent::respond_pairing(address, accept) 175 + } 148 176 } 149 177 150 178 #[cfg(not(target_os = "linux"))] ··· 170 198 pub async fn remove(_address: &str) -> Result<()> { 171 199 bail!("bluetooth: linux only") 172 200 } 201 + pub async fn start_agent(_cfg: super::A2dpConfig) -> Result<()> { 202 + // No-op on non-Linux so the server boots even with a2dp.enabled=true. 203 + // The user-visible failure happens at the RPC surface (set_discoverable 204 + // / respond_pairing / a2dp_enable). 205 + Ok(()) 206 + } 207 + pub async fn stop_agent() -> Result<()> { 208 + Ok(()) 209 + } 210 + pub async fn set_discoverable(_on: bool, _timeout_secs: u32) -> Result<()> { 211 + bail!("bluetooth: linux only") 212 + } 213 + pub async fn respond_pairing(_address: &str, _accept: bool) -> Result<()> { 214 + bail!("bluetooth: linux only") 215 + } 173 216 } 174 217 175 - pub use imp::{connect, disconnect, get_devices, pair, remove, scan}; 218 + pub use imp::{ 219 + connect, disconnect, get_devices, pair, remove, respond_pairing, scan, set_discoverable, 220 + start_agent, stop_agent, 221 + };
+30
crates/proto/proto/zerod/v1alpha1/bluetooth.proto
··· 35 35 message RemoveRequest { string address = 1; } 36 36 message RemoveResponse {} 37 37 38 + message SetDiscoverableRequest { 39 + bool discoverable = 1; 40 + // 0 → no automatic switch-off while discoverable=true. 41 + uint32 timeout_secs = 2; 42 + } 43 + message SetDiscoverableResponse {} 44 + 45 + // Respond to a pending pairing prompt previously surfaced via the 46 + // `BluetoothPairingRequest` event. Has no effect when 47 + // `[bluetooth.a2dp].auto_accept_pairings = true` (the agent already 48 + // accepted the prompt before publishing the event). 49 + message RespondPairingRequest { 50 + string address = 1; 51 + bool accept = 2; 52 + } 53 + message RespondPairingResponse {} 54 + 55 + // One-shot helper: start the bluealsa-aplay unit and flip the adapter 56 + // discoverable. Returns FAILED_PRECONDITION if A2DP mode isn't enabled 57 + // in zerod.toml or the audio backend unit isn't installed. 58 + message A2dpEnableRequest {} 59 + message A2dpEnableResponse {} 60 + 61 + message A2dpDisableRequest {} 62 + message A2dpDisableResponse {} 63 + 38 64 service BluetoothService { 39 65 rpc Scan(ScanRequest) returns (ScanResponse); 40 66 rpc ListDevices(ListDevicesRequest) returns (ListDevicesResponse); ··· 42 68 rpc ConnectDevice(ConnectDeviceRequest) returns (ConnectDeviceResponse); 43 69 rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); 44 70 rpc Remove(RemoveRequest) returns (RemoveResponse); 71 + rpc SetDiscoverable(SetDiscoverableRequest) returns (SetDiscoverableResponse); 72 + rpc RespondPairing(RespondPairingRequest) returns (RespondPairingResponse); 73 + rpc A2dpEnable(A2dpEnableRequest) returns (A2dpEnableResponse); 74 + rpc A2dpDisable(A2dpDisableRequest) returns (A2dpDisableResponse); 45 75 }
+348
crates/proto/src/generated/zerod.v1alpha1.rs
··· 60 60 } 61 61 #[derive(Clone, Copy, PartialEq, ::prost::Message)] 62 62 pub struct RemoveResponse {} 63 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 64 + pub struct SetDiscoverableRequest { 65 + #[prost(bool, tag = "1")] 66 + pub discoverable: bool, 67 + /// 0 → no automatic switch-off while discoverable=true. 68 + #[prost(uint32, tag = "2")] 69 + pub timeout_secs: u32, 70 + } 71 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 72 + pub struct SetDiscoverableResponse {} 73 + /// Respond to a pending pairing prompt previously surfaced via the 74 + /// `BluetoothPairingRequest` event. Has no effect when 75 + /// `\[bluetooth.a2dp\].auto_accept_pairings = true` (the agent already 76 + /// accepted the prompt before publishing the event). 77 + #[derive(Clone, PartialEq, ::prost::Message)] 78 + pub struct RespondPairingRequest { 79 + #[prost(string, tag = "1")] 80 + pub address: ::prost::alloc::string::String, 81 + #[prost(bool, tag = "2")] 82 + pub accept: bool, 83 + } 84 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 85 + pub struct RespondPairingResponse {} 86 + /// One-shot helper: start the bluealsa-aplay unit and flip the adapter 87 + /// discoverable. Returns FAILED_PRECONDITION if A2DP mode isn't enabled 88 + /// in zerod.toml or the audio backend unit isn't installed. 89 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 90 + pub struct A2dpEnableRequest {} 91 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 92 + pub struct A2dpEnableResponse {} 93 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 94 + pub struct A2dpDisableRequest {} 95 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 96 + pub struct A2dpDisableResponse {} 63 97 /// Generated client implementations. 64 98 pub mod bluetooth_service_client { 65 99 #![allow( ··· 292 326 .insert(GrpcMethod::new("zerod.v1alpha1.BluetoothService", "Remove")); 293 327 self.inner.unary(req, path, codec).await 294 328 } 329 + pub async fn set_discoverable( 330 + &mut self, 331 + request: impl tonic::IntoRequest<super::SetDiscoverableRequest>, 332 + ) -> std::result::Result< 333 + tonic::Response<super::SetDiscoverableResponse>, 334 + tonic::Status, 335 + > { 336 + self.inner 337 + .ready() 338 + .await 339 + .map_err(|e| { 340 + tonic::Status::unknown( 341 + format!("Service was not ready: {}", e.into()), 342 + ) 343 + })?; 344 + let codec = tonic::codec::ProstCodec::default(); 345 + let path = http::uri::PathAndQuery::from_static( 346 + "/zerod.v1alpha1.BluetoothService/SetDiscoverable", 347 + ); 348 + let mut req = request.into_request(); 349 + req.extensions_mut() 350 + .insert( 351 + GrpcMethod::new("zerod.v1alpha1.BluetoothService", "SetDiscoverable"), 352 + ); 353 + self.inner.unary(req, path, codec).await 354 + } 355 + pub async fn respond_pairing( 356 + &mut self, 357 + request: impl tonic::IntoRequest<super::RespondPairingRequest>, 358 + ) -> std::result::Result< 359 + tonic::Response<super::RespondPairingResponse>, 360 + tonic::Status, 361 + > { 362 + self.inner 363 + .ready() 364 + .await 365 + .map_err(|e| { 366 + tonic::Status::unknown( 367 + format!("Service was not ready: {}", e.into()), 368 + ) 369 + })?; 370 + let codec = tonic::codec::ProstCodec::default(); 371 + let path = http::uri::PathAndQuery::from_static( 372 + "/zerod.v1alpha1.BluetoothService/RespondPairing", 373 + ); 374 + let mut req = request.into_request(); 375 + req.extensions_mut() 376 + .insert( 377 + GrpcMethod::new("zerod.v1alpha1.BluetoothService", "RespondPairing"), 378 + ); 379 + self.inner.unary(req, path, codec).await 380 + } 381 + pub async fn a2dp_enable( 382 + &mut self, 383 + request: impl tonic::IntoRequest<super::A2dpEnableRequest>, 384 + ) -> std::result::Result< 385 + tonic::Response<super::A2dpEnableResponse>, 386 + tonic::Status, 387 + > { 388 + self.inner 389 + .ready() 390 + .await 391 + .map_err(|e| { 392 + tonic::Status::unknown( 393 + format!("Service was not ready: {}", e.into()), 394 + ) 395 + })?; 396 + let codec = tonic::codec::ProstCodec::default(); 397 + let path = http::uri::PathAndQuery::from_static( 398 + "/zerod.v1alpha1.BluetoothService/A2dpEnable", 399 + ); 400 + let mut req = request.into_request(); 401 + req.extensions_mut() 402 + .insert( 403 + GrpcMethod::new("zerod.v1alpha1.BluetoothService", "A2dpEnable"), 404 + ); 405 + self.inner.unary(req, path, codec).await 406 + } 407 + pub async fn a2dp_disable( 408 + &mut self, 409 + request: impl tonic::IntoRequest<super::A2dpDisableRequest>, 410 + ) -> std::result::Result< 411 + tonic::Response<super::A2dpDisableResponse>, 412 + tonic::Status, 413 + > { 414 + self.inner 415 + .ready() 416 + .await 417 + .map_err(|e| { 418 + tonic::Status::unknown( 419 + format!("Service was not ready: {}", e.into()), 420 + ) 421 + })?; 422 + let codec = tonic::codec::ProstCodec::default(); 423 + let path = http::uri::PathAndQuery::from_static( 424 + "/zerod.v1alpha1.BluetoothService/A2dpDisable", 425 + ); 426 + let mut req = request.into_request(); 427 + req.extensions_mut() 428 + .insert( 429 + GrpcMethod::new("zerod.v1alpha1.BluetoothService", "A2dpDisable"), 430 + ); 431 + self.inner.unary(req, path, codec).await 432 + } 295 433 } 296 434 } 297 435 /// Generated server implementations. ··· 340 478 &self, 341 479 request: tonic::Request<super::RemoveRequest>, 342 480 ) -> std::result::Result<tonic::Response<super::RemoveResponse>, tonic::Status>; 481 + async fn set_discoverable( 482 + &self, 483 + request: tonic::Request<super::SetDiscoverableRequest>, 484 + ) -> std::result::Result< 485 + tonic::Response<super::SetDiscoverableResponse>, 486 + tonic::Status, 487 + >; 488 + async fn respond_pairing( 489 + &self, 490 + request: tonic::Request<super::RespondPairingRequest>, 491 + ) -> std::result::Result< 492 + tonic::Response<super::RespondPairingResponse>, 493 + tonic::Status, 494 + >; 495 + async fn a2dp_enable( 496 + &self, 497 + request: tonic::Request<super::A2dpEnableRequest>, 498 + ) -> std::result::Result< 499 + tonic::Response<super::A2dpEnableResponse>, 500 + tonic::Status, 501 + >; 502 + async fn a2dp_disable( 503 + &self, 504 + request: tonic::Request<super::A2dpDisableRequest>, 505 + ) -> std::result::Result< 506 + tonic::Response<super::A2dpDisableResponse>, 507 + tonic::Status, 508 + >; 343 509 } 344 510 #[derive(Debug)] 345 511 pub struct BluetoothServiceServer<T> { ··· 671 837 let inner = self.inner.clone(); 672 838 let fut = async move { 673 839 let method = RemoveSvc(inner); 840 + let codec = tonic::codec::ProstCodec::default(); 841 + let mut grpc = tonic::server::Grpc::new(codec) 842 + .apply_compression_config( 843 + accept_compression_encodings, 844 + send_compression_encodings, 845 + ) 846 + .apply_max_message_size_config( 847 + max_decoding_message_size, 848 + max_encoding_message_size, 849 + ); 850 + let res = grpc.unary(method, req).await; 851 + Ok(res) 852 + }; 853 + Box::pin(fut) 854 + } 855 + "/zerod.v1alpha1.BluetoothService/SetDiscoverable" => { 856 + #[allow(non_camel_case_types)] 857 + struct SetDiscoverableSvc<T: BluetoothService>(pub Arc<T>); 858 + impl< 859 + T: BluetoothService, 860 + > tonic::server::UnaryService<super::SetDiscoverableRequest> 861 + for SetDiscoverableSvc<T> { 862 + type Response = super::SetDiscoverableResponse; 863 + type Future = BoxFuture< 864 + tonic::Response<Self::Response>, 865 + tonic::Status, 866 + >; 867 + fn call( 868 + &mut self, 869 + request: tonic::Request<super::SetDiscoverableRequest>, 870 + ) -> Self::Future { 871 + let inner = Arc::clone(&self.0); 872 + let fut = async move { 873 + <T as BluetoothService>::set_discoverable(&inner, request) 874 + .await 875 + }; 876 + Box::pin(fut) 877 + } 878 + } 879 + let accept_compression_encodings = self.accept_compression_encodings; 880 + let send_compression_encodings = self.send_compression_encodings; 881 + let max_decoding_message_size = self.max_decoding_message_size; 882 + let max_encoding_message_size = self.max_encoding_message_size; 883 + let inner = self.inner.clone(); 884 + let fut = async move { 885 + let method = SetDiscoverableSvc(inner); 886 + let codec = tonic::codec::ProstCodec::default(); 887 + let mut grpc = tonic::server::Grpc::new(codec) 888 + .apply_compression_config( 889 + accept_compression_encodings, 890 + send_compression_encodings, 891 + ) 892 + .apply_max_message_size_config( 893 + max_decoding_message_size, 894 + max_encoding_message_size, 895 + ); 896 + let res = grpc.unary(method, req).await; 897 + Ok(res) 898 + }; 899 + Box::pin(fut) 900 + } 901 + "/zerod.v1alpha1.BluetoothService/RespondPairing" => { 902 + #[allow(non_camel_case_types)] 903 + struct RespondPairingSvc<T: BluetoothService>(pub Arc<T>); 904 + impl< 905 + T: BluetoothService, 906 + > tonic::server::UnaryService<super::RespondPairingRequest> 907 + for RespondPairingSvc<T> { 908 + type Response = super::RespondPairingResponse; 909 + type Future = BoxFuture< 910 + tonic::Response<Self::Response>, 911 + tonic::Status, 912 + >; 913 + fn call( 914 + &mut self, 915 + request: tonic::Request<super::RespondPairingRequest>, 916 + ) -> Self::Future { 917 + let inner = Arc::clone(&self.0); 918 + let fut = async move { 919 + <T as BluetoothService>::respond_pairing(&inner, request) 920 + .await 921 + }; 922 + Box::pin(fut) 923 + } 924 + } 925 + let accept_compression_encodings = self.accept_compression_encodings; 926 + let send_compression_encodings = self.send_compression_encodings; 927 + let max_decoding_message_size = self.max_decoding_message_size; 928 + let max_encoding_message_size = self.max_encoding_message_size; 929 + let inner = self.inner.clone(); 930 + let fut = async move { 931 + let method = RespondPairingSvc(inner); 932 + let codec = tonic::codec::ProstCodec::default(); 933 + let mut grpc = tonic::server::Grpc::new(codec) 934 + .apply_compression_config( 935 + accept_compression_encodings, 936 + send_compression_encodings, 937 + ) 938 + .apply_max_message_size_config( 939 + max_decoding_message_size, 940 + max_encoding_message_size, 941 + ); 942 + let res = grpc.unary(method, req).await; 943 + Ok(res) 944 + }; 945 + Box::pin(fut) 946 + } 947 + "/zerod.v1alpha1.BluetoothService/A2dpEnable" => { 948 + #[allow(non_camel_case_types)] 949 + struct A2dpEnableSvc<T: BluetoothService>(pub Arc<T>); 950 + impl< 951 + T: BluetoothService, 952 + > tonic::server::UnaryService<super::A2dpEnableRequest> 953 + for A2dpEnableSvc<T> { 954 + type Response = super::A2dpEnableResponse; 955 + type Future = BoxFuture< 956 + tonic::Response<Self::Response>, 957 + tonic::Status, 958 + >; 959 + fn call( 960 + &mut self, 961 + request: tonic::Request<super::A2dpEnableRequest>, 962 + ) -> Self::Future { 963 + let inner = Arc::clone(&self.0); 964 + let fut = async move { 965 + <T as BluetoothService>::a2dp_enable(&inner, request).await 966 + }; 967 + Box::pin(fut) 968 + } 969 + } 970 + let accept_compression_encodings = self.accept_compression_encodings; 971 + let send_compression_encodings = self.send_compression_encodings; 972 + let max_decoding_message_size = self.max_decoding_message_size; 973 + let max_encoding_message_size = self.max_encoding_message_size; 974 + let inner = self.inner.clone(); 975 + let fut = async move { 976 + let method = A2dpEnableSvc(inner); 977 + let codec = tonic::codec::ProstCodec::default(); 978 + let mut grpc = tonic::server::Grpc::new(codec) 979 + .apply_compression_config( 980 + accept_compression_encodings, 981 + send_compression_encodings, 982 + ) 983 + .apply_max_message_size_config( 984 + max_decoding_message_size, 985 + max_encoding_message_size, 986 + ); 987 + let res = grpc.unary(method, req).await; 988 + Ok(res) 989 + }; 990 + Box::pin(fut) 991 + } 992 + "/zerod.v1alpha1.BluetoothService/A2dpDisable" => { 993 + #[allow(non_camel_case_types)] 994 + struct A2dpDisableSvc<T: BluetoothService>(pub Arc<T>); 995 + impl< 996 + T: BluetoothService, 997 + > tonic::server::UnaryService<super::A2dpDisableRequest> 998 + for A2dpDisableSvc<T> { 999 + type Response = super::A2dpDisableResponse; 1000 + type Future = BoxFuture< 1001 + tonic::Response<Self::Response>, 1002 + tonic::Status, 1003 + >; 1004 + fn call( 1005 + &mut self, 1006 + request: tonic::Request<super::A2dpDisableRequest>, 1007 + ) -> Self::Future { 1008 + let inner = Arc::clone(&self.0); 1009 + let fut = async move { 1010 + <T as BluetoothService>::a2dp_disable(&inner, request).await 1011 + }; 1012 + Box::pin(fut) 1013 + } 1014 + } 1015 + let accept_compression_encodings = self.accept_compression_encodings; 1016 + let send_compression_encodings = self.send_compression_encodings; 1017 + let max_decoding_message_size = self.max_decoding_message_size; 1018 + let max_encoding_message_size = self.max_encoding_message_size; 1019 + let inner = self.inner.clone(); 1020 + let fut = async move { 1021 + let method = A2dpDisableSvc(inner); 674 1022 let codec = tonic::codec::ProstCodec::default(); 675 1023 let mut grpc = tonic::server::Grpc::new(codec) 676 1024 .apply_compression_config(
crates/proto/src/generated/zerod_descriptor.bin

This is a binary file and will not be displayed.

+93 -5
crates/server/src/bluetooth.rs
··· 1 + use std::sync::Arc; 1 2 use tonic::{Request, Response, Status}; 2 3 use zerod_proto::v1alpha1::{ 3 - bluetooth_service_server::BluetoothService, BluetoothDevice, ConnectDeviceRequest, 4 + bluetooth_service_server::BluetoothService, A2dpDisableRequest, A2dpDisableResponse, 5 + A2dpEnableRequest, A2dpEnableResponse, BluetoothDevice, ConnectDeviceRequest, 4 6 ConnectDeviceResponse, DisconnectRequest, DisconnectResponse, ListDevicesRequest, 5 - ListDevicesResponse, PairRequest, PairResponse, RemoveRequest, RemoveResponse, ScanRequest, 6 - ScanResponse, 7 + ListDevicesResponse, PairRequest, PairResponse, RemoveRequest, RemoveResponse, 8 + RespondPairingRequest, RespondPairingResponse, ScanRequest, ScanResponse, 9 + SetDiscoverableRequest, SetDiscoverableResponse, 7 10 }; 11 + use zerod_systemd::UnitAllowlist; 12 + 13 + use crate::settings::A2dpSettings; 14 + 15 + pub struct BluetoothSvc { 16 + a2dp: A2dpSettings, 17 + allow: Arc<UnitAllowlist>, 18 + } 8 19 9 - #[derive(Default)] 10 - pub struct BluetoothSvc; 20 + impl BluetoothSvc { 21 + pub fn new(a2dp: A2dpSettings, allow: Arc<UnitAllowlist>) -> Self { 22 + Self { a2dp, allow } 23 + } 24 + 25 + fn require_a2dp(&self) -> Result<(), Status> { 26 + if !self.a2dp.enabled { 27 + return Err(Status::failed_precondition( 28 + "A2DP disabled in zerod.toml ([bluetooth.a2dp].enabled = false)", 29 + )); 30 + } 31 + Ok(()) 32 + } 33 + } 11 34 12 35 fn to_proto(d: zerod_bluetooth::BluetoothDevice) -> BluetoothDevice { 13 36 BluetoothDevice { ··· 85 108 tracing::info!("bluetooth.Remove {}", addr); 86 109 zerod_bluetooth::remove(&addr).await.map_err(err)?; 87 110 Ok(Response::new(RemoveResponse {})) 111 + } 112 + 113 + async fn set_discoverable( 114 + &self, 115 + req: Request<SetDiscoverableRequest>, 116 + ) -> Result<Response<SetDiscoverableResponse>, Status> { 117 + let r = req.into_inner(); 118 + tracing::info!( 119 + "bluetooth.SetDiscoverable on={} timeout_secs={}", 120 + r.discoverable, 121 + r.timeout_secs, 122 + ); 123 + zerod_bluetooth::set_discoverable(r.discoverable, r.timeout_secs) 124 + .await 125 + .map_err(err)?; 126 + Ok(Response::new(SetDiscoverableResponse {})) 127 + } 128 + 129 + async fn respond_pairing( 130 + &self, 131 + req: Request<RespondPairingRequest>, 132 + ) -> Result<Response<RespondPairingResponse>, Status> { 133 + let r = req.into_inner(); 134 + tracing::info!("bluetooth.RespondPairing address={} accept={}", r.address, r.accept); 135 + zerod_bluetooth::respond_pairing(&r.address, r.accept) 136 + .await 137 + .map_err(|e| Status::failed_precondition(format!("{e:#}")))?; 138 + Ok(Response::new(RespondPairingResponse {})) 139 + } 140 + 141 + async fn a2dp_enable( 142 + &self, 143 + _req: Request<A2dpEnableRequest>, 144 + ) -> Result<Response<A2dpEnableResponse>, Status> { 145 + self.require_a2dp()?; 146 + let unit = &self.a2dp.bluealsa_aplay_unit; 147 + tracing::info!("bluetooth.A2dpEnable starting {}", unit); 148 + // Pre-flight: surface a clear error if bluez-alsa isn't installed 149 + // rather than a cryptic systemd "unit not found". 150 + if let Err(e) = zerod_systemd::status(&self.allow, unit).await { 151 + return Err(Status::failed_precondition(format!( 152 + "{unit} not available — install bluez-alsa-utils on the device ({e:#})" 153 + ))); 154 + } 155 + zerod_systemd::start(&self.allow, unit).await.map_err(err)?; 156 + zerod_bluetooth::set_discoverable(true, self.a2dp.discoverable_timeout_secs) 157 + .await 158 + .map_err(err)?; 159 + Ok(Response::new(A2dpEnableResponse {})) 160 + } 161 + 162 + async fn a2dp_disable( 163 + &self, 164 + _req: Request<A2dpDisableRequest>, 165 + ) -> Result<Response<A2dpDisableResponse>, Status> { 166 + self.require_a2dp()?; 167 + let unit = &self.a2dp.bluealsa_aplay_unit; 168 + tracing::info!("bluetooth.A2dpDisable stopping {}", unit); 169 + // set_discoverable failure is non-fatal — we still try to stop 170 + // the unit so a partial state doesn't leave the daemon broadcasting. 171 + if let Err(e) = zerod_bluetooth::set_discoverable(false, 0).await { 172 + tracing::warn!("bluetooth.A2dpDisable set_discoverable(false) failed: {e:#}"); 173 + } 174 + zerod_systemd::stop(&self.allow, unit).await.map_err(err)?; 175 + Ok(Response::new(A2dpDisableResponse {})) 88 176 } 89 177 }
+14 -1
crates/server/src/lib.rs
··· 36 36 let bearer = resolve_bearer_token(&settings.server.bearer_token)?; 37 37 let snap_svc = build_snapcast_svc(&settings.snapcast); 38 38 39 + if settings.bluetooth.a2dp.enabled { 40 + let a2dp = settings.bluetooth.a2dp.clone(); 41 + let cfg = zerod_bluetooth::A2dpConfig { 42 + auto_accept_pairings: a2dp.auto_accept_pairings, 43 + adapter_alias: a2dp.adapter_alias, 44 + discoverable_on_boot: a2dp.discoverable_on_boot, 45 + discoverable_timeout_secs: a2dp.discoverable_timeout_secs, 46 + }; 47 + if let Err(e) = zerod_bluetooth::start_agent(cfg).await { 48 + tracing::warn!("bluetooth: failed to start pairing agent: {e:#}"); 49 + } 50 + } 51 + 39 52 let _mdns = maybe_advertise(&settings.mdns, addr.port()); 40 53 41 54 let reflection = tonic_reflection::server::Builder::configure() ··· 64 77 interceptor.clone(), 65 78 )) 66 79 .add_service(BluetoothServiceServer::with_interceptor( 67 - bluetooth::BluetoothSvc::default(), 80 + bluetooth::BluetoothSvc::new(settings.bluetooth.a2dp.clone(), allow.clone()), 68 81 interceptor.clone(), 69 82 )) 70 83 .add_service(StreamServiceServer::with_interceptor(
+69 -2
crates/server/src/settings.rs
··· 33 33 pub snapcast: SnapcastSettings, 34 34 #[serde(default)] 35 35 pub librespot: LibrespotSettings, 36 + #[serde(default)] 37 + pub bluetooth: BluetoothSettings, 38 + } 39 + 40 + #[derive(Debug, Clone, Default, Deserialize, Serialize)] 41 + pub struct BluetoothSettings { 42 + #[serde(default)] 43 + pub a2dp: A2dpSettings, 44 + } 45 + 46 + #[derive(Debug, Clone, Deserialize, Serialize)] 47 + pub struct A2dpSettings { 48 + /// Register a BlueZ pairing agent and (when `discoverable_on_boot`) 49 + /// flip the adapter discoverable at startup. When false, the agent 50 + /// isn't registered and `BluetoothService.A2dpEnable` returns 51 + /// FAILED_PRECONDITION. 52 + #[serde(default)] 53 + pub enabled: bool, 54 + /// systemd unit responsible for actually routing decoded BlueZ audio 55 + /// to ALSA. zerod doesn't decode SBC/AAC — bluealsa-aplay does. 56 + /// Auto-appended to the systemd allowlist at boot when `enabled`. 57 + #[serde(default = "default_bluealsa_unit")] 58 + pub bluealsa_aplay_unit: String, 59 + /// Accept every pairing prompt without waiting for a `RespondPairing` 60 + /// RPC. Convenient for a kiosk-style setup; risky on a public LAN. 61 + #[serde(default)] 62 + pub auto_accept_pairings: bool, 63 + /// Friendly name advertised to phones (set as adapter `Alias`). 64 + /// Empty → leave the existing alias untouched. 65 + #[serde(default = "default_adapter_alias")] 66 + pub adapter_alias: String, 67 + /// Flip the adapter to discoverable at server boot. 68 + #[serde(default = "default_true")] 69 + pub discoverable_on_boot: bool, 70 + /// Discoverable window in seconds. 0 → discoverable forever. 71 + #[serde(default)] 72 + pub discoverable_timeout_secs: u32, 73 + } 74 + 75 + impl Default for A2dpSettings { 76 + fn default() -> Self { 77 + Self { 78 + enabled: false, 79 + bluealsa_aplay_unit: default_bluealsa_unit(), 80 + auto_accept_pairings: false, 81 + adapter_alias: default_adapter_alias(), 82 + discoverable_on_boot: true, 83 + discoverable_timeout_secs: 0, 84 + } 85 + } 86 + } 87 + 88 + fn default_bluealsa_unit() -> String { 89 + "bluealsa-aplay.service".to_string() 90 + } 91 + 92 + fn default_adapter_alias() -> String { 93 + "zerod".to_string() 36 94 } 37 95 38 96 #[derive(Debug, Clone, Deserialize, Serialize)] ··· 178 236 configs: Vec::new(), 179 237 snapcast: SnapcastSettings::default(), 180 238 librespot: LibrespotSettings::default(), 239 + bluetooth: BluetoothSettings::default(), 181 240 } 182 241 } 183 242 } 184 243 185 244 impl Settings { 186 245 pub fn systemd_allowlist(&self) -> UnitAllowlist { 187 - UnitAllowlist { 188 - units: self.systemd.units.clone(), 246 + let mut units = self.systemd.units.clone(); 247 + // When A2DP sink mode is on, zerod manages bluealsa-aplay through 248 + // the existing SystemdService — auto-allowlist it so users don't 249 + // have to remember to add it manually. 250 + if self.bluetooth.a2dp.enabled { 251 + let unit = self.bluetooth.a2dp.bluealsa_aplay_unit.clone(); 252 + if !unit.is_empty() && !units.iter().any(|u| u == &unit) { 253 + units.push(unit); 254 + } 189 255 } 256 + UnitAllowlist { units } 190 257 } 191 258 192 259 pub fn config_registry(&self) -> Registry {
+76
src/main.rs
··· 109 109 /// Control a Snapcast server (snapserver JSON-RPC). 110 110 #[command(subcommand)] 111 111 Snapcast(SnapcastCmd), 112 + /// Toggle the Pi as a Bluetooth audio sink (A2DP via bluealsa-aplay). 113 + #[command(subcommand)] 114 + A2dp(A2dpCmd), 112 115 /// Browse mDNS for zerod servers on the LAN. 113 116 Discover, 117 + } 118 + 119 + #[derive(Subcommand)] 120 + enum A2dpCmd { 121 + /// Start bluealsa-aplay and flip the adapter discoverable. 122 + Enable, 123 + /// Stop bluealsa-aplay and clear discoverable. 124 + Disable, 114 125 } 115 126 116 127 #[derive(Subcommand)] ··· 186 197 Connect { address: String }, 187 198 Disconnect { address: String }, 188 199 Remove { address: String }, 200 + /// Flip the adapter discoverable / not-discoverable. 201 + Discoverable { 202 + #[arg(value_enum)] 203 + state: OnOff, 204 + /// 0 → no auto-switch-off while discoverable=on. 205 + #[arg(long, default_value_t = 0)] 206 + timeout: u32, 207 + }, 208 + /// Resolve a pending pairing prompt surfaced via the bt.pairing event. 209 + RespondPairing { 210 + address: String, 211 + #[arg(long)] 212 + accept: bool, 213 + }, 214 + } 215 + 216 + #[derive(Clone, Copy, clap::ValueEnum)] 217 + enum OnOff { 218 + On, 219 + Off, 189 220 } 190 221 191 222 #[derive(Subcommand)] ··· 359 390 Some(Command::Volume(cmd)) => run_volume(&endpoint.unwrap(), bearer_token, cmd).await, 360 391 Some(Command::Events(cmd)) => run_events(&endpoint.unwrap(), bearer_token, cmd).await, 361 392 Some(Command::Snapcast(cmd)) => run_snapcast(&endpoint.unwrap(), bearer_token, cmd).await, 393 + Some(Command::A2dp(cmd)) => run_a2dp(&endpoint.unwrap(), bearer_token, cmd).await, 362 394 } 363 395 } 364 396 ··· 547 579 } 548 580 BluetoothCmd::Remove { address } => { 549 581 client.remove(attach_token(Request::new(pb::RemoveRequest { address }), &token)).await?; 582 + println!("ok"); 583 + } 584 + BluetoothCmd::Discoverable { state, timeout } => { 585 + let discoverable = matches!(state, OnOff::On); 586 + client 587 + .set_discoverable(attach_token( 588 + Request::new(pb::SetDiscoverableRequest { 589 + discoverable, 590 + timeout_secs: timeout, 591 + }), 592 + &token, 593 + )) 594 + .await?; 595 + println!("ok"); 596 + } 597 + BluetoothCmd::RespondPairing { address, accept } => { 598 + client 599 + .respond_pairing(attach_token( 600 + Request::new(pb::RespondPairingRequest { address, accept }), 601 + &token, 602 + )) 603 + .await?; 604 + println!("ok"); 605 + } 606 + } 607 + Ok(()) 608 + } 609 + 610 + // --- a2dp ------------------------------------------------------------------ 611 + 612 + async fn run_a2dp(ep: &Endpoint, token: Option<String>, cmd: A2dpCmd) -> Result<()> { 613 + let ch = channel(ep).await?; 614 + let mut client = pb::bluetooth_service_client::BluetoothServiceClient::new(ch); 615 + match cmd { 616 + A2dpCmd::Enable => { 617 + client 618 + .a2dp_enable(attach_token(Request::new(pb::A2dpEnableRequest {}), &token)) 619 + .await?; 620 + println!("ok"); 621 + } 622 + A2dpCmd::Disable => { 623 + client 624 + .a2dp_disable(attach_token(Request::new(pb::A2dpDisableRequest {}), &token)) 625 + .await?; 550 626 println!("ok"); 551 627 } 552 628 }
+18
zerod.toml.example
··· 79 79 # (current directory). --disable-audio-cache is always passed, so this 80 80 # is creds only — pick a persistent path so you don't re-pair every boot. 81 81 cache_path = "" 82 + 83 + [bluetooth.a2dp] 84 + # Make the Pi advertise as a Bluetooth speaker. zerod registers a BlueZ 85 + # pairing agent and delegates the actual audio path to bluealsa-aplay 86 + # (install `bluez-alsa-utils` on the device). The unit name below is 87 + # auto-added to the systemd allowlist when enabled. 88 + enabled = false 89 + bluealsa_aplay_unit = "bluealsa-aplay.service" 90 + # Skip the RespondPairing flow and accept every prompt. Convenient for 91 + # kiosks; risky on a shared LAN. 92 + auto_accept_pairings = false 93 + # Friendly name shown to phones. Empty → leave the adapter alias alone. 94 + adapter_alias = "zerod" 95 + # Flip the adapter discoverable at server boot. Set false to defer to 96 + # the `a2dp enable` RPC. 97 + discoverable_on_boot = true 98 + # Discoverable window in seconds. 0 → discoverable forever. 99 + discoverable_timeout_secs = 0