stage 2
This commit is contained in:
commit
ca1910de60
39 changed files with 6328 additions and 0 deletions
16
crates/headroom-ipc/Cargo.toml
Normal file
16
crates/headroom-ipc/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "headroom-ipc"
|
||||
description = "Headroom control-protocol types, framing, and codec. No I/O."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MPL-2.0"
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
12
crates/headroom-ipc/README.md
Normal file
12
crates/headroom-ipc/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# headroom-ipc
|
||||
|
||||
Wire types, framing, and codec for the Headroom control protocol.
|
||||
|
||||
This crate is the authoritative Rust binding to the protocol defined in
|
||||
[`IPC.md`](../../IPC.md). It performs no I/O; pair it with `headroom-client`
|
||||
for a ready-to-use client, or use it directly to implement your own.
|
||||
|
||||
## License
|
||||
|
||||
MPL-2.0. Third-party clients (including non-GPL ones) can depend on this
|
||||
crate without affecting their license.
|
||||
267
crates/headroom-ipc/src/codec.rs
Normal file
267
crates/headroom-ipc/src/codec.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
125
crates/headroom-ipc/src/error.rs
Normal file
125
crates/headroom-ipc/src/error.rs
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
69
crates/headroom-ipc/src/lib.rs
Normal file
69
crates/headroom-ipc/src/lib.rs
Normal 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
|
||||
}
|
||||
693
crates/headroom-ipc/src/proto.rs
Normal file
693
crates/headroom-ipc/src/proto.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue