This commit is contained in:
atagen 2026-05-19 16:33:09 +10:00
commit ca1910de60
39 changed files with 6328 additions and 0 deletions

View file

@ -0,0 +1,267 @@
//! Length-prefixed JSON framing.
//!
//! Wire format: a 4-byte big-endian unsigned length, followed by exactly
//! that many bytes of UTF-8 JSON. Each frame is a single JSON value.
use std::io::{Read, Write};
use serde::{de::DeserializeOwned, Serialize};
use crate::error::Error;
/// Default upper bound on a single frame's payload size.
pub const DEFAULT_MAX_FRAME_BYTES: usize = 1024 * 1024; // 1 MiB
/// Lower bound enforced by [`Codec::with_max_frame_size`].
///
/// Frames below this size are silly small and almost always indicate a
/// bug; the limit is enforced so misuse fails loudly rather than
/// rejecting normal traffic.
pub const MIN_MAX_FRAME_BYTES: usize = 256;
/// A stateless framing codec.
///
/// Instances are cheap to clone. The codec owns no buffers; callers
/// supply their own readers and writers.
#[derive(Debug, Clone, Copy)]
pub struct Codec {
max_frame_bytes: usize,
}
impl Default for Codec {
fn default() -> Self {
Self {
max_frame_bytes: DEFAULT_MAX_FRAME_BYTES,
}
}
}
impl Codec {
/// Returns a codec with the default 1 MiB frame limit.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Returns a codec with the supplied frame size cap.
///
/// The cap is clamped to at least [`MIN_MAX_FRAME_BYTES`].
#[must_use]
pub fn with_max_frame_size(bytes: usize) -> Self {
Self {
max_frame_bytes: bytes.max(MIN_MAX_FRAME_BYTES),
}
}
/// Maximum payload size, in bytes.
#[must_use]
pub fn max_frame_bytes(self) -> usize {
self.max_frame_bytes
}
/// Serialize `msg` and write it as a framed payload.
///
/// # Errors
/// - [`Error::Json`] if the value cannot be serialized.
/// - [`Error::FrameTooLarge`] if the serialized form exceeds the cap.
/// - [`Error::Io`] on write failure.
pub fn write<W: Write, T: Serialize>(self, mut w: W, msg: &T) -> Result<(), Error> {
// Serialize first so we know the exact length up-front.
let buf = serde_json::to_vec(msg)?;
if buf.len() > self.max_frame_bytes {
return Err(Error::FrameTooLarge {
actual: buf.len(),
limit: self.max_frame_bytes,
});
}
let len = u32::try_from(buf.len()).expect("buf.len() <= max_frame_bytes <= u32::MAX");
w.write_all(&len.to_be_bytes())?;
w.write_all(&buf)?;
w.flush()?;
Ok(())
}
/// Read one framed payload from `r` and deserialize it.
///
/// # Errors
/// - [`Error::Closed`] if EOF is hit before any bytes of the length
/// prefix arrive (graceful close).
/// - [`Error::Io`] on partial reads or other I/O failure.
/// - [`Error::FrameTooLarge`] if the announced length exceeds the cap.
/// - [`Error::Json`] if the payload fails to deserialize.
pub fn read<R: Read, T: DeserializeOwned>(self, mut r: R) -> Result<T, Error> {
let mut len_buf = [0u8; 4];
match read_full_or_eof(&mut r, &mut len_buf)? {
ReadOutcome::Full => {}
ReadOutcome::ZeroAtStart => return Err(Error::Closed),
}
let len = u32::from_be_bytes(len_buf) as usize;
if len > self.max_frame_bytes {
return Err(Error::FrameTooLarge {
actual: len,
limit: self.max_frame_bytes,
});
}
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
Ok(serde_json::from_slice(&buf)?)
}
}
enum ReadOutcome {
Full,
ZeroAtStart,
}
fn read_full_or_eof<R: Read>(r: &mut R, buf: &mut [u8]) -> Result<ReadOutcome, std::io::Error> {
let mut read = 0;
while read < buf.len() {
match r.read(&mut buf[read..]) {
Ok(0) => {
if read == 0 {
return Ok(ReadOutcome::ZeroAtStart);
}
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"eof mid-length-prefix",
));
}
Ok(n) => read += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(ReadOutcome::Full)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Event, Op, Request, Response, ServerFrame, Topic};
use std::io::Cursor;
#[test]
fn write_read_request_roundtrip() {
let codec = Codec::new();
let req = Request::new(
42,
Op::ProfileUse {
name: "night".into(),
},
);
let mut buf = Vec::new();
codec.write(&mut buf, &req).unwrap();
let mut cur = Cursor::new(&buf);
let back: Request = codec.read(&mut cur).unwrap();
assert_eq!(back, req);
}
#[test]
fn server_frame_round_trip_response() {
let codec = Codec::new();
let resp = Response::ok(1, &serde_json::json!({"name": "default"})).unwrap();
let frame = ServerFrame::Response(resp.clone());
let mut buf = Vec::new();
codec.write(&mut buf, &frame).unwrap();
let mut cur = Cursor::new(&buf);
let back: ServerFrame = codec.read(&mut cur).unwrap();
match back {
ServerFrame::Response(r) => assert_eq!(r, resp),
ServerFrame::Event(_) => panic!("decoded as event"),
}
}
#[test]
fn server_frame_round_trip_event() {
let codec = Codec::new();
let ev = Event::new(Topic::Daemon, "shutdown", &serde_json::json!({})).unwrap();
let frame = ServerFrame::Event(ev.clone());
let mut buf = Vec::new();
codec.write(&mut buf, &frame).unwrap();
let mut cur = Cursor::new(&buf);
let back: ServerFrame = codec.read(&mut cur).unwrap();
match back {
ServerFrame::Event(e) => assert_eq!(e, ev),
ServerFrame::Response(_) => panic!("decoded as response"),
}
}
#[test]
fn rejects_oversized_frames_on_write() {
let codec = Codec::with_max_frame_size(MIN_MAX_FRAME_BYTES);
// A big string that will serialize > 256 bytes.
let req = Request::new(
1,
Op::SettingSet {
key: "x".into(),
value: serde_json::Value::String("a".repeat(1024)),
},
);
let mut buf = Vec::new();
let err = codec.write(&mut buf, &req).unwrap_err();
assert!(matches!(err, Error::FrameTooLarge { .. }));
}
#[test]
fn rejects_oversized_frames_on_read() {
let codec = Codec::with_max_frame_size(MIN_MAX_FRAME_BYTES);
// Hand-craft a length prefix that exceeds the cap.
let mut buf = Vec::new();
let bad_len: u32 = MIN_MAX_FRAME_BYTES as u32 + 1;
buf.extend_from_slice(&bad_len.to_be_bytes());
// No need to follow with payload; we expect early rejection.
let mut cur = Cursor::new(&buf);
let err = codec.read::<_, serde_json::Value>(&mut cur).unwrap_err();
assert!(matches!(err, Error::FrameTooLarge { .. }));
}
#[test]
fn graceful_eof_at_frame_boundary() {
let codec = Codec::new();
let mut cur = Cursor::new(Vec::<u8>::new());
let err = codec.read::<_, Request>(&mut cur).unwrap_err();
assert!(matches!(err, Error::Closed));
}
#[test]
fn mid_frame_eof_is_io_error() {
let codec = Codec::new();
// Half a length prefix.
let mut cur = Cursor::new(vec![0u8, 0u8]);
let err = codec.read::<_, Request>(&mut cur).unwrap_err();
assert!(matches!(err, Error::Io(_)));
}
#[test]
fn rejects_invalid_json_payload() {
let codec = Codec::new();
let payload = b"not-json";
let mut buf = Vec::new();
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
buf.extend_from_slice(payload);
let mut cur = Cursor::new(&buf);
let err = codec.read::<_, Request>(&mut cur).unwrap_err();
assert!(matches!(err, Error::Json(_)));
}
#[test]
fn back_to_back_frames() {
let codec = Codec::new();
let a = Request::new(1, Op::Status);
let b = Request::new(2, Op::ProfileList);
let mut buf = Vec::new();
codec.write(&mut buf, &a).unwrap();
codec.write(&mut buf, &b).unwrap();
let mut cur = Cursor::new(&buf);
let ra: Request = codec.read(&mut cur).unwrap();
let rb: Request = codec.read(&mut cur).unwrap();
assert_eq!(ra, a);
assert_eq!(rb, b);
}
}

