use anyhow::{Context, Result, bail}; use lesavka_common::lesavka::{ CalibrationAction, CalibrationRequest, CalibrationState, CapturePowerCommand, HandshakeSet, 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, } 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), _ => None, } } } #[derive(Debug, Eq, PartialEq)] struct Config { server: String, command: CommandKind, audio_delta_us: i64, video_delta_us: i64, note: String, } #[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>" } 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 (audio_delta_us, video_delta_us, note) = parse_command_args(command, command_args)?; Ok(ParseOutcome::Run(Config { server, command, audio_delta_us, video_delta_us, note, })) } fn parse_command_args(command: CommandKind, args: Vec) -> Result<(i64, i64, String)> { if command != CommandKind::CalibrationAdjust { if let Some(arg) = args.first() { bail!("unexpected argument `{arg}`\n{}", usage()); } return Ok((0, 0, String::new())); } 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((audio_delta_us, video_delta_us, note)) } #[cfg(test)] 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)) } 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 => 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))] 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); } 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); } fn print_upstream_sync(state: lesavka_common::lesavka::UpstreamSyncState) { println!("planner_session_id={}", state.session_id); println!("planner_phase={}", state.phase); println!( "planner_live_lag_ms={}", state .live_lag_ms .map(|value| format!("{value:.1}")) .unwrap_or_else(|| "pending".to_string()) ); println!( "planner_skew_ms={}", state .planner_skew_ms .map(|value| format!("{value:+.1}")) .unwrap_or_else(|| "pending".to_string()) ); println!("planner_stale_audio_drops={}", state.stale_audio_drops); println!("planner_stale_video_drops={}", state.stale_video_drops); println!("planner_skew_video_drops={}", state.skew_video_drops); println!("planner_freshness_reanchors={}", state.freshness_reanchors); println!("planner_startup_timeouts={}", state.startup_timeouts); println!("planner_video_freezes={}", state.video_freezes); println!("planner_detail={}", state.last_reason); } 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); } 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 => 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(), }) } #[cfg(not(coverage))] #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { let config = match parse_args()? { ParseOutcome::Run(config) => config, ParseOutcome::Help => { println!("{}", usage()); return Ok(()); } }; if config.command == CommandKind::Version { let caps = get_server_capabilities(config.server.as_str()).await?; print_versions(config.server.as_str(), &caps); return Ok(()); } let mut client = connect(config.server.as_str()).await?; if let Some(request) = capture_power_request(config.command) { let context = match config.command { CommandKind::Auto => "setting capture power to auto", CommandKind::On => "forcing capture power on", CommandKind::Off => "forcing capture power off", CommandKind::Status | CommandKind::Version | CommandKind::CalibrationStatus | CommandKind::CalibrationAdjust | CommandKind::CalibrationRestoreDefault | CommandKind::CalibrationRestoreFactory | CommandKind::CalibrationSaveDefault | CommandKind::RecoverUsb | CommandKind::RecoverUac | CommandKind::RecoverUvc | CommandKind::ResetUsb | CommandKind::UpstreamSync => unreachable!(), }; let reply = client .set_capture_power(Request::new(request)) .await .context(context)? .into_inner(); print_state(reply); return Ok(()); } if let Some(request) = calibration_request_for(&config) { let reply = client .calibrate(Request::new(request)) .await .context("applying upstream A/V calibration")? .into_inner(); print_calibration_state(reply); return Ok(()); } let reply = match config.command { CommandKind::Status => client .get_capture_power(Request::new(Empty {})) .await .context("querying capture power state")? .into_inner(), CommandKind::RecoverUsb => { let reply = client .recover_usb(Request::new(Empty {})) .await .context("requesting soft USB recovery")? .into_inner(); println!("ok={}", reply.ok); return Ok(()); } CommandKind::RecoverUac => { let reply = client .recover_uac(Request::new(Empty {})) .await .context("requesting soft UAC recovery")? .into_inner(); println!("ok={}", reply.ok); return Ok(()); } CommandKind::RecoverUvc => { let reply = client .recover_uvc(Request::new(Empty {})) .await .context("requesting soft UVC recovery")? .into_inner(); println!("ok={}", reply.ok); return Ok(()); } CommandKind::ResetUsb => { let reply = client .reset_usb(Request::new(Empty {})) .await .context("forcing USB gadget recovery")? .into_inner(); println!("ok={}", reply.ok); return Ok(()); } CommandKind::UpstreamSync => { let reply = client .get_upstream_sync(Request::new(Empty {})) .await .context("querying upstream sync planner state")? .into_inner(); print_upstream_sync(reply); return Ok(()); } CommandKind::CalibrationStatus => { let reply = client .get_calibration(Request::new(Empty {})) .await .context("querying upstream A/V calibration")? .into_inner(); print_calibration_state(reply); return Ok(()); } CommandKind::Version | CommandKind::Auto | CommandKind::On | CommandKind::Off => { unreachable!() } CommandKind::CalibrationAdjust | CommandKind::CalibrationRestoreDefault | CommandKind::CalibrationRestoreFactory | CommandKind::CalibrationSaveDefault => unreachable!(), }; print_state(reply); Ok(()) } #[cfg(coverage)] fn main() { let _ = parse_args as fn() -> Result; } #[cfg(test)] mod tests { use super::{ CalibrationAction, CapturePowerCommand, CommandKind, Config, ParseOutcome, calibration_request_for, capture_power_request, parse_args_from, parse_args_outcome_from, }; use lesavka_common::lesavka::CapturePowerState; #[test] /// Verifies safe recovery commands stay separate from explicit hard reset. fn command_aliases_parse_to_stable_actions() { assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status)); assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status)); assert_eq!(CommandKind::parse("version"), Some(CommandKind::Version)); assert_eq!(CommandKind::parse("versions"), Some(CommandKind::Version)); assert_eq!( CommandKind::parse("calibration"), Some(CommandKind::CalibrationStatus) ); assert_eq!( CommandKind::parse("calibrate"), Some(CommandKind::CalibrationAdjust) ); assert_eq!( CommandKind::parse("calibration-restore-default"), Some(CommandKind::CalibrationRestoreDefault) ); assert_eq!( CommandKind::parse("calibration-restore-factory"), Some(CommandKind::CalibrationRestoreFactory) ); assert_eq!( CommandKind::parse("calibration-save-default"), Some(CommandKind::CalibrationSaveDefault) ); assert_eq!( CommandKind::parse("upstream-sync"), Some(CommandKind::UpstreamSync) ); assert_eq!(CommandKind::parse("sync"), Some(CommandKind::UpstreamSync)); assert_eq!(CommandKind::parse("force-on"), Some(CommandKind::On)); assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off)); assert_eq!( CommandKind::parse("recover-usb"), Some(CommandKind::RecoverUsb) ); assert_eq!( CommandKind::parse("recover-uac"), Some(CommandKind::RecoverUac) ); assert_eq!( CommandKind::parse("recover-uvc"), Some(CommandKind::RecoverUvc) ); assert_eq!( CommandKind::parse("hard-reset-usb"), Some(CommandKind::ResetUsb) ); assert_eq!(CommandKind::parse("wat"), None); } #[test] fn parse_args_defaults_to_local_status() { let config = parse_args_from(std::iter::empty::<&str>()).expect("default config"); assert_eq!(config.server, "http://127.0.0.1:50051"); assert_eq!(config.command, CommandKind::Status); assert_eq!(config.audio_delta_us, 0); assert_eq!(config.video_delta_us, 0); assert!(config.note.is_empty()); } #[test] fn parse_args_accepts_server_and_command() { let config = parse_args_from(["--server", " http://lab:50051 ", "upstream-sync"]).expect("config"); assert_eq!(config.server, "http://lab:50051"); assert_eq!(config.command, CommandKind::UpstreamSync); } #[test] fn parse_args_accepts_calibration_adjustment() { let config = parse_args_from([ "--server", "http://lab:50051", "calibrate", "0", "71600", "probe", "median", ]) .expect("calibration config"); assert_eq!(config.command, CommandKind::CalibrationAdjust); assert_eq!(config.audio_delta_us, 0); assert_eq!(config.video_delta_us, 71_600); assert_eq!(config.note, "probe median"); } #[test] fn parse_args_rejects_bad_inputs() { assert!(parse_args_from(["--server"]).is_err()); assert!(parse_args_from(["nope"]).is_err()); assert!(parse_args_from(["status", "extra"]).is_err()); assert!(parse_args_from(["calibrate"]).is_err()); assert!(parse_args_from(["calibrate", "0", "not-int"]).is_err()); } #[test] fn parse_args_reports_help_without_exiting_test_process() { assert_eq!( parse_args_outcome_from(["--help"]).unwrap(), ParseOutcome::Help ); assert_eq!(parse_args_outcome_from(["-h"]).unwrap(), ParseOutcome::Help); assert!(parse_args_from(["--help"]).is_err()); } #[test] fn parse_args_runtime_wrapper_is_non_panicking_under_tests() { let _ = super::parse_args(); } #[cfg(coverage)] #[test] fn coverage_main_references_runtime_parser() { super::main(); } #[cfg(coverage)] #[tokio::test(flavor = "current_thread")] async fn coverage_connect_uses_lazy_channel_after_endpoint_validation() { let client = super::connect("http://127.0.0.1:1") .await .expect("coverage lazy channel"); drop(client); } #[test] /// Keeps status/read commands from accidentally mutating capture power. fn mutating_commands_map_to_capture_power_requests() { let auto = capture_power_request(CommandKind::Auto).expect("auto request"); assert!(!auto.enabled); assert_eq!(auto.command, CapturePowerCommand::Auto as i32); let on = capture_power_request(CommandKind::On).expect("on request"); assert!(on.enabled); assert_eq!(on.command, CapturePowerCommand::ForceOn as i32); let off = capture_power_request(CommandKind::Off).expect("off request"); assert!(!off.enabled); assert_eq!(off.command, CapturePowerCommand::ForceOff as i32); assert!(capture_power_request(CommandKind::Status).is_none()); assert!(capture_power_request(CommandKind::Version).is_none()); assert!(capture_power_request(CommandKind::CalibrationStatus).is_none()); assert!(capture_power_request(CommandKind::CalibrationAdjust).is_none()); assert!(capture_power_request(CommandKind::CalibrationRestoreDefault).is_none()); assert!(capture_power_request(CommandKind::CalibrationRestoreFactory).is_none()); assert!(capture_power_request(CommandKind::CalibrationSaveDefault).is_none()); assert!(capture_power_request(CommandKind::RecoverUsb).is_none()); assert!(capture_power_request(CommandKind::RecoverUac).is_none()); assert!(capture_power_request(CommandKind::RecoverUvc).is_none()); assert!(capture_power_request(CommandKind::ResetUsb).is_none()); assert!(capture_power_request(CommandKind::UpstreamSync).is_none()); } #[test] fn print_state_accepts_full_capture_power_payload() { super::print_state(CapturePowerState { available: true, enabled: false, mode: "auto".to_string(), detected_devices: 2, active_leases: 1, unit: "lesavka-capture-power.service".to_string(), detail: "ready".to_string(), }); } #[test] fn print_calibration_accepts_full_payload() { super::print_calibration_state(lesavka_common::lesavka::CalibrationState { profile: "mjpeg".to_string(), factory_audio_offset_us: 0, factory_video_offset_us: 1_090_000, default_audio_offset_us: 0, default_video_offset_us: 1_090_000, active_audio_offset_us: 0, active_video_offset_us: 1_161_600, source: "manual".to_string(), confidence: "manual".to_string(), updated_at: "2026-05-02T00:00:00Z".to_string(), detail: "probe nudge".to_string(), }); } #[test] fn calibration_requests_are_only_built_for_calibration_mutations() { let config = Config { server: "http://127.0.0.1:50051".to_string(), command: CommandKind::CalibrationAdjust, audio_delta_us: 0, video_delta_us: 71_600, note: "probe".to_string(), }; let request = calibration_request_for(&config).expect("request"); assert_eq!(request.action, CalibrationAction::AdjustActive as i32); assert_eq!(request.audio_delta_us, 0); assert_eq!(request.video_delta_us, 71_600); assert_eq!(request.note, "probe"); let status = Config { command: CommandKind::CalibrationStatus, ..config }; assert!(calibration_request_for(&status).is_none()); } }