atagen

headroom

public
Star0
Watch0

Commit dd6b8e786e1f

tarball

atagen <boss@atagen.co>2026-06-02 05:09 UTC

8 files changed+1657-0
ipc: uds control protocol and blocking client
Added file: crates/headroom-client/Cargo.toml+20 additions
@@ -0,0 +1,20 @@
1+[package]
2+name = "headroom-client"
3+description = "automatic loudness and per-app volume control (pipewire)"
4+version.workspace = true
5+edition.workspace = true
6+license = "MPL-2.0"
7+homepage.workspace = true
8+repository.workspace = true
9+authors.workspace = true
10+
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 client
20+async = []
Added file: crates/headroom-client/src/client.rs+546 additions
@@ -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 event
44+/// 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 below
75+ 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.topic
101+ )))
102+ }
103+ ServerFrame::Response(r) => {
104+ return Err(ClientError::BadHello(format!("response id={}", r.id)))
105+ }
106+ }
107+
108+ // best effort for older daemons
109+ 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.hello
119+ }
120+
121+ /// socket path this client is connected to.
122+ #[must_use]
123+ pub fn socket_path(&self) -> &Path {
124+ &self.socket_path
125+ }
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 correct
130+ self.next_id = self.next_id.wrapping_add(1);
131+ id
132+ }
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 as
172+ /// [`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 wrappers
189+
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 a
393+ /// `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+ codec
414+ .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 each
445+ // subscribed topic so the client can demonstrate
446+ // event handling.
447+ let body = serde_json::json!({ "subscribed": &topics });
448+ let resp = Response::ok(req.id, &body).unwrap();
449+ codec
450+ .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+ codec
462+ .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+ me
487+ }
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 drains
525+ // 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 events
531+ // 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 from
536+ // 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 status
542+ // response; both should be queued.
543+ assert!(topics.contains(&Topic::Meters));
544+ assert!(topics.contains(&Topic::Profile));
545+ }
546+}
Added file: crates/headroom-client/src/lib.rs+15 additions
@@ -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+};
Added file: crates/headroom-ipc/Cargo.toml+14 additions
@@ -0,0 +1,14 @@
1+[package]
2+name = "headroom-ipc"
3+description = "automatic loudness and per-app volume control (pipewire)"
4+version.workspace = true
5+edition.workspace = true
6+license = "MPL-2.0"
7+homepage.workspace = true
8+repository.workspace = true
9+authors.workspace = true
10+
11+[dependencies]
12+serde = { workspace = true }
13+serde_json = { workspace = true }
14+thiserror = { workspace = true }
Added file: crates/headroom-ipc/src/codec.rs+248 additions
@@ -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 MiB
11+
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_bytes
46+ }
47+
48+ /// # Errors
49+ /// - [`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 length
54+ 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+ /// # Errors
69+ /// - [`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+}
Added file: crates/headroom-ipc/src/error.rs+108 additions
@@ -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::Closed
106+ )
107+ }
108+}
Added file: crates/headroom-ipc/src/lib.rs+57 additions
@@ -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-light
40+ 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+ None
57+}
Added file: crates/headroom-ipc/src/proto.rs+649 additions
@@ -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 handshake
169+ #[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+ /// # Errors
231+ /// 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+ /// # Errors
270+ /// 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 and
314+ /// persist across `profile.use`, so a stale `headroom set` can silently override it
315+ /// (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+ true
448+}
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) -> T
518+ where
519+ 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+}