View file

@ -0,0 +1,125 @@
//! Error types for the protocol crate.
use serde::{Deserialize, Serialize};
/// Stable machine-readable error code emitted in `error.code`.
///
/// Adding variants is a non-breaking change. Removing or renaming
/// variants is breaking and bumps `PROTOCOL_VERSION`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ErrorCode {
/// Malformed framing or non-JSON payload. Connection is closed.
InvalidFrame,
/// Valid JSON, but does not match any known message shape.
InvalidMessage,
/// `op` does not name a known operation.
UnknownOp,
/// `args` is missing a field, has the wrong type, or is out of range.
InvalidArgs,
/// Named profile / app / stream / setting key does not exist.
NotFound,
/// Operation would violate an invariant.
Conflict,
/// Daemon transiently cannot serve the request.
Busy,
/// Server-side bug. `message` contains debug detail.
Internal,
}
impl ErrorCode {
/// Returns the canonical SCREAMING_SNAKE wire string.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ErrorCode::InvalidFrame => "INVALID_FRAME",
ErrorCode::InvalidMessage => "INVALID_MESSAGE",
ErrorCode::UnknownOp => "UNKNOWN_OP",
ErrorCode::InvalidArgs => "INVALID_ARGS",
ErrorCode::NotFound => "NOT_FOUND",
ErrorCode::Conflict => "CONFLICT",
ErrorCode::Busy => "BUSY",
ErrorCode::Internal => "INTERNAL",
}
}
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Error payload as it appears on the wire inside a `Response`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtoError {
/// Stable machine-readable code.
pub code: ErrorCode,
/// Human-readable English message. Not stable; do not pattern match.
pub message: String,
}
impl ProtoError {
/// Construct a new protocol error.
#[must_use]
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl std::fmt::Display for ProtoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ProtoError {}
/// Errors produced by the codec and high-level helpers in this crate.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Underlying I/O failed.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// JSON encoding or decoding failed.
#[error("json: {0}")]
Json(#[from] serde_json::Error),
/// A frame exceeded the configured maximum size.
#[error("frame too large: {actual} bytes (limit {limit})")]
FrameTooLarge {
/// Actual size of the offending frame in bytes.
actual: usize,
/// Configured maximum, in bytes.
limit: usize,
},
/// The peer answered with an error response.
#[error("protocol: {0}")]
Protocol(#[from] ProtoError),
/// The peer sent an unexpected frame given protocol context (e.g.
/// a response with a mismatched id).
#[error("unexpected frame: {0}")]
UnexpectedFrame(String),
/// The connection was closed mid-frame.
#[error("connection closed")]
Closed,
}
impl Error {
/// True if this error indicates the connection should be torn down.
#[must_use]
pub fn is_fatal(&self) -> bool {
matches!(
self,
Error::Io(_) | Error::FrameTooLarge { .. } | Error::Closed
)
}
}

View file

@ -0,0 +1,69 @@
//! Headroom control-protocol types and framing.
//!
//! The authoritative protocol specification is in `IPC.md` at the
//! repository root. This crate is its Rust binding.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod codec;
mod error;
mod proto;
pub use codec::{Codec, DEFAULT_MAX_FRAME_BYTES, MIN_MAX_FRAME_BYTES};
pub use error::{Error, ErrorCode, ProtoError};
pub use proto::{
DaemonEvent, Event, HelloData, MeterTick, Op, ProfileEvent, ProfileInfo, Request, Response,
ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame,
SinkInfo, Sinks, Status, StreamRoute, Topic,
};
/// Wire-protocol version. Bumped only on incompatible changes.
pub const PROTOCOL_VERSION: u32 = 1;
/// Default Unix-domain socket path stem inside `$XDG_RUNTIME_DIR`.
///
/// The full socket path is `${XDG_RUNTIME_DIR}/headroom/control.sock`,
/// falling back to `/run/user/$UID/headroom/control.sock` when
/// `XDG_RUNTIME_DIR` is unset.
pub const DEFAULT_SOCKET_DIR: &str = "headroom";
/// Default socket filename.
pub const DEFAULT_SOCKET_NAME: &str = "control.sock";
/// Returns the conventional control-socket path for the current user.
///
/// Honours `XDG_RUNTIME_DIR`, with the documented fallback to
/// `/run/user/$UID/...`. Returns `None` when neither is determinable.
#[must_use]
pub fn default_socket_path() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
if !dir.is_empty() {
return Some(
PathBuf::from(dir)
.join(DEFAULT_SOCKET_DIR)
.join(DEFAULT_SOCKET_NAME),
);
}
}
// SAFETY: `nix`-free fallback. Read uid from /proc/self/status as
// a pure-Rust dependency-light path.
let uid = read_self_uid()?;
Some(
PathBuf::from(format!("/run/user/{uid}"))
.join(DEFAULT_SOCKET_DIR)
.join(DEFAULT_SOCKET_NAME),
)
}
fn read_self_uid() -> Option<u32> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
for line in s.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
let first = rest.split_whitespace().next()?;
return first.parse().ok();
}
}
None
}

