use anyhow::{Context, Result, bail}; use lesavka_common::lesavka::{ CalibrationAction, CalibrationRequest, CalibrationState, CapturePowerCommand, HandshakeSet, OutputDelayProbeRequest, SetCapturePowerRequest, relay_client::RelayClient, }; #[cfg(not(coverage))] use lesavka_common::lesavka::{Empty, handshake_client::HandshakeClient}; #[cfg(not(coverage))] use tonic::Request; use tonic::transport::Channel; use lesavka_client::relay_transport; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CommandKind { Status, Version, CalibrationStatus, CalibrationAdjust, CalibrationRestoreDefault, CalibrationRestoreFactory, CalibrationSaveDefault, Auto, On, Off, RecoverUsb, RecoverUac, RecoverUvc, ResetUsb, UpstreamSync, OutputDelayProbe, } impl CommandKind { /// Parse the intentionally small CLI vocabulary into safe relay actions. fn parse(value: &str) -> Option { match value { "status" | "get" => Some(Self::Status), "version" | "versions" => Some(Self::Version), "calibration" | "calibration-status" => Some(Self::CalibrationStatus), "calibrate" | "calibration-adjust" => Some(Self::CalibrationAdjust), "calibration-restore-default" | "restore-calibration" => { Some(Self::CalibrationRestoreDefault) } "calibration-restore-factory" | "factory-calibration" => { Some(Self::CalibrationRestoreFactory) } "calibration-save-default" | "save-calibration" => Some(Self::CalibrationSaveDefault), "auto" => Some(Self::Auto), "on" | "force-on" => Some(Self::On), "off" | "force-off" => Some(Self::Off), "recover-usb" => Some(Self::RecoverUsb), "recover-uac" => Some(Self::RecoverUac), "recover-uvc" => Some(Self::RecoverUvc), "reset-usb" | "hard-reset-usb" => Some(Self::ResetUsb), "upstream-sync" | "sync" => Some(Self::UpstreamSync), "output-delay-probe" | "probe-output-delay" => Some(Self::OutputDelayProbe), _ => None, } } } #[derive(Debug, Eq, PartialEq)] struct Config { server: String, command: CommandKind, audio_delta_us: i64, video_delta_us: i64, note: String, probe_duration_seconds: u32, probe_warmup_seconds: u32, probe_pulse_period_ms: u32, probe_pulse_width_ms: u32, probe_event_width_codes: String, probe_audio_delay_us: i64, probe_video_delay_us: i64, } #[derive(Debug, Default, Eq, PartialEq)] struct ParsedCommandArgs { audio_delta_us: i64, video_delta_us: i64, note: String, probe_duration_seconds: u32, probe_warmup_seconds: u32, probe_pulse_period_ms: u32, probe_pulse_width_ms: u32, probe_event_width_codes: String, probe_audio_delay_us: i64, probe_video_delay_us: i64, } #[derive(Debug, Eq, PartialEq)] enum ParseOutcome { Run(Config), Help, } fn usage() -> &'static str { "Usage: lesavka-relayctl [--server http://HOST:50051] [note]|calibration-save-default|calibration-restore-default|calibration-restore-factory|auto|on|off|recover-usb|recover-uac|recover-uvc|reset-usb>" } /// Keeps `parse_args_outcome_from` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn parse_args_outcome_from(args: I) -> Result where I: IntoIterator, S: Into, { let mut args = args.into_iter().map(Into::into); let mut server = "http://127.0.0.1:50051".to_string(); let mut command = None; let mut command_args = Vec::new(); while let Some(arg) = args.next() { match arg.as_str() { "--server" => { server = args .next() .context("missing value after --server")? .trim() .to_string(); } "--help" | "-h" => { return Ok(ParseOutcome::Help); } _ if command.is_none() => { command = CommandKind::parse(arg.as_str()); if command.is_none() { bail!("unknown command `{arg}`\n{}", usage()); } } _ => command_args.push(arg), } } let command = command.unwrap_or(CommandKind::Status); let parsed = parse_command_args(command, command_args)?; Ok(ParseOutcome::Run(Config { server, command, audio_delta_us: parsed.audio_delta_us, video_delta_us: parsed.video_delta_us, note: parsed.note, probe_duration_seconds: parsed.probe_duration_seconds, probe_warmup_seconds: parsed.probe_warmup_seconds, probe_pulse_period_ms: parsed.probe_pulse_period_ms, probe_pulse_width_ms: parsed.probe_pulse_width_ms, probe_event_width_codes: parsed.probe_event_width_codes, probe_audio_delay_us: parsed.probe_audio_delay_us, probe_video_delay_us: parsed.probe_video_delay_us, })) } /// Keeps `parse_command_args` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn parse_command_args(command: CommandKind, args: Vec) -> Result { if command == CommandKind::OutputDelayProbe { return parse_output_delay_probe_args(args); } if command != CommandKind::CalibrationAdjust { if let Some(arg) = args.first() { bail!("unexpected argument `{arg}`\n{}", usage()); } return Ok(ParsedCommandArgs::default()); } if args.len() < 2 { bail!( "calibrate requires audio_delta_us and video_delta_us\n{}", usage() ); } let audio_delta_us = args[0] .parse::() .with_context(|| format!("parsing audio_delta_us `{}`", args[0]))?; let video_delta_us = args[1] .parse::() .with_context(|| format!("parsing video_delta_us `{}`", args[1]))?; let note = args[2..].join(" "); Ok(ParsedCommandArgs { audio_delta_us, video_delta_us, note, ..ParsedCommandArgs::default() }) } /// Keeps `parse_output_delay_probe_args` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn parse_output_delay_probe_args(args: Vec) -> Result { if args.len() > 7 { bail!( "output-delay-probe accepts at most seven arguments\n{}", usage() ); } let parse_u32 = |index: usize, name: &str| -> Result { args.get(index) .map(|value| { value .parse::() .with_context(|| format!("parsing {name} `{value}`")) }) .transpose() .map(|value| value.unwrap_or(0)) }; let parse_i64 = |index: usize, name: &str| -> Result { args.get(index) .map(|value| { value .parse::() .with_context(|| format!("parsing {name} `{value}`")) }) .transpose() .map(|value| value.unwrap_or(0)) }; Ok(ParsedCommandArgs { probe_duration_seconds: parse_u32(0, "duration_s")?, probe_warmup_seconds: parse_u32(1, "warmup_s")?, probe_pulse_period_ms: parse_u32(2, "period_ms")?, probe_pulse_width_ms: parse_u32(3, "width_ms")?, probe_event_width_codes: args.get(4).cloned().unwrap_or_default(), probe_audio_delay_us: parse_i64(5, "audio_delay_us")?, probe_video_delay_us: parse_i64(6, "video_delay_us")?, ..ParsedCommandArgs::default() }) } #[cfg(test)] /// Keeps `parse_args_from` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn parse_args_from(args: I) -> Result where I: IntoIterator, S: Into, { match parse_args_outcome_from(args)? { ParseOutcome::Run(config) => Ok(config), ParseOutcome::Help => bail!("{}", usage()), } } fn parse_args() -> Result { parse_args_outcome_from(std::env::args().skip(1)) } /// Keeps `capture_power_request` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn capture_power_request(command: CommandKind) -> Option { let (enabled, command) = match command { CommandKind::Status | CommandKind::Version | CommandKind::CalibrationStatus | CommandKind::CalibrationAdjust | CommandKind::CalibrationRestoreDefault | CommandKind::CalibrationRestoreFactory | CommandKind::CalibrationSaveDefault | CommandKind::RecoverUsb | CommandKind::RecoverUac | CommandKind::RecoverUvc | CommandKind::ResetUsb | CommandKind::UpstreamSync | CommandKind::OutputDelayProbe => return None, CommandKind::Auto => (false, CapturePowerCommand::Auto), CommandKind::On => (true, CapturePowerCommand::ForceOn), CommandKind::Off => (false, CapturePowerCommand::ForceOff), }; Some(SetCapturePowerRequest { enabled, command: command as i32, }) } #[cfg(not(coverage))] async fn connect(server_addr: &str) -> Result> { let channel = relay_transport::endpoint(server_addr)? .tcp_nodelay(true) .connect() .await .with_context(|| format!("connecting to relay at {server_addr}"))?; Ok(RelayClient::new(channel)) } #[cfg(not(coverage))] /// Keeps `get_server_capabilities` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. async fn get_server_capabilities(server_addr: &str) -> Result { let channel = relay_transport::endpoint(server_addr)? .tcp_nodelay(true) .connect() .await .with_context(|| format!("connecting to handshake at {server_addr}"))?; let mut client = HandshakeClient::new(channel); Ok(client .get_capabilities(Request::new(Empty {})) .await .context("querying server capabilities")? .into_inner()) } #[cfg(coverage)] async fn connect(server_addr: &str) -> Result> { let channel = relay_transport::endpoint(server_addr)? .tcp_nodelay(true) .connect_lazy(); Ok(RelayClient::new(channel)) } fn print_state(state: lesavka_common::lesavka::CapturePowerState) { println!("available={}", state.available); println!("enabled={}", state.enabled); println!("mode={}", state.mode); println!("detected_devices={}", state.detected_devices); println!("active_leases={}", state.active_leases); println!("unit={}", state.unit); println!("detail={}", state.detail); } /// Keeps `print_versions` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn print_versions(server_addr: &str, caps: &HandshakeSet) { let server_version = if caps.server_version.is_empty() { "unknown" } else { caps.server_version.as_str() }; let server_revision = if caps.server_revision.is_empty() { "unknown" } else { caps.server_revision.as_str() }; println!("client_version={}", lesavka_client::VERSION); println!("client_revision={}", lesavka_client::REVISION); println!("server_addr={server_addr}"); println!("server_version={server_version}"); println!("server_revision={server_revision}"); println!("server_camera_output={}", caps.camera_output); println!("server_camera_codec={}", caps.camera_codec); println!("server_camera_width={}", caps.camera_width); println!("server_camera_height={}", caps.camera_height); println!("server_camera_fps={}", caps.camera_fps); println!("server_bundled_webcam_media={}", caps.bundled_webcam_media); } include!("lesavka_relayctl/upstream_sync_formatting.rs"); /// Keeps `print_calibration_state` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn print_calibration_state(state: CalibrationState) { println!("calibration_profile={}", state.profile); println!( "calibration_factory_audio_offset_us={}", state.factory_audio_offset_us ); println!( "calibration_factory_video_offset_us={}", state.factory_video_offset_us ); println!( "calibration_default_audio_offset_us={}", state.default_audio_offset_us ); println!( "calibration_default_video_offset_us={}", state.default_video_offset_us ); println!( "calibration_active_audio_offset_us={}", state.active_audio_offset_us ); println!( "calibration_active_video_offset_us={}", state.active_video_offset_us ); println!("calibration_source={}", state.source); println!("calibration_confidence={}", state.confidence); println!("calibration_updated_at={}", state.updated_at); println!("calibration_detail={}", state.detail); } /// Keeps `calibration_request_for` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn calibration_request_for(config: &Config) -> Option { let action = match config.command { CommandKind::CalibrationAdjust => CalibrationAction::AdjustActive, CommandKind::CalibrationRestoreDefault => CalibrationAction::RestoreDefault, CommandKind::CalibrationRestoreFactory => CalibrationAction::RestoreFactory, CommandKind::CalibrationSaveDefault => CalibrationAction::SaveActiveAsDefault, CommandKind::Status | CommandKind::Version | CommandKind::CalibrationStatus | CommandKind::Auto | CommandKind::On | CommandKind::Off | CommandKind::RecoverUsb | CommandKind::RecoverUac | CommandKind::RecoverUvc | CommandKind::ResetUsb | CommandKind::UpstreamSync | CommandKind::OutputDelayProbe => return None, }; Some(CalibrationRequest { action: action as i32, audio_delta_us: config.audio_delta_us, video_delta_us: config.video_delta_us, observed_delivery_skew_ms: 0.0, observed_enqueue_skew_ms: 0.0, note: config.note.clone(), }) } include!("lesavka_relayctl/command_dispatch.rs"); #[cfg(coverage)] fn main() { let _ = parse_args as fn() -> Result; } #[cfg(test)] #[path = "tests/lesavka_relayctl.rs"] mod tests;