From 9855d1e18a5b817736c41b7d06ecc27f408be1f5 Mon Sep 17 00:00:00 2001 From: Oleksandr Berezovskyi Date: Sat, 12 Sep 2026 10:09:09 +0300 Subject: [PATCH] feat(config): add the CLI and environment configuration Parses the WxH sizes, clamps them against the HyperHDR message limit and carries the HyperHDR server constants (max message size, default timeout). --- src/config.rs | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 289 insertions(+) create mode 100644 src/config.rs diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1c5ed37 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,288 @@ +//! Command line and environment configuration. + +use std::fmt; +use std::str::FromStr; +use std::time::Duration; + +use clap::Parser; + +/// Largest message the HyperHDR flatbuffers server accepts (`FlatBuffersServerConnection::readyRead`). +pub const HYPERHDR_MAX_MESSAGE: usize = 10_000_000; +/// Default `timeout` of the HyperHDR flatbuffers server. +pub const HYPERHDR_DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); + +const MAX_SIZE_DIM: u32 = 1920; +const MAX_REQUEST_DIM: u32 = 3840; +/// Generous upper bound of the flatbuffers overhead of an `Image` request plus framing. +const IMAGE_MESSAGE_OVERHEAD: usize = 256; + +/// A `WxH` frame size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Size { + pub width: u32, + pub height: u32, +} + +impl Size { + pub const fn new(width: u32, height: u32) -> Self { + Self { width, height } + } +} + +impl FromStr for Size { + type Err = String; + + fn from_str(s: &str) -> Result { + let (w, h) = s + .split_once(['x', 'X']) + .ok_or_else(|| format!("expected WxH, got {s:?}"))?; + let dim = |v: &str| { + v.trim() + .parse::() + .ok() + .filter(|v| *v > 0) + .ok_or_else(|| format!("invalid dimension {v:?} in {s:?}")) + }; + Ok(Size::new(dim(w)?, dim(h)?)) + } +} + +impl fmt::Display for Size { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}x{}", self.width, self.height) + } +} + +/// Capture gamescope's PipeWire stream and feed it to HyperHDR's flatbuffers server. +#[derive(Parser, Debug, Clone)] +#[command(version, about)] +pub struct Config { + /// HyperHDR host (IP address or name) + #[arg(long, env = "GSHDR_HOST")] + pub host: String, + + /// HyperHDR flatbuffers server port + #[arg(long, env = "GSHDR_PORT", default_value_t = 19400)] + pub port: u16, + + /// Source priority in HyperHDR (50..250); must be lower than the USB grabber's (240) + #[arg(long, env = "GSHDR_PRIORITY", default_value_t = 150, + value_parser = clap::value_parser!(i32).range(50..=250))] + pub priority: i32, + + /// Source name shown in HyperHDR (the server appends "@") + #[arg(long, env = "GSHDR_ORIGIN", default_value = "gamescope")] + pub origin: String, + + /// Maximum size of the frame sent to HyperHDR; the aspect ratio is taken from the frame + #[arg(long, env = "GSHDR_SIZE", default_value = "320x180")] + pub size: Size, + + /// Request size x N from gamescope and area-average down by N on the CPU (1..4) + #[arg(long, env = "GSHDR_SUPERSAMPLE", default_value_t = 1, + value_parser = clap::value_parser!(u32).range(1..=4))] + pub supersample: u32, + + /// Upper bound of the send rate (1..120) + #[arg(long, env = "GSHDR_MAX_FPS", default_value_t = 30, + value_parser = clap::value_parser!(u32).range(1..=120))] + pub max_fps: u32, + + /// Re-send the last frame after this many milliseconds without new frames + #[arg(long, env = "GSHDR_KEEPALIVE_MS", default_value_t = 1000, + value_parser = clap::value_parser!(u64).range(1..))] + pub keepalive_ms: u64, + + /// Clear the priority after this many seconds without new frames (0 = never) + #[arg(long, env = "GSHDR_IDLE_CLEAR_SECS", default_value_t = 0)] + pub idle_clear_secs: u64, + + /// node.name of the source node + #[arg(long, env = "GSHDR_TARGET", default_value = "gamescope")] + pub target: String, + + /// TCP connect timeout + #[arg(long, default_value_t = 2000, value_parser = clap::value_parser!(u64).range(1..))] + pub connect_timeout_ms: u64, + + /// A write blocked for longer than this means the connection is dead + #[arg(long, default_value_t = 2000, value_parser = clap::value_parser!(u64).range(1..))] + pub write_timeout_ms: u64, + + /// Log level or tracing filter expression + #[arg(long, env = "RUST_LOG", default_value = "info")] + pub log_level: String, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ConfigError { + #[error("--host must not be empty")] + EmptyHost, + #[error("--target must not be empty")] + EmptyTarget, + #[error("--size {0}: each dimension must be <= {MAX_SIZE_DIM}")] + SizeTooLarge(Size), + #[error("--size {size} x --supersample {supersample} = {requested}: each dimension must be <= {MAX_REQUEST_DIM}")] + RequestTooLarge { + size: Size, + supersample: u32, + requested: Size, + }, + #[error("--size {size}: an image message would be up to {bytes} bytes, HyperHDR accepts less than {HYPERHDR_MAX_MESSAGE}")] + MessageTooLarge { size: Size, bytes: usize }, +} + +impl Config { + /// Checks cross-field constraints. Returns warnings that do not prevent startup. + pub fn validate(&self) -> Result, ConfigError> { + if self.host.trim().is_empty() { + return Err(ConfigError::EmptyHost); + } + if self.target.is_empty() { + return Err(ConfigError::EmptyTarget); + } + let size = self.size; + if size.width > MAX_SIZE_DIM || size.height > MAX_SIZE_DIM { + return Err(ConfigError::SizeTooLarge(size)); + } + let requested = self.requested_size(); + if requested.width > MAX_REQUEST_DIM || requested.height > MAX_REQUEST_DIM { + return Err(ConfigError::RequestTooLarge { + size, + supersample: self.supersample, + requested, + }); + } + let bytes = size.width as usize * size.height as usize * 3 + IMAGE_MESSAGE_OVERHEAD; + if bytes >= HYPERHDR_MAX_MESSAGE { + return Err(ConfigError::MessageTooLarge { size, bytes }); + } + + let mut warnings = Vec::new(); + if self.keepalive() >= HYPERHDR_DEFAULT_TIMEOUT { + warnings.push(format!( + "--keepalive-ms {} is not below HyperHDR's default connection timeout ({} s); \ + the server may drop the connection while the picture is static", + self.keepalive_ms, + HYPERHDR_DEFAULT_TIMEOUT.as_secs() + )); + } + Ok(warnings) + } + + /// Size requested from gamescope via `requested_size`. + pub fn requested_size(&self) -> Size { + Size::new( + self.size.width.saturating_mul(self.supersample), + self.size.height.saturating_mul(self.supersample), + ) + } + + pub fn keepalive(&self) -> Duration { + Duration::from_millis(self.keepalive_ms) + } + + pub fn idle_clear(&self) -> Option { + (self.idle_clear_secs > 0).then(|| Duration::from_secs(self.idle_clear_secs)) + } + + pub fn min_send_interval(&self) -> Duration { + Duration::from_secs(1) / self.max_fps + } + + pub fn connect_timeout(&self) -> Duration { + Duration::from_millis(self.connect_timeout_ms) + } + + pub fn write_timeout(&self) -> Duration { + Duration::from_millis(self.write_timeout_ms) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + let mut argv = vec!["gamescope-hyperhdr", "--host", "192.0.2.10"]; + argv.extend_from_slice(args); + Config::try_parse_from(argv) + } + + #[test] + fn size_parsing() { + assert_eq!("320x180".parse::(), Ok(Size::new(320, 180))); + assert_eq!("64X36".parse::(), Ok(Size::new(64, 36))); + assert!("320".parse::().is_err()); + assert!("0x180".parse::().is_err()); + assert!("320x-1".parse::().is_err()); + assert!("axb".parse::().is_err()); + } + + #[test] + fn defaults() { + let cfg = parse(&[]).unwrap(); + assert_eq!(cfg.port, 19400); + assert_eq!(cfg.priority, 150); + assert_eq!(cfg.origin, "gamescope"); + assert_eq!(cfg.size, Size::new(320, 180)); + assert_eq!(cfg.supersample, 1); + assert_eq!(cfg.max_fps, 30); + assert_eq!(cfg.keepalive(), Duration::from_secs(1)); + assert_eq!(cfg.idle_clear(), None); + assert_eq!(cfg.target, "gamescope"); + assert_eq!(cfg.validate(), Ok(vec![])); + assert_eq!(cfg.requested_size(), Size::new(320, 180)); + } + + #[test] + fn range_checks() { + assert!(parse(&["--priority", "49"]).is_err()); + assert!(parse(&["--priority", "251"]).is_err()); + assert!(parse(&["--priority", "250"]).is_ok()); + assert!(parse(&["--supersample", "0"]).is_err()); + assert!(parse(&["--supersample", "5"]).is_err()); + assert!(parse(&["--max-fps", "0"]).is_err()); + assert!(parse(&["--max-fps", "121"]).is_err()); + assert!(parse(&["--keepalive-ms", "0"]).is_err()); + assert!(parse(&["--size", "320x0"]).is_err()); + } + + #[test] + fn validation() { + let cfg = parse(&["--size", "1921x100"]).unwrap(); + assert_eq!( + cfg.validate(), + Err(ConfigError::SizeTooLarge(Size::new(1921, 100))) + ); + + let cfg = parse(&["--size", "1920x1080", "--supersample", "3"]).unwrap(); + assert!(matches!( + cfg.validate(), + Err(ConfigError::RequestTooLarge { .. }) + )); + + let cfg = parse(&["--size", "1920x1080", "--supersample", "2"]).unwrap(); + assert_eq!(cfg.validate(), Ok(vec![])); + assert_eq!(cfg.requested_size(), Size::new(3840, 2160)); + + let cfg = parse(&["--size", "1920x1920"]).unwrap(); + assert!(matches!( + cfg.validate(), + Err(ConfigError::MessageTooLarge { .. }) + )); + + let cfg = parse(&["--keepalive-ms", "5000"]).unwrap(); + assert_eq!(cfg.validate().unwrap().len(), 1); + + let cfg = parse(&["--target", ""]).unwrap(); + assert_eq!(cfg.validate(), Err(ConfigError::EmptyTarget)); + } + + #[test] + fn derived_durations() { + let cfg = parse(&["--max-fps", "60", "--idle-clear-secs", "7"]).unwrap(); + assert_eq!(cfg.min_send_interval(), Duration::from_secs(1) / 60); + assert_eq!(cfg.idle_clear(), Some(Duration::from_secs(7))); + } +} diff --git a/src/lib.rs b/src/lib.rs index d1cc1ff..ac0347d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ //! Captures gamescope's PipeWire stream at a small `requested_size` and forwards it to //! HyperHDR's flatbuffers server. +pub mod config; pub mod hyperhdr;