#![forbid(unsafe_code)] use std::time::Duration; use crate::handshake::PeerCaps; use crate::input::camera::{CameraCodec, CameraConfig}; pub const DEFAULT_SERVER_ADDR: &str = "https://38.28.125.112:50051"; #[must_use] /// Resolve the server address from `--server`, positional args, env, or default. pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String { args.windows(2) .find_map(|pair| { (pair[0] == "--server" && !pair[1].starts_with("--")).then(|| pair[1].clone()) }) .or_else(|| args.iter().find(|arg| !arg.starts_with("--")).cloned()) .or_else(|| env_addr.map(ToOwned::to_owned)) .unwrap_or_else(|| DEFAULT_SERVER_ADDR.to_string()) } #[must_use] /// Build local camera capture settings from negotiated peer capabilities. pub fn camera_config_from_caps(caps: &PeerCaps) -> Option { let codec = std::env::var("LESAVKA_CAM_CODEC") .ok() .and_then(|value| parse_camera_codec(&value)) .or_else(|| parse_camera_codec(caps.camera_codec.as_deref()?))?; Some(CameraConfig { codec, width: caps.camera_width?, height: caps.camera_height?, fps: caps.camera_fps?, }) } #[must_use] /// Clamp queue depth so stale video cannot backlog behind live input. pub fn sanitize_video_queue(queue: Option) -> usize { queue.unwrap_or(8).max(4) } #[must_use] /// Exponential reconnect delay capped at 30 seconds. pub fn next_delay(current: Duration) -> Duration { match current.as_secs() { 1..=15 => current * 2, _ => Duration::from_secs(30), } } /// Parse the camera codec string used by launcher and server negotiation. /// /// Inputs: operator/env codec text. Output: the supported transport codec when /// recognized. Why: the client must not silently fall back to a differently /// calibrated upstream path when the UI asks for HEVC or MJPEG. pub(crate) fn parse_camera_codec(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "mjpeg" | "mjpg" | "jpeg" => Some(CameraCodec::Mjpeg), "h264" => Some(CameraCodec::H264), "hevc" | "h265" | "h.265" => Some(CameraCodec::Hevc), _ => None, } } #[cfg(test)] mod tests { use super::{ DEFAULT_SERVER_ADDR, camera_config_from_caps, next_delay, resolve_server_addr, sanitize_video_queue, }; use crate::handshake::PeerCaps; use crate::input::camera::CameraCodec; use serial_test::serial; use std::time::Duration; #[test] fn resolve_server_addr_prefers_cli_then_env_then_default() { assert_eq!( resolve_server_addr(&[String::from("http://cli:1")], Some("http://env:2")), "http://cli:1" ); assert_eq!( resolve_server_addr( &[ String::from("--no-launcher"), String::from("--server"), String::from("http://cli-flag:3"), ], Some("http://env:2"), ), "http://cli-flag:3" ); assert_eq!( resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")), "http://env:2" ); assert_eq!( resolve_server_addr(&[], Some("http://env:2")), "http://env:2" ); assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR); } #[test] #[serial] fn camera_config_from_caps_requires_complete_profile() { temp_env::with_var("LESAVKA_CAM_CODEC", None::<&str>, || { let mut caps = PeerCaps { camera: true, microphone: false, bundled_webcam_media: false, server_version: None, camera_output: Some(String::from("uvc")), camera_codec: Some(String::from("mjpeg")), camera_width: Some(1280), camera_height: Some(720), camera_fps: Some(25), eye_width: None, eye_height: None, eye_fps: None, }; let config = camera_config_from_caps(&caps).expect("complete caps should map"); assert!(matches!(config.codec, CameraCodec::Mjpeg)); assert_eq!(config.width, 1280); caps.camera_codec = Some(String::from("h265")); let config = camera_config_from_caps(&caps).expect("h265 alias should map"); assert!(matches!(config.codec, CameraCodec::Hevc)); caps.camera_codec = Some(String::from("vp9")); assert!(camera_config_from_caps(&caps).is_none()); }); } #[test] #[serial] fn camera_config_from_caps_honors_launcher_codec_override() { let caps = PeerCaps { camera: true, microphone: false, bundled_webcam_media: false, server_version: None, camera_output: Some(String::from("uvc")), camera_codec: Some(String::from("hevc")), camera_width: Some(1280), camera_height: Some(720), camera_fps: Some(25), eye_width: None, eye_height: None, eye_fps: None, }; temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || { let config = camera_config_from_caps(&caps).expect("override should map"); assert!(matches!(config.codec, CameraCodec::Mjpeg)); }); } #[test] fn sanitize_video_queue_enforces_floor() { assert_eq!(sanitize_video_queue(None), 8); assert_eq!(sanitize_video_queue(Some(1)), 4); assert_eq!(sanitize_video_queue(Some(32)), 32); } #[test] fn next_delay_doubles_until_capped() { assert_eq!(next_delay(Duration::from_secs(1)), Duration::from_secs(2)); assert_eq!(next_delay(Duration::from_secs(15)), Duration::from_secs(30)); assert_eq!(next_delay(Duration::from_secs(31)), Duration::from_secs(30)); } }