View file

@ -0,0 +1,693 @@
//! Protocol message types.
//!
//! See `IPC.md` for the normative specification.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::ProtoError;
// ---------------------------------------------------------------------------
// Topics
// ---------------------------------------------------------------------------
/// A subscription topic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Topic {
/// Live loudness / peak / GR telemetry.
Meters,
/// Profile use / reload events.
Profile,
/// Routing rule and per-stream events.
Routing,
/// Daemon lifecycle and overflow.
Daemon,
/// Synthetic topic for `hello`. Clients never subscribe to it.
Control,
}
impl Topic {
/// Returns the canonical wire string.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Topic::Meters => "meters",
Topic::Profile => "profile",
Topic::Routing => "routing",
Topic::Daemon => "daemon",
Topic::Control => "control",
}
}
/// Topics that clients are allowed to subscribe to.
#[must_use]
pub const fn subscribable() -> &'static [Topic] {
&[Topic::Meters, Topic::Profile, Topic::Routing, Topic::Daemon]
}
}
impl std::fmt::Display for Topic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ---------------------------------------------------------------------------
// Route enum
// ---------------------------------------------------------------------------
/// Routing decision for a single application or stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Route {
/// Route the stream through the processed (filtered) sink.
Processed,
/// Route the stream directly to the real hardware sink (no
/// processing, no extra graph hop).
Bypass,
}
impl Route {
/// Returns the canonical wire string.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Route::Processed => "processed",
Route::Bypass => "bypass",
}
}
}
impl std::fmt::Display for Route {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ---------------------------------------------------------------------------
// Operations
// ---------------------------------------------------------------------------
/// All known operations, as a strongly-typed enum.
///
/// The wire form is `{ "op": "<name>", "args": <args> }` with operations
/// that take no arguments omitting the `args` field. Operations with no
/// arguments serialize as `{ "op": "<name>" }`; serde fills the unit
/// variants accordingly.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", content = "args")]
#[non_exhaustive]
pub enum Op {
/// Snapshot of daemon state.
#[serde(rename = "status")]
Status,
/// List all known profiles.
#[serde(rename = "profile.list")]
ProfileList,
/// Activate the named profile.
#[serde(rename = "profile.use")]
ProfileUse {
/// Profile name.
name: String,
},
/// Show a profile in full. `name` defaults to the active profile
/// when omitted.
#[serde(rename = "profile.show")]
ProfileShow {
/// Profile name. `None` means the active profile.
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Reload all profile files from disk.
#[serde(rename = "profile.reload")]
ProfileReload,
/// List routing rules and current per-stream decisions.
#[serde(rename = "route.list")]
RouteList,
/// Add or replace a routing rule for an app (persistent).
#[serde(rename = "route.set")]
RouteSet {
/// Application identifier (typically `application.process.binary`).
app: String,
/// Target route.
to: Route,
},
/// Remove a user routing rule for an app.
#[serde(rename = "route.unset")]
RouteUnset {
/// Application identifier.
app: String,
},
/// One-shot reroute of a specific live stream.
#[serde(rename = "route.stream")]
RouteStream {
/// PipeWire node id of the stream.
node_id: u32,
/// Target route.
to: Route,
},
/// Get a single setting from the active profile.
#[serde(rename = "setting.get")]
SettingGet {
/// Dotted setting key (e.g. `compressor.threshold_db`).
key: String,
},
/// Set a single setting in the active profile.
#[serde(rename = "setting.set")]
SettingSet {
/// Dotted setting key.
key: String,
/// New value. Must match the setting's type.
value: Value,
},
/// List all settings (active profile).
#[serde(rename = "setting.list")]
SettingList,
/// Enable or disable the global bypass kill switch.
#[serde(rename = "bypass.set")]
BypassSet {
/// `true` to route everything through bypass.
enabled: bool,
},
/// Subscribe to one or more event topics on this connection.
#[serde(rename = "subscribe")]
Subscribe {
/// Topics to subscribe to.
topics: Vec<Topic>,
},
/// Unsubscribe from one or more topics on this connection.
#[serde(rename = "unsubscribe")]
Unsubscribe {
/// Topics to unsubscribe from.
topics: Vec<Topic>,
},
}
impl Op {
/// Canonical wire name of this operation.
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Op::Status => "status",
Op::ProfileList => "profile.list",
Op::ProfileUse { .. } => "profile.use",
Op::ProfileShow { .. } => "profile.show",
Op::ProfileReload => "profile.reload",
Op::RouteList => "route.list",
Op::RouteSet { .. } => "route.set",
Op::RouteUnset { .. } => "route.unset",
Op::RouteStream { .. } => "route.stream",
Op::SettingGet { .. } => "setting.get",
Op::SettingSet { .. } => "setting.set",
Op::SettingList => "setting.list",
Op::BypassSet { .. } => "bypass.set",
Op::Subscribe { .. } => "subscribe",
Op::Unsubscribe { .. } => "unsubscribe",
}
}
}
// ---------------------------------------------------------------------------
// Request / Response / Event
// ---------------------------------------------------------------------------
/// A client-to-server request.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Request {
/// Client-chosen identifier. Echoed back in the paired response.
pub id: u64,
/// The operation and its arguments. Flattened: contributes `op`
/// and (optionally) `args` fields at the same level as `id`.
#[serde(flatten)]
pub op: Op,
}
impl Request {
/// Construct a new request.
#[must_use]
pub fn new(id: u64, op: Op) -> Self {
Self { id, op }
}
}
/// A server-to-client response, paired with a request by `id`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Response {
/// Matches the originating request's `id`.
pub id: u64,
/// Result or error.
#[serde(flatten)]
pub payload: ResponsePayload,
}
impl Response {
/// Build an OK response from any serializable value.
///
/// # Errors
/// Returns the underlying serde error if `value` fails to serialize.
pub fn ok<T: Serialize>(id: u64, value: &T) -> Result<Self, serde_json::Error> {
Ok(Self {
id,
payload: ResponsePayload::Ok {
result: serde_json::to_value(value)?,
},
})
}
/// Build an error response.
#[must_use]
pub fn err(id: u64, error: ProtoError) -> Self {
Self {
id,
payload: ResponsePayload::Err { error },
}
}
}
/// Discriminated body of a `Response`.
///
/// On the wire this is a single field — either `result` or `error` —
/// inlined alongside `id`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsePayload {
/// Successful result.
Ok {
/// Operation-specific result body. JSON; typically an object.
result: Value,
},
/// Operation failed.
Err {
/// Error payload.
error: ProtoError,
},
}
/// Server-to-client event on a subscribed topic.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event {
/// Event name within the topic (e.g. `tick`, `changed`).
pub event: String,
/// Subscription topic this event belongs to.
pub topic: Topic,
/// Event payload. Shape depends on `topic` and `event`.
pub data: Value,
}
impl Event {
/// Construct a new event with a JSON-serializable payload.
///
/// # Errors
/// Returns the underlying serde error if `data` fails to serialize.
pub fn new<T: Serialize>(
topic: Topic,
event: impl Into<String>,
data: &T,
) -> Result<Self, serde_json::Error> {
Ok(Self {
event: event.into(),
topic,
data: serde_json::to_value(data)?,
})
}
}
/// A single frame as a client would receive it from the server: either
/// a paired response or a fan-out event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ServerFrame {
/// Response to a prior request.
Response(Response),
/// Subscription event.
Event(Event),
}
// ---------------------------------------------------------------------------
// Result body shapes for typed access
// ---------------------------------------------------------------------------
/// Result body of `status`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Status {
/// Daemon version string (semver).
pub version: String,
/// Wire protocol version.
pub protocol: u32,
/// Daemon uptime in seconds.
pub uptime_s: u64,
/// Active profile name.
pub profile: String,
/// Global bypass flag.
pub bypass: bool,
/// Sink status snapshot.
pub sinks: Sinks,
/// Currently-tracked playback streams.
pub streams: Vec<StreamRoute>,
}
/// Sink-side of `Status`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Sinks {
/// The processed virtual sink. The only sink Headroom creates.
pub processed: SinkInfo,
/// The hardware sink Headroom is currently forwarding to, and
/// where bypassed streams are routed directly.
pub real: SinkInfo,
}
/// Information about one sink.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SinkInfo {
/// PipeWire node id, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<u32>,
/// Human-readable sink name, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// True if the sink is currently linked and accepting audio.
#[serde(default)]
pub ready: bool,
}
/// One playback stream and where it's routed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StreamRoute {
/// PipeWire node id.
pub node_id: u32,
/// Application identifier (typically `application.process.binary`).
pub app: String,
/// Active route.
pub route: Route,
}
/// Summary entry returned by `profile.list`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProfileInfo {
/// Profile name.
pub name: String,
/// True if this is the active profile.
pub active: bool,
/// Short description from the profile document.
pub description: String,
}
/// Result body of `route.list`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RouteList {
/// Active rules, in evaluation order.
pub rules: Vec<RouteRule>,
/// Current per-stream routing decisions.
pub current: Vec<StreamRoute>,
/// Fallback route when no rule matches.
pub default_route: Route,
}
/// One routing rule.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RouteRule {
/// Conditions that must all hold for this rule to fire.
#[serde(rename = "match")]
pub match_: RouteRuleMatch,
/// Route to assign when the rule fires.
pub route: Route,
}
/// Match conditions for a routing rule.
///
/// All present fields must hold (logical AND); within a field, any
/// listed value matches (logical OR). Empty match is implicitly true.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RouteRuleMatch {
/// Match on `application.process.binary`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub process_binary: Vec<String>,
/// Match on `application.name`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub application_name: Vec<String>,
/// Match on `pipewire.access.portal.app_id` (Flatpak).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub portal_app_id: Vec<String>,
/// Match on `media.role`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub media_role: Vec<String>,
}
// ---------------------------------------------------------------------------
// Event payload shapes (typed)
// ---------------------------------------------------------------------------
/// Payload of a `hello` event (sent on connect).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HelloData {
/// Always `"headroom"`.
pub daemon: String,
/// Daemon version string (semver).
pub version: String,
/// Wire protocol version.
pub protocol: u32,
}
/// Payload of a `meters` tick event.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct MeterTick {
/// Momentary loudness (BS.1770 M, 400 ms window), in LUFS.
pub momentary_lufs: f32,
/// Short-term loudness (BS.1770 S, 3 s window), in LUFS.
pub shortterm_lufs: f32,
/// Integrated loudness (BS.1770 I, gated), in LUFS.
pub integrated_lufs: f32,
/// Maximum true peak across channels, in dBTP.
pub true_peak_dbtp: f32,
/// Combined gain reduction (compressor + limiter), in dB.
pub gain_reduction_db: f32,
/// Compressor stage gain reduction, in dB.
pub compressor_gr_db: f32,
/// Limiter stage gain reduction, in dB.
pub limiter_gr_db: f32,
/// AGC contribution (positive = boost), in dB.
pub agc_gain_db: f32,
}
/// `profile` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProfileEvent {
/// Active profile changed.
Changed {
/// New active profile.
name: String,
/// Previous active profile.
previous: String,
},
/// One or more profile files were reloaded.
Reloaded {
/// Names of profiles whose definitions changed on disk.
changed: Vec<String>,
},
}
/// `routing` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum RoutingEvent {
/// A new stream was assigned a route.
StreamRouted {
/// Node id of the routed stream.
node_id: u32,
/// Application identifier.
app: String,
/// Route assigned.
to: Route,
},
/// A persistent rule was added, replaced, or removed.
RuleChanged,
}
/// `daemon` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum DaemonEvent {
/// Daemon started.
Started {
/// Daemon version string.
version: String,
},
/// Daemon shutting down.
Shutdown,
/// One or more events were dropped on this connection.
Overflow {
/// Topic whose queue overflowed.
lost_topic: Topic,
/// Number lost in this batch.
lost: u32,
/// Total lost on this connection so far.
total_lost: u64,
},
/// Non-fatal daemon error.
Error {
/// Stable code (matches `ErrorCode` when applicable).
code: String,
/// Human-readable detail.
message: String,
},
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip<T>(v: &T) -> T
where
T: Serialize + for<'de> Deserialize<'de>,
{
let s = serde_json::to_string(v).expect("serialize");
serde_json::from_str(&s).expect("deserialize")
}
#[test]
fn op_status_serializes_without_args() {
let req = Request::new(1, Op::Status);
let s = serde_json::to_string(&req).unwrap();
// Must be the flat form, no `kind` wrapper, no `args` field.
assert_eq!(s, r#"{"id":1,"op":"status"}"#);
let back: Request = serde_json::from_str(&s).unwrap();
assert_eq!(back, req);
}
#[test]
fn op_profile_use_round_trips_flat() {
let req = Request::new(
7,
Op::ProfileUse {
name: "night".into(),
},
);
let s = serde_json::to_string(&req).unwrap();
assert_eq!(s, r#"{"id":7,"op":"profile.use","args":{"name":"night"}}"#);
let back: Request = serde_json::from_str(&s).unwrap();
assert_eq!(back, req);
}
#[test]
fn route_set_serializes_canonical() {
let req = Request::new(
12,
Op::RouteSet {
app: "firefox".into(),
to: Route::Processed,
},
);
let s = serde_json::to_string(&req).unwrap();
assert_eq!(
s,
r#"{"id":12,"op":"route.set","args":{"app":"firefox","to":"processed"}}"#
);
}
#[test]
fn response_ok_shape() {
let resp = Response::ok(3, &serde_json::json!({ "name": "default" })).unwrap();
let s = serde_json::to_string(&resp).unwrap();
assert_eq!(s, r#"{"id":3,"result":{"name":"default"}}"#);
}
#[test]
fn response_err_shape() {
let resp = Response::err(4, ProtoError::new(crate::ErrorCode::NotFound, "missing"));
let s = serde_json::to_string(&resp).unwrap();
assert_eq!(
s,
r#"{"id":4,"error":{"code":"NOT_FOUND","message":"missing"}}"#
);
}
#[test]
fn server_frame_distinguishes_response_from_event() {
let resp = Response::ok(1, &serde_json::json!(null)).unwrap();
let s = serde_json::to_string(&resp).unwrap();
let frame: ServerFrame = serde_json::from_str(&s).unwrap();
assert!(matches!(frame, ServerFrame::Response(_)));
let ev = Event::new(Topic::Meters, "tick", &serde_json::json!({})).unwrap();
let s = serde_json::to_string(&ev).unwrap();
let frame: ServerFrame = serde_json::from_str(&s).unwrap();
assert!(matches!(frame, ServerFrame::Event(_)));
}
#[test]
fn meter_tick_roundtrip() {
let m = MeterTick {
momentary_lufs: -19.3,
shortterm_lufs: -20.1,
integrated_lufs: -19.8,
true_peak_dbtp: -1.4,
gain_reduction_db: -2.1,
compressor_gr_db: -0.8,
limiter_gr_db: -1.3,
agc_gain_db: 0.5,
};
let back = roundtrip(&m);
assert_eq!(back, m);
}
#[test]
fn topic_string_canonical() {
assert_eq!(Topic::Meters.as_str(), "meters");
assert_eq!(serde_json::to_string(&Topic::Meters).unwrap(), "\"meters\"");
let t: Topic = serde_json::from_str("\"profile\"").unwrap();
assert_eq!(t, Topic::Profile);
}
#[test]
fn route_string_canonical() {
let r: Route = serde_json::from_str("\"bypass\"").unwrap();
assert_eq!(r, Route::Bypass);
assert_eq!(serde_json::to_string(&r).unwrap(), "\"bypass\"");
}
#[test]
fn error_code_screaming_snake() {
let s = serde_json::to_string(&crate::ErrorCode::InvalidFrame).unwrap();
assert_eq!(s, "\"INVALID_FRAME\"");
let c: crate::ErrorCode = serde_json::from_str("\"UNKNOWN_OP\"").unwrap();
assert_eq!(c, crate::ErrorCode::UnknownOp);
}
#[test]
fn subscribe_op_roundtrip() {
let req = Request::new(
5,
Op::Subscribe {
topics: vec![Topic::Meters, Topic::Profile],
},
);
let back: Request = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
assert_eq!(back, req);
}
}