Commit dd6b8e786e1f
tarball8 files changed+1657-0
@@ -0,0 +1,20 @@
1
+[package]2
+name = "headroom-client"3
+description = "automatic loudness and per-app volume control (pipewire)"4
+version.workspace = true5
+edition.workspace = true6
+license = "MPL-2.0"7
+homepage.workspace = true8
+repository.workspace = true9
+authors.workspace = true10
+11
+[dependencies]12
+headroom-ipc = { workspace = true }13
+serde = { workspace = true }14
+serde_json = { workspace = true }15
+thiserror = { workspace = true }16
+17
+[features]18
+default = []19
+# reserved for an eventual tokio-based async client20
+async = []@@ -0,0 +1,546 @@
1
+//! blocking [`Client`].2
+3
+use std::collections::VecDeque;4
+use std::io::{BufReader, BufWriter};5
+use std::os::unix::net::UnixStream;6
+use std::path::{Path, PathBuf};7
+8
+use serde::de::DeserializeOwned;9
+10
+use headroom_ipc::{11
+ default_socket_path, Codec, Event, HelloData, Op, ProtoError, Request, Response,12
+ ResponsePayload, Route, ServerFrame, Status, Topic,13
+};14
+15
+/// errors from the blocking client.16
+#[derive(Debug, thiserror::Error)]17
+pub enum ClientError {18
+ #[error("ipc: {0}")]19
+ Ipc(#[from] headroom_ipc::Error),20
+21
+ /// server's first frame was not the `hello` event.22
+ #[error("expected hello event from server, got {0}")]23
+ BadHello(String),24
+25
+ /// response id we never issued.26
+ #[error("response with unknown id {0}")]27
+ UnknownResponseId(u64),28
+29
+ /// protocol-level error for an op.30
+ #[error("server error: {0}")]31
+ Protocol(#[from] ProtoError),32
+33
+ #[error("no default socket path (XDG_RUNTIME_DIR unset and /proc/self/status unreadable)")]34
+ NoDefaultPath,35
+36
+ /// typed-helper response failed to deserialize into the expected shape.37
+ #[error("response shape mismatch: {0}")]38
+ DecodeResult(serde_json::Error),39
+}40
+41
+/// blocking client for the headroom control protocol.42
+///43
+/// single-threaded by construction; for concurrent request/response and event44
+/// consumption, open two connections.45
+pub struct Client {46
+ reader: BufReader<UnixStream>,47
+ writer: BufWriter<UnixStream>,48
+ codec: Codec,49
+ next_id: u64,50
+ pending_events: VecDeque<Event>,51
+ hello: HelloData,52
+ socket_path: PathBuf,53
+}54
+55
+impl Client {56
+ /// connect at the default socket path.57
+ pub fn connect() -> Result<Self, ClientError> {58
+ let path = default_socket_path().ok_or(ClientError::NoDefaultPath)?;59
+ Self::connect_at(&path)60
+ }61
+62
+ /// connect at the given socket path.63
+ pub fn connect_at(path: &Path) -> Result<Self, ClientError> {64
+ let stream = UnixStream::connect(path).map_err(|e| ClientError::Ipc(e.into()))?;65
+ let reader_half = stream.try_clone().map_err(|e| ClientError::Ipc(e.into()))?;66
+ let writer_half = stream;67
+68
+ let mut me = Self {69
+ reader: BufReader::new(reader_half),70
+ writer: BufWriter::new(writer_half),71
+ codec: Codec::new(),72
+ next_id: 1,73
+ pending_events: VecDeque::new(),74
+ // placeholder; populated by handshake below75
+ hello: HelloData {76
+ daemon: String::new(),77
+ version: String::new(),78
+ protocol: 0,79
+ },80
+ socket_path: path.to_path_buf(),81
+ };82
+83
+ me.handshake()?;84
+ Ok(me)85
+ }86
+87
+ fn handshake(&mut self) -> Result<(), ClientError> {88
+ let frame: ServerFrame = self.codec.read(&mut self.reader)?;89
+ match frame {90
+ ServerFrame::Event(ev)91
+ if ev.topic == Topic::Control && ev.event.as_str() == "hello" =>92
+ {93
+ let hello: HelloData =94
+ serde_json::from_value(ev.data).map_err(ClientError::DecodeResult)?;95
+ self.hello = hello;96
+ }97
+ ServerFrame::Event(ev) => {98
+ return Err(ClientError::BadHello(format!(99
+ "{} event on {}",100
+ ev.event, ev.topic101
+ )))102
+ }103
+ ServerFrame::Response(r) => {104
+ return Err(ClientError::BadHello(format!("response id={}", r.id)))105
+ }106
+ }107
+108
+ // best effort for older daemons109
+ self.send_raw(Op::Hello {110
+ protocol: headroom_ipc::PROTOCOL_VERSION,111
+ })?;112
+ Ok(())113
+ }114
+115
+ /// `hello` payload received on connect.116
+ #[must_use]117
+ pub fn hello(&self) -> &HelloData {118
+ &self.hello119
+ }120
+121
+ /// socket path this client is connected to.122
+ #[must_use]123
+ pub fn socket_path(&self) -> &Path {124
+ &self.socket_path125
+ }126
+127
+ fn alloc_id(&mut self) -> u64 {128
+ let id = self.next_id;129
+ // wrap: u64::MAX requests on one connection won't happen, but be correct130
+ self.next_id = self.next_id.wrapping_add(1);131
+ id132
+ }133
+134
+ /// send a request, block until the paired response arrives.135
+ ///136
+ /// stray events received meanwhile are queued for [`next_event`](Self::next_event).137
+ pub fn send(&mut self, op: Op) -> Result<serde_json::Value, ClientError> {138
+ let payload = self.send_raw(op)?;139
+ match payload {140
+ ResponsePayload::Ok { result } => Ok(result),141
+ ResponsePayload::Err { error } => Err(ClientError::Protocol(error)),142
+ }143
+ }144
+145
+ /// like [`send`](Self::send) but returns the raw [`ResponsePayload`] —146
+ /// keeps a protocol-level error in-band instead of as [`ClientError::Protocol`].147
+ pub fn send_raw(&mut self, op: Op) -> Result<ResponsePayload, ClientError> {148
+ let id = self.alloc_id();149
+ let req = Request::new(id, op);150
+ self.codec.write(&mut self.writer, &req)?;151
+152
+ loop {153
+ let frame: ServerFrame = self.codec.read(&mut self.reader)?;154
+ match frame {155
+ ServerFrame::Response(Response {156
+ id: rid,157
+ payload: _,158
+ }) if rid != id => {159
+ return Err(ClientError::UnknownResponseId(rid));160
+ }161
+ ServerFrame::Response(Response { payload, .. }) => return Ok(payload),162
+ ServerFrame::Event(ev) => {163
+ self.pending_events.push_back(ev);164
+ }165
+ }166
+ }167
+ }168
+169
+ /// block until the next event arrives. drains the queue before reading the socket.170
+ ///171
+ /// an unsolicited response (no matching request) is rejected as172
+ /// [`ClientError::UnknownResponseId`].173
+ pub fn next_event(&mut self) -> Result<Event, ClientError> {174
+ if let Some(ev) = self.pending_events.pop_front() {175
+ return Ok(ev);176
+ }177
+ match self.codec.read::<_, ServerFrame>(&mut self.reader)? {178
+ ServerFrame::Event(ev) => Ok(ev),179
+ ServerFrame::Response(r) => Err(ClientError::UnknownResponseId(r.id)),180
+ }181
+ }182
+183
+ /// pop a queued event without blocking. does **not** read from the socket.184
+ pub fn pending_event(&mut self) -> Option<Event> {185
+ self.pending_events.pop_front()186
+ }187
+188
+ // typed convenience wrappers189
+190
+ fn send_into<T: DeserializeOwned>(&mut self, op: Op) -> Result<T, ClientError> {191
+ let value = self.send(op)?;192
+ serde_json::from_value(value).map_err(ClientError::DecodeResult)193
+ }194
+195
+ /// `status`196
+ pub fn status(&mut self) -> Result<Status, ClientError> {197
+ self.send_into(Op::Status)198
+ }199
+200
+ /// `profile.list`201
+ pub fn profile_list(&mut self) -> Result<Vec<headroom_ipc::ProfileInfo>, ClientError> {202
+ #[derive(serde::Deserialize)]203
+ struct Body {204
+ profiles: Vec<headroom_ipc::ProfileInfo>,205
+ }206
+ let body: Body = self.send_into(Op::ProfileList)?;207
+ Ok(body.profiles)208
+ }209
+210
+ /// `profile.use`211
+ pub fn profile_use(&mut self, name: &str) -> Result<String, ClientError> {212
+ #[derive(serde::Deserialize)]213
+ struct Body {214
+ name: String,215
+ }216
+ let body: Body = self.send_into(Op::ProfileUse {217
+ name: name.to_owned(),218
+ })?;219
+ Ok(body.name)220
+ }221
+222
+ /// `profile.show`223
+ pub fn profile_show(224
+ &mut self,225
+ name: Option<&str>,226
+ ) -> Result<serde_json::Value, ClientError> {227
+ self.send(Op::ProfileShow {228
+ name: name.map(String::from),229
+ })230
+ }231
+232
+ /// `profile.reload`233
+ pub fn profile_reload(&mut self) -> Result<Vec<String>, ClientError> {234
+ #[derive(serde::Deserialize)]235
+ struct Body {236
+ reloaded: Vec<String>,237
+ }238
+ let body: Body = self.send_into(Op::ProfileReload)?;239
+ Ok(body.reloaded)240
+ }241
+242
+ /// `route.list`243
+ pub fn route_list(&mut self) -> Result<headroom_ipc::RouteList, ClientError> {244
+ self.send_into(Op::RouteList)245
+ }246
+247
+ /// `route.set`248
+ pub fn route_set(&mut self, app: &str, to: Route) -> Result<(), ClientError> {249
+ let _: serde_json::Value = self.send(Op::RouteSet {250
+ app: app.to_owned(),251
+ to,252
+ })?;253
+ Ok(())254
+ }255
+256
+ /// `route.unset`257
+ pub fn route_unset(&mut self, app: &str) -> Result<(), ClientError> {258
+ let _: serde_json::Value = self.send(Op::RouteUnset {259
+ app: app.to_owned(),260
+ })?;261
+ Ok(())262
+ }263
+264
+ /// `route.stream`265
+ pub fn route_stream(&mut self, node_id: u32, to: Route) -> Result<(), ClientError> {266
+ let _: serde_json::Value = self.send(Op::RouteStream { node_id, to })?;267
+ Ok(())268
+ }269
+270
+ /// `setting.get`271
+ pub fn setting_get(&mut self, key: &str) -> Result<serde_json::Value, ClientError> {272
+ #[derive(serde::Deserialize)]273
+ struct Body {274
+ #[allow(dead_code)]275
+ key: String,276
+ value: serde_json::Value,277
+ }278
+ let body: Body = self.send_into(Op::SettingGet {279
+ key: key.to_owned(),280
+ })?;281
+ Ok(body.value)282
+ }283
+284
+ /// `setting.set`285
+ pub fn setting_set(286
+ &mut self,287
+ key: &str,288
+ value: serde_json::Value,289
+ ) -> Result<(), ClientError> {290
+ let _: serde_json::Value = self.send(Op::SettingSet {291
+ key: key.to_owned(),292
+ value,293
+ })?;294
+ Ok(())295
+ }296
+297
+ /// `setting.clear` — returns whether an override was present.298
+ pub fn setting_clear(&mut self, key: &str) -> Result<bool, ClientError> {299
+ #[derive(serde::Deserialize)]300
+ struct Body {301
+ cleared: bool,302
+ }303
+ let body: Body = self.send_into(Op::SettingClear {304
+ key: key.to_owned(),305
+ })?;306
+ Ok(body.cleared)307
+ }308
+309
+ /// `setting.reset` — returns how many overrides were cleared.310
+ pub fn setting_reset(&mut self) -> Result<usize, ClientError> {311
+ #[derive(serde::Deserialize)]312
+ struct Body {313
+ cleared: usize,314
+ }315
+ let body: Body = self.send_into(Op::SettingReset)?;316
+ Ok(body.cleared)317
+ }318
+319
+ /// `bypass.set`320
+ pub fn bypass_set(&mut self, enabled: bool) -> Result<(), ClientError> {321
+ let _: serde_json::Value = self.send(Op::BypassSet { enabled })?;322
+ Ok(())323
+ }324
+325
+ /// `per-app.list`326
+ pub fn layer_a_list(327
+ &mut self,328
+ ) -> Result<Vec<headroom_ipc::LayerASnapshot>, ClientError> {329
+ #[derive(serde::Deserialize)]330
+ struct Body {331
+ layer_a: Vec<headroom_ipc::LayerASnapshot>,332
+ }333
+ let body: Body = self.send_into(Op::LayerAList)?;334
+ Ok(body.layer_a)335
+ }336
+337
+ /// `per-app.set`338
+ pub fn per_app_set(&mut self, app: &str, enabled: bool) -> Result<(), ClientError> {339
+ let _: serde_json::Value = self.send(Op::PerAppSet {340
+ app: app.to_owned(),341
+ enabled,342
+ })?;343
+ Ok(())344
+ }345
+346
+ /// `per-app.master`347
+ pub fn per_app_master(&mut self, enabled: bool) -> Result<(), ClientError> {348
+ let _: serde_json::Value = self.send(Op::PerAppMaster { enabled })?;349
+ Ok(())350
+ }351
+352
+ /// `per-app.reset`353
+ pub fn layer_a_reset(&mut self, node_id: u32) -> Result<(), ClientError> {354
+ let _: serde_json::Value = self.send(Op::LayerAReset { node_id })?;355
+ Ok(())356
+ }357
+358
+ /// `subscribe`359
+ pub fn subscribe(&mut self, topics: &[Topic]) -> Result<Vec<Topic>, ClientError> {360
+ #[derive(serde::Deserialize)]361
+ struct Body {362
+ subscribed: Vec<Topic>,363
+ }364
+ let body: Body = self.send_into(Op::Subscribe {365
+ topics: topics.to_vec(),366
+ })?;367
+ Ok(body.subscribed)368
+ }369
+370
+ /// `unsubscribe`371
+ pub fn unsubscribe(&mut self, topics: &[Topic]) -> Result<Vec<Topic>, ClientError> {372
+ #[derive(serde::Deserialize)]373
+ struct Body {374
+ unsubscribed: Vec<Topic>,375
+ }376
+ let body: Body = self.send_into(Op::Unsubscribe {377
+ topics: topics.to_vec(),378
+ })?;379
+ Ok(body.unsubscribed)380
+ }381
+}382
+383
+#[cfg(test)]384
+mod tests {385
+ use super::*;386
+ use std::io::{BufReader, BufWriter};387
+ use std::os::unix::net::UnixStream;388
+ use std::thread;389
+390
+ use headroom_ipc::{Codec, Event, HelloData, Op, Request, Response, ServerFrame, Topic};391
+392
+ /// A tiny in-process server that runs on the other end of a393
+ /// `UnixStream::pair`. Knows just enough to exercise the client.394
+ fn spawn_test_server() -> (UnixStream, thread::JoinHandle<()>) {395
+ let (a, b) = UnixStream::pair().unwrap();396
+ let server_handle = thread::spawn(move || {397
+ let codec = Codec::new();398
+ let read_side = b.try_clone().unwrap();399
+ let mut reader = BufReader::new(read_side);400
+ let mut writer = BufWriter::new(b);401
+402
+ // Send hello.403
+ let hello = Event::new(404
+ Topic::Control,405
+ "hello",406
+ &HelloData {407
+ daemon: "headroom".into(),408
+ version: "0.1.0-test".into(),409
+ protocol: headroom_ipc::PROTOCOL_VERSION,410
+ },411
+ )412
+ .unwrap();413
+ codec414
+ .write(&mut writer, &ServerFrame::Event(hello))415
+ .unwrap();416
+417
+ // Serve one round.418
+ loop {419
+ let req: Request = match codec.read(&mut reader) {420
+ Ok(r) => r,421
+ Err(_) => return,422
+ };423
+ let resp = match req.op {424
+ Op::Status => Response::ok(425
+ req.id,426
+ &serde_json::json!({427
+ "version": "0.1.0-test",428
+ "protocol": headroom_ipc::PROTOCOL_VERSION,429
+ "uptime_s": 0u64,430
+ "profile": "default",431
+ "bypass": false,432
+ "sinks": {433
+ "processed": {"ready": false},434
+ "real": {"ready": false},435
+ },436
+ "streams": []437
+ }),438
+ )439
+ .unwrap(),440
+ Op::ProfileUse { name } => {441
+ Response::ok(req.id, &serde_json::json!({ "name": name })).unwrap()442
+ }443
+ Op::Subscribe { topics } => {444
+ // Acknowledge, then push one event of each445
+ // subscribed topic so the client can demonstrate446
+ // event handling.447
+ let body = serde_json::json!({ "subscribed": &topics });448
+ let resp = Response::ok(req.id, &body).unwrap();449
+ codec450
+ .write(&mut writer, &ServerFrame::Response(resp.clone()))451
+ .unwrap();452
+453
+ for t in topics {454
+ let ev = Event::new(t, "tick", &serde_json::json!({})).unwrap();455
+ codec.write(&mut writer, &ServerFrame::Event(ev)).unwrap();456
+ }457
+ continue;458
+ }459
+ _ => Response::ok(req.id, &serde_json::Value::Null).unwrap(),460
+ };461
+ codec462
+ .write(&mut writer, &ServerFrame::Response(resp))463
+ .unwrap();464
+ }465
+ });466
+ (a, server_handle)467
+ }468
+469
+ fn client_on(stream: UnixStream) -> Client {470
+ let reader_half = stream.try_clone().unwrap();471
+ let writer_half = stream;472
+ let mut me = Client {473
+ reader: BufReader::new(reader_half),474
+ writer: BufWriter::new(writer_half),475
+ codec: Codec::new(),476
+ next_id: 1,477
+ pending_events: VecDeque::new(),478
+ hello: HelloData {479
+ daemon: String::new(),480
+ version: String::new(),481
+ protocol: 0,482
+ },483
+ socket_path: PathBuf::from("<test>"),484
+ };485
+ me.handshake().unwrap();486
+ me487
+ }488
+489
+ #[test]490
+ fn handshake_then_status() {491
+ let (client_sock, _server) = spawn_test_server();492
+ let mut client = client_on(client_sock);493
+ assert_eq!(client.hello().daemon, "headroom");494
+ assert_eq!(client.hello().protocol, headroom_ipc::PROTOCOL_VERSION);495
+496
+ let status = client.status().unwrap();497
+ assert_eq!(status.profile, "default");498
+ assert!(!status.bypass);499
+ }500
+501
+ #[test]502
+ fn profile_use_returns_name() {503
+ let (client_sock, _server) = spawn_test_server();504
+ let mut client = client_on(client_sock);505
+ let name = client.profile_use("night").unwrap();506
+ assert_eq!(name, "night");507
+ }508
+509
+ #[test]510
+ fn subscribe_then_consume_event() {511
+ let (client_sock, _server) = spawn_test_server();512
+ let mut client = client_on(client_sock);513
+ let acked = client.subscribe(&[Topic::Meters]).unwrap();514
+ assert_eq!(acked, vec![Topic::Meters]);515
+516
+ let ev = client.next_event().unwrap();517
+ assert_eq!(ev.topic, Topic::Meters);518
+ assert_eq!(ev.event, "tick");519
+ }520
+521
+ #[test]522
+ fn events_interleaved_during_request_are_queued() {523
+ // The test server pushes events *after* the subscribe response,524
+ // so let's check that requesting another op afterwards drains525
+ // them through the queue.526
+ let (client_sock, _server) = spawn_test_server();527
+ let mut client = client_on(client_sock);528
+ client.subscribe(&[Topic::Meters, Topic::Profile]).unwrap();529
+530
+ // Now issue another request. The server hasn't sent the events531
+ // until we read more, but our client will keep reading.532
+ let status = client.status().unwrap();533
+ assert_eq!(status.profile, "default");534
+535
+ // We may have buffered events from the prior subscribe and from536
+ // any in flight; drain them.537
+ let mut topics = Vec::new();538
+ while let Some(ev) = client.pending_event() {539
+ topics.push(ev.topic);540
+ }541
+ // The events arrived between the subscribe-ack and the status542
+ // response; both should be queued.543
+ assert!(topics.contains(&Topic::Meters));544
+ assert!(topics.contains(&Topic::Profile));545
+ }546
+}@@ -0,0 +1,15 @@
1
+//! blocking client for the headroom control protocol. entry point: [`Client`].2
+3
+#![forbid(unsafe_code)]4
+#![warn(missing_docs)]5
+6
+mod client;7
+8
+pub use client::{Client, ClientError};9
+10
+pub use headroom_ipc::{11
+ default_socket_path, Codec, DaemonEvent, Error as IpcError, ErrorCode, Event, HelloData,12
+ LayerALevel, LayerASnapshot, MeterTick, Op, ProfileEvent, ProfileInfo, ProtoError, Request,13
+ Response, ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent,14
+ ServerFrame, SinkInfo, Sinks, Status, StreamRoute, Topic, PROTOCOL_VERSION,15
+};@@ -0,0 +1,14 @@
1
+[package]2
+name = "headroom-ipc"3
+description = "automatic loudness and per-app volume control (pipewire)"4
+version.workspace = true5
+edition.workspace = true6
+license = "MPL-2.0"7
+homepage.workspace = true8
+repository.workspace = true9
+authors.workspace = true10
+11
+[dependencies]12
+serde = { workspace = true }13
+serde_json = { workspace = true }14
+thiserror = { workspace = true }@@ -0,0 +1,248 @@
1
+//! length-prefixed json framing: 4-byte big-endian length, then that many bytes of utf-8 json.2
+3
+use std::io::{Read, Write};4
+5
+use serde::{de::DeserializeOwned, Serialize};6
+7
+use crate::error::Error;8
+9
+/// default frame payload cap.10
+pub const DEFAULT_MAX_FRAME_BYTES: usize = 1024 * 1024; // 1 MiB11
+12
+/// floor on the frame cap; a smaller cap is misuse, not normal traffic.13
+pub const MIN_MAX_FRAME_BYTES: usize = 256;14
+15
+/// stateless framing codec; owns no buffers, callers supply reader/writer.16
+#[derive(Debug, Clone, Copy)]17
+pub struct Codec {18
+ max_frame_bytes: usize,19
+}20
+21
+impl Default for Codec {22
+ fn default() -> Self {23
+ Self {24
+ max_frame_bytes: DEFAULT_MAX_FRAME_BYTES,25
+ }26
+ }27
+}28
+29
+impl Codec {30
+ #[must_use]31
+ pub fn new() -> Self {32
+ Self::default()33
+ }34
+35
+ /// cap clamped up to [`MIN_MAX_FRAME_BYTES`].36
+ #[must_use]37
+ pub fn with_max_frame_size(bytes: usize) -> Self {38
+ Self {39
+ max_frame_bytes: bytes.max(MIN_MAX_FRAME_BYTES),40
+ }41
+ }42
+43
+ #[must_use]44
+ pub fn max_frame_bytes(self) -> usize {45
+ self.max_frame_bytes46
+ }47
+48
+ /// # Errors49
+ /// - [`Error::Json`] on serialize failure.50
+ /// - [`Error::FrameTooLarge`] if the serialized form exceeds the cap.51
+ /// - [`Error::Io`] on write failure.52
+ pub fn write<W: Write, T: Serialize>(self, mut w: W, msg: &T) -> Result<(), Error> {53
+ // serialize first to know the exact length54
+ let buf = serde_json::to_vec(msg)?;55
+ if buf.len() > self.max_frame_bytes {56
+ return Err(Error::FrameTooLarge {57
+ actual: buf.len(),58
+ limit: self.max_frame_bytes,59
+ });60
+ }61
+ let len = u32::try_from(buf.len()).expect("buf.len() <= max_frame_bytes <= u32::MAX");62
+ w.write_all(&len.to_be_bytes())?;63
+ w.write_all(&buf)?;64
+ w.flush()?;65
+ Ok(())66
+ }67
+68
+ /// # Errors69
+ /// - [`Error::Closed`] on EOF before any length-prefix byte (graceful close).70
+ /// - [`Error::Io`] on partial reads or other i/o failure.71
+ /// - [`Error::FrameTooLarge`] if the announced length exceeds the cap.72
+ /// - [`Error::Json`] on deserialize failure.73
+ pub fn read<R: Read, T: DeserializeOwned>(self, mut r: R) -> Result<T, Error> {74
+ let mut len_buf = [0u8; 4];75
+ match read_full_or_eof(&mut r, &mut len_buf)? {76
+ ReadOutcome::Full => {}77
+ ReadOutcome::ZeroAtStart => return Err(Error::Closed),78
+ }79
+ let len = u32::from_be_bytes(len_buf) as usize;80
+ if len > self.max_frame_bytes {81
+ return Err(Error::FrameTooLarge {82
+ actual: len,83
+ limit: self.max_frame_bytes,84
+ });85
+ }86
+ let mut buf = vec![0u8; len];87
+ r.read_exact(&mut buf)?;88
+ Ok(serde_json::from_slice(&buf)?)89
+ }90
+}91
+92
+enum ReadOutcome {93
+ Full,94
+ ZeroAtStart,95
+}96
+97
+fn read_full_or_eof<R: Read>(r: &mut R, buf: &mut [u8]) -> Result<ReadOutcome, std::io::Error> {98
+ let mut read = 0;99
+ while read < buf.len() {100
+ match r.read(&mut buf[read..]) {101
+ Ok(0) => {102
+ if read == 0 {103
+ return Ok(ReadOutcome::ZeroAtStart);104
+ }105
+ return Err(std::io::Error::new(106
+ std::io::ErrorKind::UnexpectedEof,107
+ "eof mid-length-prefix",108
+ ));109
+ }110
+ Ok(n) => read += n,111
+ Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,112
+ Err(e) => return Err(e),113
+ }114
+ }115
+ Ok(ReadOutcome::Full)116
+}117
+118
+#[cfg(test)]119
+mod tests {120
+ use super::*;121
+ use crate::{Event, Op, Request, Response, ServerFrame, Topic};122
+ use std::io::Cursor;123
+124
+ #[test]125
+ fn write_read_request_roundtrip() {126
+ let codec = Codec::new();127
+ let req = Request::new(128
+ 42,129
+ Op::ProfileUse {130
+ name: "night".into(),131
+ },132
+ );133
+134
+ let mut buf = Vec::new();135
+ codec.write(&mut buf, &req).unwrap();136
+137
+ let mut cur = Cursor::new(&buf);138
+ let back: Request = codec.read(&mut cur).unwrap();139
+ assert_eq!(back, req);140
+ }141
+142
+ #[test]143
+ fn server_frame_round_trip_response() {144
+ let codec = Codec::new();145
+ let resp = Response::ok(1, &serde_json::json!({"name": "default"})).unwrap();146
+ let frame = ServerFrame::Response(resp.clone());147
+148
+ let mut buf = Vec::new();149
+ codec.write(&mut buf, &frame).unwrap();150
+ let mut cur = Cursor::new(&buf);151
+ let back: ServerFrame = codec.read(&mut cur).unwrap();152
+ match back {153
+ ServerFrame::Response(r) => assert_eq!(r, resp),154
+ ServerFrame::Event(_) => panic!("decoded as event"),155
+ }156
+ }157
+158
+ #[test]159
+ fn server_frame_round_trip_event() {160
+ let codec = Codec::new();161
+ let ev = Event::new(Topic::Daemon, "shutdown", &serde_json::json!({})).unwrap();162
+ let frame = ServerFrame::Event(ev.clone());163
+164
+ let mut buf = Vec::new();165
+ codec.write(&mut buf, &frame).unwrap();166
+ let mut cur = Cursor::new(&buf);167
+ let back: ServerFrame = codec.read(&mut cur).unwrap();168
+ match back {169
+ ServerFrame::Event(e) => assert_eq!(e, ev),170
+ ServerFrame::Response(_) => panic!("decoded as response"),171
+ }172
+ }173
+174
+ #[test]175
+ fn rejects_oversized_frames_on_write() {176
+ let codec = Codec::with_max_frame_size(MIN_MAX_FRAME_BYTES);177
+ // A big string that will serialize > 256 bytes.178
+ let req = Request::new(179
+ 1,180
+ Op::SettingSet {181
+ key: "x".into(),182
+ value: serde_json::Value::String("a".repeat(1024)),183
+ },184
+ );185
+ let mut buf = Vec::new();186
+ let err = codec.write(&mut buf, &req).unwrap_err();187
+ assert!(matches!(err, Error::FrameTooLarge { .. }));188
+ }189
+190
+ #[test]191
+ fn rejects_oversized_frames_on_read() {192
+ let codec = Codec::with_max_frame_size(MIN_MAX_FRAME_BYTES);193
+ // Hand-craft a length prefix that exceeds the cap.194
+ let mut buf = Vec::new();195
+ let bad_len: u32 = MIN_MAX_FRAME_BYTES as u32 + 1;196
+ buf.extend_from_slice(&bad_len.to_be_bytes());197
+ // No need to follow with payload; we expect early rejection.198
+ let mut cur = Cursor::new(&buf);199
+ let err = codec.read::<_, serde_json::Value>(&mut cur).unwrap_err();200
+ assert!(matches!(err, Error::FrameTooLarge { .. }));201
+ }202
+203
+ #[test]204
+ fn graceful_eof_at_frame_boundary() {205
+ let codec = Codec::new();206
+ let mut cur = Cursor::new(Vec::<u8>::new());207
+ let err = codec.read::<_, Request>(&mut cur).unwrap_err();208
+ assert!(matches!(err, Error::Closed));209
+ }210
+211
+ #[test]212
+ fn mid_frame_eof_is_io_error() {213
+ let codec = Codec::new();214
+ // Half a length prefix.215
+ let mut cur = Cursor::new(vec![0u8, 0u8]);216
+ let err = codec.read::<_, Request>(&mut cur).unwrap_err();217
+ assert!(matches!(err, Error::Io(_)));218
+ }219
+220
+ #[test]221
+ fn rejects_invalid_json_payload() {222
+ let codec = Codec::new();223
+ let payload = b"not-json";224
+ let mut buf = Vec::new();225
+ buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());226
+ buf.extend_from_slice(payload);227
+ let mut cur = Cursor::new(&buf);228
+ let err = codec.read::<_, Request>(&mut cur).unwrap_err();229
+ assert!(matches!(err, Error::Json(_)));230
+ }231
+232
+ #[test]233
+ fn back_to_back_frames() {234
+ let codec = Codec::new();235
+ let a = Request::new(1, Op::Status);236
+ let b = Request::new(2, Op::ProfileList);237
+238
+ let mut buf = Vec::new();239
+ codec.write(&mut buf, &a).unwrap();240
+ codec.write(&mut buf, &b).unwrap();241
+242
+ let mut cur = Cursor::new(&buf);243
+ let ra: Request = codec.read(&mut cur).unwrap();244
+ let rb: Request = codec.read(&mut cur).unwrap();245
+ assert_eq!(ra, a);246
+ assert_eq!(rb, b);247
+ }248
+}@@ -0,0 +1,108 @@
1
+//! error types.2
+3
+use serde::{Deserialize, Serialize};4
+5
+/// wire error code in `error.code`. adding variants is non-breaking; removing/renaming bumps `PROTOCOL_VERSION`.6
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]7
+#[serde(rename_all = "SCREAMING_SNAKE_CASE")]8
+#[non_exhaustive]9
+pub enum ErrorCode {10
+ /// malformed framing or non-json payload; connection is closed.11
+ InvalidFrame,12
+ /// valid json, no known message shape.13
+ InvalidMessage,14
+ /// `op` names no known operation.15
+ UnknownOp,16
+ /// `args` missing a field, wrong type, or out of range.17
+ InvalidArgs,18
+ /// named profile / app / stream / setting key does not exist.19
+ NotFound,20
+ /// would violate an invariant.21
+ Conflict,22
+ /// daemon transiently cannot serve the request.23
+ Busy,24
+ /// server-side bug; `message` carries detail.25
+ Internal,26
+}27
+28
+impl ErrorCode {29
+ #[must_use]30
+ pub fn as_str(self) -> &'static str {31
+ match self {32
+ ErrorCode::InvalidFrame => "INVALID_FRAME",33
+ ErrorCode::InvalidMessage => "INVALID_MESSAGE",34
+ ErrorCode::UnknownOp => "UNKNOWN_OP",35
+ ErrorCode::InvalidArgs => "INVALID_ARGS",36
+ ErrorCode::NotFound => "NOT_FOUND",37
+ ErrorCode::Conflict => "CONFLICT",38
+ ErrorCode::Busy => "BUSY",39
+ ErrorCode::Internal => "INTERNAL",40
+ }41
+ }42
+}43
+44
+impl std::fmt::Display for ErrorCode {45
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {46
+ f.write_str(self.as_str())47
+ }48
+}49
+50
+/// error payload inside a `Response`.51
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]52
+pub struct ProtoError {53
+ pub code: ErrorCode,54
+ /// not stable; do not pattern match.55
+ pub message: String,56
+}57
+58
+impl ProtoError {59
+ #[must_use]60
+ pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {61
+ Self {62
+ code,63
+ message: message.into(),64
+ }65
+ }66
+}67
+68
+impl std::fmt::Display for ProtoError {69
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {70
+ write!(f, "{}: {}", self.code, self.message)71
+ }72
+}73
+74
+impl std::error::Error for ProtoError {}75
+76
+/// errors from the codec and helpers.77
+#[derive(Debug, thiserror::Error)]78
+pub enum Error {79
+ #[error("io: {0}")]80
+ Io(#[from] std::io::Error),81
+82
+ #[error("json: {0}")]83
+ Json(#[from] serde_json::Error),84
+85
+ #[error("frame too large: {actual} bytes (limit {limit})")]86
+ FrameTooLarge { actual: usize, limit: usize },87
+88
+ #[error("protocol: {0}")]89
+ Protocol(#[from] ProtoError),90
+91
+ /// e.g. a response with a mismatched id.92
+ #[error("unexpected frame: {0}")]93
+ UnexpectedFrame(String),94
+95
+ #[error("connection closed")]96
+ Closed,97
+}98
+99
+impl Error {100
+ /// true when the connection should be torn down.101
+ #[must_use]102
+ pub fn is_fatal(&self) -> bool {103
+ matches!(104
+ self,105
+ Error::Io(_) | Error::FrameTooLarge { .. } | Error::Closed106
+ )107
+ }108
+}@@ -0,0 +1,57 @@
1
+//! headroom control-protocol types and framing. spec: `IPC.md` (repo root); this is its rust binding.2
+3
+#![forbid(unsafe_code)]4
+#![warn(missing_docs)]5
+6
+mod codec;7
+mod error;8
+mod proto;9
+10
+pub use codec::{Codec, DEFAULT_MAX_FRAME_BYTES, MIN_MAX_FRAME_BYTES};11
+pub use error::{Error, ErrorCode, ProtoError};12
+pub use proto::{13
+ DaemonEvent, Event, HelloData, LayerALevel, LayerASnapshot, MeterTick, Op, ProfileEvent,14
+ ProfileInfo, Request, Response, ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch,15
+ RoutingEvent, ServerFrame, SinkInfo, Sinks, Status, StreamRoute, Topic,16
+};17
+18
+/// wire version; bumped only on incompatible changes.19
+pub const PROTOCOL_VERSION: u32 = 1;20
+21
+/// socket dir stem; full path `${XDG_RUNTIME_DIR}/headroom/control.sock`, falling back to `/run/user/$UID/...`.22
+pub const DEFAULT_SOCKET_DIR: &str = "headroom";23
+24
+pub const DEFAULT_SOCKET_NAME: &str = "control.sock";25
+26
+/// honours `XDG_RUNTIME_DIR`, else falls back to `/run/user/$UID/...`. `None` when neither is determinable.27
+#[must_use]28
+pub fn default_socket_path() -> Option<std::path::PathBuf> {29
+ use std::path::PathBuf;30
+ if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {31
+ if !dir.is_empty() {32
+ return Some(33
+ PathBuf::from(dir)34
+ .join(DEFAULT_SOCKET_DIR)35
+ .join(DEFAULT_SOCKET_NAME),36
+ );37
+ }38
+ }39
+ // uid from /proc/self/status: nix-free, dependency-light40
+ let uid = read_self_uid()?;41
+ Some(42
+ PathBuf::from(format!("/run/user/{uid}"))43
+ .join(DEFAULT_SOCKET_DIR)44
+ .join(DEFAULT_SOCKET_NAME),45
+ )46
+}47
+48
+fn read_self_uid() -> Option<u32> {49
+ let s = std::fs::read_to_string("/proc/self/status").ok()?;50
+ for line in s.lines() {51
+ if let Some(rest) = line.strip_prefix("Uid:") {52
+ let first = rest.split_whitespace().next()?;53
+ return first.parse().ok();54
+ }55
+ }56
+ None57
+}@@ -0,0 +1,649 @@
1
+//! protocol message types. spec: `IPC.md`.2
+3
+use std::collections::BTreeMap;4
+5
+use serde::{Deserialize, Serialize};6
+use serde_json::Value;7
+8
+use crate::error::ProtoError;9
+10
+/// subscription topic.11
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]12
+#[serde(rename_all = "kebab-case")]13
+#[non_exhaustive]14
+pub enum Topic {15
+ /// loudness / peak / GR telemetry.16
+ Meters,17
+ Profile,18
+ Routing,19
+ Daemon,20
+ /// synthetic, for `hello`; clients never subscribe.21
+ Control,22
+}23
+24
+impl Topic {25
+ #[must_use]26
+ pub fn as_str(self) -> &'static str {27
+ match self {28
+ Topic::Meters => "meters",29
+ Topic::Profile => "profile",30
+ Topic::Routing => "routing",31
+ Topic::Daemon => "daemon",32
+ Topic::Control => "control",33
+ }34
+ }35
+36
+ /// topics clients may subscribe to (excludes `Control`).37
+ #[must_use]38
+ pub const fn subscribable() -> &'static [Topic] {39
+ &[Topic::Meters, Topic::Profile, Topic::Routing, Topic::Daemon]40
+ }41
+}42
+43
+impl std::fmt::Display for Topic {44
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {45
+ f.write_str(self.as_str())46
+ }47
+}48
+49
+/// routing decision for one app or stream.50
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]51
+#[serde(rename_all = "lowercase")]52
+pub enum Route {53
+ /// through the processed (filtered) sink.54
+ Processed,55
+ /// straight to the real hardware sink; no processing, no extra graph hop.56
+ Bypass,57
+}58
+59
+impl Route {60
+ #[must_use]61
+ pub fn as_str(self) -> &'static str {62
+ match self {63
+ Route::Processed => "processed",64
+ Route::Bypass => "bypass",65
+ }66
+ }67
+}68
+69
+impl std::fmt::Display for Route {70
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {71
+ f.write_str(self.as_str())72
+ }73
+}74
+75
+/// all operations. wire form `{ "op": "<name>", "args": <args> }`; arg-less ops omit `args`.76
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]77
+#[serde(tag = "op", content = "args")]78
+#[non_exhaustive]79
+pub enum Op {80
+ #[serde(rename = "status")]81
+ Status,82
+83
+ #[serde(rename = "profile.list")]84
+ ProfileList,85
+86
+ #[serde(rename = "profile.use")]87
+ ProfileUse { name: String },88
+89
+ /// `name` omitted ⇒ active profile.90
+ #[serde(rename = "profile.show")]91
+ ProfileShow {92
+ #[serde(default, skip_serializing_if = "Option::is_none")]93
+ name: Option<String>,94
+ },95
+96
+ /// reload all profile files from disk.97
+ #[serde(rename = "profile.reload")]98
+ ProfileReload,99
+100
+ #[serde(rename = "route.list")]101
+ RouteList,102
+103
+ /// add/replace a persistent routing rule.104
+ #[serde(rename = "route.set")]105
+ RouteSet {106
+ /// typically `application.process.binary`.107
+ app: String,108
+ to: Route,109
+ },110
+111
+ #[serde(rename = "route.unset")]112
+ RouteUnset { app: String },113
+114
+ /// one-shot reroute of a live stream.115
+ #[serde(rename = "route.stream")]116
+ RouteStream { node_id: u32, to: Route },117
+118
+ #[serde(rename = "setting.get")]119
+ SettingGet {120
+ /// dotted key, e.g. `compressor.threshold_db`.121
+ key: String,122
+ },123
+124
+ #[serde(rename = "setting.set")]125
+ SettingSet { key: String, value: Value },126
+127
+ #[serde(rename = "setting.list")]128
+ SettingList,129
+130
+ /// revert one key to the active profile's value.131
+ #[serde(rename = "setting.clear")]132
+ SettingClear { key: String },133
+134
+ /// revert all settings to the active profile; leaves route/per-app/bypass overrides intact.135
+ #[serde(rename = "setting.reset")]136
+ SettingReset,137
+138
+ /// global bypass kill switch.139
+ #[serde(rename = "bypass.set")]140
+ BypassSet { enabled: bool },141
+142
+ /// per-app (Layer A) controller state for managed streams.143
+ #[serde(rename = "per-app.list")]144
+ LayerAList,145
+146
+ /// Layer A enable for one app (persistent overlay override).147
+ #[serde(rename = "per-app.set")]148
+ PerAppSet {149
+ /// process_binary or application_name.150
+ app: String,151
+ enabled: bool,152
+ },153
+154
+ /// Layer A master switch (persistent overlay override).155
+ #[serde(rename = "per-app.master")]156
+ PerAppMaster { enabled: bool },157
+158
+ /// clear a stream's deference lock (user-ceiling / strict-mode) so the controller resumes.159
+ #[serde(rename = "per-app.reset")]160
+ LayerAReset { node_id: u32 },161
+162
+ #[serde(rename = "subscribe")]163
+ Subscribe { topics: Vec<Topic> },164
+165
+ #[serde(rename = "unsubscribe")]166
+ Unsubscribe { topics: Vec<Topic> },167
+168
+ /// optional client protocol handshake169
+ #[serde(rename = "hello")]170
+ Hello { protocol: u32 },171
+}172
+173
+impl Op {174
+ /// canonical wire name.175
+ #[must_use]176
+ pub fn name(&self) -> &'static str {177
+ match self {178
+ Op::Status => "status",179
+ Op::ProfileList => "profile.list",180
+ Op::ProfileUse { .. } => "profile.use",181
+ Op::ProfileShow { .. } => "profile.show",182
+ Op::ProfileReload => "profile.reload",183
+ Op::RouteList => "route.list",184
+ Op::RouteSet { .. } => "route.set",185
+ Op::RouteUnset { .. } => "route.unset",186
+ Op::RouteStream { .. } => "route.stream",187
+ Op::SettingGet { .. } => "setting.get",188
+ Op::SettingSet { .. } => "setting.set",189
+ Op::SettingList => "setting.list",190
+ Op::SettingClear { .. } => "setting.clear",191
+ Op::SettingReset => "setting.reset",192
+ Op::BypassSet { .. } => "bypass.set",193
+ Op::LayerAList => "per-app.list",194
+ Op::PerAppSet { .. } => "per-app.set",195
+ Op::PerAppMaster { .. } => "per-app.master",196
+ Op::LayerAReset { .. } => "per-app.reset",197
+ Op::Subscribe { .. } => "subscribe",198
+ Op::Unsubscribe { .. } => "unsubscribe",199
+ Op::Hello { .. } => "hello",200
+ }201
+ }202
+}203
+204
+/// client-to-server request.205
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]206
+pub struct Request {207
+ /// client-chosen; echoed in the paired response.208
+ pub id: u64,209
+ /// flattened: contributes `op` and optional `args` alongside `id`.210
+ #[serde(flatten)]211
+ pub op: Op,212
+}213
+214
+impl Request {215
+ #[must_use]216
+ pub fn new(id: u64, op: Op) -> Self {217
+ Self { id, op }218
+ }219
+}220
+221
+/// server-to-client response, paired to a request by `id`.222
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]223
+pub struct Response {224
+ pub id: u64,225
+ #[serde(flatten)]226
+ pub payload: ResponsePayload,227
+}228
+229
+impl Response {230
+ /// # Errors231
+ /// serde error if `value` fails to serialize.232
+ pub fn ok<T: Serialize>(id: u64, value: &T) -> Result<Self, serde_json::Error> {233
+ Ok(Self {234
+ id,235
+ payload: ResponsePayload::Ok {236
+ result: serde_json::to_value(value)?,237
+ },238
+ })239
+ }240
+241
+ #[must_use]242
+ pub fn err(id: u64, error: ProtoError) -> Self {243
+ Self {244
+ id,245
+ payload: ResponsePayload::Err { error },246
+ }247
+ }248
+}249
+250
+/// body of a `Response`. on the wire: a single `result` or `error` field inlined alongside `id`.251
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]252
+#[serde(untagged)]253
+pub enum ResponsePayload {254
+ Ok { result: Value },255
+ Err { error: ProtoError },256
+}257
+258
+/// server-to-client event on a subscribed topic.259
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]260
+pub struct Event {261
+ /// e.g. `tick`, `changed`.262
+ pub event: String,263
+ pub topic: Topic,264
+ /// shape depends on `topic` and `event`.265
+ pub data: Value,266
+}267
+268
+impl Event {269
+ /// # Errors270
+ /// serde error if `data` fails to serialize.271
+ pub fn new<T: Serialize>(272
+ topic: Topic,273
+ event: impl Into<String>,274
+ data: &T,275
+ ) -> Result<Self, serde_json::Error> {276
+ Ok(Self {277
+ event: event.into(),278
+ topic,279
+ data: serde_json::to_value(data)?,280
+ })281
+ }282
+}283
+284
+/// one frame as received from the server: paired response or fan-out event.285
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]286
+#[serde(untagged)]287
+pub enum ServerFrame {288
+ Response(Response),289
+ Event(Event),290
+}291
+292
+/// result body of `status`.293
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]294
+pub struct Status {295
+ /// semver.296
+ pub version: String,297
+ pub protocol: u32,298
+ pub uptime_s: u64,299
+ pub profile: String,300
+ pub bypass: bool,301
+ /// Layer A master switch (per-app level control, global).302
+ #[serde(default)]303
+ pub per_app: bool,304
+ pub sinks: Sinks,305
+ pub streams: Vec<StreamRoute>,306
+ /// per-app (Layer A) controller state; empty when not managing anything.307
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]308
+ pub layer_a: Vec<LayerASnapshot>,309
+ /// non-fatal warnings (typically profile-load: TOML parse errors, active profile missing on disk).310
+ /// reflects the last successful load/reload; empty when healthy.311
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]312
+ pub warnings: Vec<String>,313
+ /// user-overlay setting overrides (dotted key → value). these shadow the active profile and314
+ /// persist across `profile.use`, so a stale `headroom set` can silently override it315
+ /// (e.g. `agc.enabled=false`). surfaced so clients can flag it.316
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]317
+ pub setting_overrides: BTreeMap<String, Value>,318
+}319
+320
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]321
+pub struct Sinks {322
+ /// the processed virtual sink; the only sink headroom creates.323
+ pub processed: SinkInfo,324
+ /// hardware sink headroom forwards to, and where bypassed streams go directly.325
+ pub real: SinkInfo,326
+}327
+328
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]329
+pub struct SinkInfo {330
+ #[serde(default, skip_serializing_if = "Option::is_none")]331
+ pub node_id: Option<u32>,332
+ #[serde(default, skip_serializing_if = "Option::is_none")]333
+ pub name: Option<String>,334
+ /// linked and accepting audio.335
+ #[serde(default)]336
+ pub ready: bool,337
+ /// native rate (Hz). filter matches the *real* sink's rate to skip the output-edge resample;338
+ /// the processed sink advertises whatever rate the filter currently runs at.339
+ #[serde(default, skip_serializing_if = "Option::is_none")]340
+ pub sample_rate: Option<u32>,341
+}342
+343
+/// one playback stream and its route.344
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]345
+pub struct StreamRoute {346
+ pub node_id: u32,347
+ /// typically `application.process.binary`.348
+ pub app: String,349
+ pub route: Route,350
+}351
+352
+/// per-app (Layer A) controller state for one managed stream; on `status` and `per-app.list`.353
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]354
+pub struct LayerASnapshot {355
+ pub node_id: u32,356
+ pub app: String,357
+ /// true while a tap + controller is actively managing the stream.358
+ pub managed: bool,359
+ /// last linear volume written (1.0 = unity).360
+ pub volume_lin: f32,361
+ /// asserted gain reduction in dB (`>= 0`; `0` = no cut).362
+ pub reduction_db: f32,363
+ /// user-set ceiling (linear) when ceiling-mode deference is active.364
+ #[serde(default, skip_serializing_if = "Option::is_none")]365
+ pub user_ceiling_lin: Option<f32>,366
+ /// strict-mode deference has locked the controller until `per-app.reset`.367
+ pub deferred: bool,368
+}369
+370
+/// entry in `profile.list`.371
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]372
+pub struct ProfileInfo {373
+ pub name: String,374
+ pub active: bool,375
+ pub description: String,376
+}377
+378
+/// result body of `route.list`.379
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]380
+pub struct RouteList {381
+ /// in evaluation order.382
+ pub rules: Vec<RouteRule>,383
+ pub current: Vec<StreamRoute>,384
+ /// fallback when no rule matches.385
+ pub default_route: Route,386
+}387
+388
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]389
+pub struct RouteRule {390
+ #[serde(rename = "match")]391
+ pub match_: RouteRuleMatch,392
+ pub route: Route,393
+}394
+395
+/// present fields are AND'd; values within a field are OR'd; empty match is true.396
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]397
+pub struct RouteRuleMatch {398
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]399
+ pub process_binary: Vec<String>,400
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]401
+ pub application_name: Vec<String>,402
+ /// `pipewire.access.portal.app_id` (Flatpak).403
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]404
+ pub portal_app_id: Vec<String>,405
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]406
+ pub media_role: Vec<String>,407
+}408
+409
+/// `hello` payload (sent on connect).410
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]411
+pub struct HelloData {412
+ /// always `"headroom"`.413
+ pub daemon: String,414
+ /// semver.415
+ pub version: String,416
+ pub protocol: u32,417
+}418
+419
+/// `meters` tick payload.420
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]421
+pub struct MeterTick {422
+ /// BS.1770 M, 400 ms, LUFS.423
+ pub momentary_lufs: f32,424
+ /// BS.1770 S, 3 s, LUFS.425
+ pub shortterm_lufs: f32,426
+ /// BS.1770 I, gated, LUFS.427
+ pub integrated_lufs: f32,428
+ /// max true peak across channels, dBTP.429
+ pub true_peak_dbtp: f32,430
+ /// compressor + limiter, dB.431
+ pub gain_reduction_db: f32,432
+ pub compressor_gr_db: f32,433
+ pub limiter_gr_db: f32,434
+ /// AGC contribution (positive = boost), dB.435
+ pub agc_gain_db: f32,436
+ /// when `false`, AGC is bypassed and `agc_gain_db` is inert (0) — render as "disabled",437
+ /// not an active 0 dB. absent ⇒ `true`.438
+ #[serde(default = "default_true")]439
+ pub agc_enabled: bool,440
+ /// same as [`Self::agc_enabled`] for `compressor_gr_db`. (limiter has no flag — always-on backstop.)441
+ #[serde(default = "default_true")]442
+ pub compressor_enabled: bool,443
+}444
+445
+/// absent ⇒ enabled, matching the always-on assumption before these fields existed.446
+fn default_true() -> bool {447
+ true448
+}449
+450
+/// `profile` topic events.451
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]452
+#[serde(tag = "event", rename_all = "snake_case")]453
+#[non_exhaustive]454
+pub enum ProfileEvent {455
+ Changed { name: String, previous: String },456
+ /// `changed`: profiles whose definitions changed on disk.457
+ Reloaded { changed: Vec<String> },458
+}459
+460
+/// `routing` topic events.461
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]462
+#[serde(tag = "event", rename_all = "snake_case")]463
+#[non_exhaustive]464
+pub enum RoutingEvent {465
+ StreamRouted {466
+ node_id: u32,467
+ app: String,468
+ to: Route,469
+ },470
+ /// stream's PipeWire node disappeared; drop state keyed by `node_id`.471
+ StreamRemoved { node_id: u32 },472
+ /// Layer A tap attached; daemon now manages `Props.channelVolumes` and publishes `meters/layer_a_level`.473
+ LayerAAttached { node_id: u32, app: String },474
+ /// Layer A tap torn down (usually stream gone); drop Layer A state for `node_id`.475
+ LayerADetached { node_id: u32 },476
+ RuleChanged,477
+}478
+479
+/// `meters/layer_a_level` payload — emitted when the Layer A controller writes a new `channelVolumes`.480
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]481
+pub struct LayerALevel {482
+ pub node_id: u32,483
+ pub app: String,484
+ /// linear volume written (1.0 = unity).485
+ pub volume_lin: f32,486
+ /// asserted gain reduction, dB; ≤ 0 when reducing.487
+ pub reduction_db: f32,488
+}489
+490
+/// `daemon` topic events.491
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]492
+#[serde(tag = "event", rename_all = "snake_case")]493
+#[non_exhaustive]494
+pub enum DaemonEvent {495
+ Started { version: String },496
+ Shutdown,497
+ /// events dropped on this connection.498
+ Overflow {499
+ lost_topic: Topic,500
+ /// lost in this batch.501
+ lost: u32,502
+ /// total lost on this connection.503
+ total_lost: u64,504
+ },505
+ /// non-fatal error.506
+ Error {507
+ /// matches `ErrorCode` when applicable.508
+ code: String,509
+ message: String,510
+ },511
+}512
+513
+#[cfg(test)]514
+mod tests {515
+ use super::*;516
+517
+ fn roundtrip<T>(v: &T) -> T518
+ where519
+ T: Serialize + for<'de> Deserialize<'de>,520
+ {521
+ let s = serde_json::to_string(v).expect("serialize");522
+ serde_json::from_str(&s).expect("deserialize")523
+ }524
+525
+ #[test]526
+ fn op_status_serializes_without_args() {527
+ let req = Request::new(1, Op::Status);528
+ let s = serde_json::to_string(&req).unwrap();529
+ // Must be the flat form, no `kind` wrapper, no `args` field.530
+ assert_eq!(s, r#"{"id":1,"op":"status"}"#);531
+532
+ let back: Request = serde_json::from_str(&s).unwrap();533
+ assert_eq!(back, req);534
+ }535
+536
+ #[test]537
+ fn op_profile_use_round_trips_flat() {538
+ let req = Request::new(539
+ 7,540
+ Op::ProfileUse {541
+ name: "night".into(),542
+ },543
+ );544
+ let s = serde_json::to_string(&req).unwrap();545
+ assert_eq!(s, r#"{"id":7,"op":"profile.use","args":{"name":"night"}}"#);546
+547
+ let back: Request = serde_json::from_str(&s).unwrap();548
+ assert_eq!(back, req);549
+ }550
+551
+ #[test]552
+ fn route_set_serializes_canonical() {553
+ let req = Request::new(554
+ 12,555
+ Op::RouteSet {556
+ app: "firefox".into(),557
+ to: Route::Processed,558
+ },559
+ );560
+ let s = serde_json::to_string(&req).unwrap();561
+ assert_eq!(562
+ s,563
+ r#"{"id":12,"op":"route.set","args":{"app":"firefox","to":"processed"}}"#564
+ );565
+ }566
+567
+ #[test]568
+ fn response_ok_shape() {569
+ let resp = Response::ok(3, &serde_json::json!({ "name": "default" })).unwrap();570
+ let s = serde_json::to_string(&resp).unwrap();571
+ assert_eq!(s, r#"{"id":3,"result":{"name":"default"}}"#);572
+ }573
+574
+ #[test]575
+ fn response_err_shape() {576
+ let resp = Response::err(4, ProtoError::new(crate::ErrorCode::NotFound, "missing"));577
+ let s = serde_json::to_string(&resp).unwrap();578
+ assert_eq!(579
+ s,580
+ r#"{"id":4,"error":{"code":"NOT_FOUND","message":"missing"}}"#581
+ );582
+ }583
+584
+ #[test]585
+ fn server_frame_distinguishes_response_from_event() {586
+ let resp = Response::ok(1, &serde_json::json!(null)).unwrap();587
+ let s = serde_json::to_string(&resp).unwrap();588
+ let frame: ServerFrame = serde_json::from_str(&s).unwrap();589
+ assert!(matches!(frame, ServerFrame::Response(_)));590
+591
+ let ev = Event::new(Topic::Meters, "tick", &serde_json::json!({})).unwrap();592
+ let s = serde_json::to_string(&ev).unwrap();593
+ let frame: ServerFrame = serde_json::from_str(&s).unwrap();594
+ assert!(matches!(frame, ServerFrame::Event(_)));595
+ }596
+597
+ #[test]598
+ fn meter_tick_roundtrip() {599
+ let m = MeterTick {600
+ momentary_lufs: -19.3,601
+ shortterm_lufs: -20.1,602
+ integrated_lufs: -19.8,603
+ true_peak_dbtp: -1.4,604
+ gain_reduction_db: -2.1,605
+ compressor_gr_db: -0.8,606
+ limiter_gr_db: -1.3,607
+ agc_gain_db: 0.5,608
+ agc_enabled: true,609
+ compressor_enabled: true,610
+ };611
+ let back = roundtrip(&m);612
+ assert_eq!(back, m);613
+ }614
+615
+ #[test]616
+ fn topic_string_canonical() {617
+ assert_eq!(Topic::Meters.as_str(), "meters");618
+ assert_eq!(serde_json::to_string(&Topic::Meters).unwrap(), "\"meters\"");619
+ let t: Topic = serde_json::from_str("\"profile\"").unwrap();620
+ assert_eq!(t, Topic::Profile);621
+ }622
+623
+ #[test]624
+ fn route_string_canonical() {625
+ let r: Route = serde_json::from_str("\"bypass\"").unwrap();626
+ assert_eq!(r, Route::Bypass);627
+ assert_eq!(serde_json::to_string(&r).unwrap(), "\"bypass\"");628
+ }629
+630
+ #[test]631
+ fn error_code_screaming_snake() {632
+ let s = serde_json::to_string(&crate::ErrorCode::InvalidFrame).unwrap();633
+ assert_eq!(s, "\"INVALID_FRAME\"");634
+ let c: crate::ErrorCode = serde_json::from_str("\"UNKNOWN_OP\"").unwrap();635
+ assert_eq!(c, crate::ErrorCode::UnknownOp);636
+ }637
+638
+ #[test]639
+ fn subscribe_op_roundtrip() {640
+ let req = Request::new(641
+ 5,642
+ Op::Subscribe {643
+ topics: vec![Topic::Meters, Topic::Profile],644
+ },645
+ );646
+ let back: Request = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();647
+ assert_eq!(back, req);648
+ }649
+}