lesavka/client/src/bin/lesavka-relayctl.rs

424 lines
15 KiB
Rust
Raw Normal View History

use anyhow::{Context, Result, bail};
use lesavka_common::lesavka::{
CalibrationAction, CalibrationRequest, CalibrationState, CapturePowerCommand, HandshakeSet,
2026-05-03 14:00:58 -03:00
OutputDelayProbeRequest, SetCapturePowerRequest, relay_client::RelayClient,
};
#[cfg(not(coverage))]
2026-05-01 16:06:52 -03:00
use lesavka_common::lesavka::{Empty, handshake_client::HandshakeClient};
#[cfg(not(coverage))]
use tonic::Request;
use tonic::transport::Channel;
2026-04-30 08:16:57 -03:00
use lesavka_client::relay_transport;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommandKind {
Status,
2026-05-01 16:06:52 -03:00
Version,
CalibrationStatus,
CalibrationAdjust,
CalibrationRestoreDefault,
CalibrationRestoreFactory,
CalibrationSaveDefault,
Auto,
On,
Off,
RecoverUsb,
RecoverUac,
RecoverUvc,
2026-04-20 08:38:26 -03:00
ResetUsb,
UpstreamSync,
2026-05-03 14:00:58 -03:00
OutputDelayProbe,
}
impl CommandKind {
/// Parse the intentionally small CLI vocabulary into safe relay actions.
fn parse(value: &str) -> Option<Self> {
match value {
"status" | "get" => Some(Self::Status),
2026-05-01 16:06:52 -03:00
"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),
2026-05-03 14:00:58 -03:00
"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,
2026-05-03 14:00:58 -03:00
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,
2026-05-03 14:00:58 -03:00
}
#[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] <status|version|upstream-sync|output-delay-probe [duration_s warmup_s period_ms width_ms codes audio_delay_us video_delay_us]|calibration|calibrate <audio_delta_us> <video_delta_us> [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<I, S>(args: I) -> Result<ParseOutcome>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
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);
2026-05-03 14:00:58 -03:00
let parsed = parse_command_args(command, command_args)?;
Ok(ParseOutcome::Run(Config {
server,
command,
2026-05-03 14:00:58 -03:00
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.
2026-05-03 14:00:58 -03:00
fn parse_command_args(command: CommandKind, args: Vec<String>) -> Result<ParsedCommandArgs> {
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());
}
2026-05-03 14:00:58 -03:00
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::<i64>()
.with_context(|| format!("parsing audio_delta_us `{}`", args[0]))?;
let video_delta_us = args[1]
.parse::<i64>()
.with_context(|| format!("parsing video_delta_us `{}`", args[1]))?;
let note = args[2..].join(" ");
2026-05-03 14:00:58 -03:00
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.
2026-05-03 14:00:58 -03:00
fn parse_output_delay_probe_args(args: Vec<String>) -> Result<ParsedCommandArgs> {
if args.len() > 7 {
2026-05-03 14:00:58 -03:00
bail!(
"output-delay-probe accepts at most seven arguments\n{}",
2026-05-03 14:00:58 -03:00
usage()
);
}
let parse_u32 = |index: usize, name: &str| -> Result<u32> {
args.get(index)
.map(|value| {
value
.parse::<u32>()
.with_context(|| format!("parsing {name} `{value}`"))
})
.transpose()
.map(|value| value.unwrap_or(0))
};
let parse_i64 = |index: usize, name: &str| -> Result<i64> {
args.get(index)
.map(|value| {
value
.parse::<i64>()
.with_context(|| format!("parsing {name} `{value}`"))
})
.transpose()
.map(|value| value.unwrap_or(0))
};
2026-05-03 14:00:58 -03:00
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")?,
2026-05-03 14:00:58 -03:00
..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<I, S>(args: I) -> Result<Config>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
match parse_args_outcome_from(args)? {
ParseOutcome::Run(config) => Ok(config),
ParseOutcome::Help => bail!("{}", usage()),
}
}
fn parse_args() -> Result<ParseOutcome> {
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<SetCapturePowerRequest> {
let (enabled, command) = match command {
CommandKind::Status
2026-05-01 16:06:52 -03:00
| CommandKind::Version
| CommandKind::CalibrationStatus
| CommandKind::CalibrationAdjust
| CommandKind::CalibrationRestoreDefault
| CommandKind::CalibrationRestoreFactory
| CommandKind::CalibrationSaveDefault
| CommandKind::RecoverUsb
| CommandKind::RecoverUac
| CommandKind::RecoverUvc
| CommandKind::ResetUsb
2026-05-03 14:00:58 -03:00
| 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<RelayClient<Channel>> {
2026-04-30 08:16:57 -03:00
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))
}
2026-05-01 16:06:52 -03:00
#[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.
2026-05-01 16:06:52 -03:00
async fn get_server_capabilities(server_addr: &str) -> Result<HandshakeSet> {
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<RelayClient<Channel>> {
2026-04-30 08:16:57 -03:00
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);
2026-04-20 01:41:57 -03:00
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.
2026-05-01 16:06:52 -03:00
fn print_versions(server_addr: &str, caps: &HandshakeSet) {
let server_version = if caps.server_version.is_empty() {
"unknown"
} else {
caps.server_version.as_str()
};
2026-05-01 20:25:56 -03:00
let server_revision = if caps.server_revision.is_empty() {
"unknown"
} else {
caps.server_revision.as_str()
};
2026-05-01 16:06:52 -03:00
println!("client_version={}", lesavka_client::VERSION);
2026-05-01 20:25:56 -03:00
println!("client_revision={}", lesavka_client::REVISION);
2026-05-01 16:06:52 -03:00
println!("server_addr={server_addr}");
println!("server_version={server_version}");
2026-05-01 20:25:56 -03:00
println!("server_revision={server_revision}");
2026-05-01 16:06:52 -03:00
println!("server_camera_output={}", caps.camera_output);
println!("server_camera_codec={}", caps.camera_codec);
2026-05-03 05:37:16 -03:00
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);
2026-05-01 16:06:52 -03:00
}
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<CalibrationRequest> {
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
2026-05-03 14:00:58 -03:00
| 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<ParseOutcome>;
}
#[cfg(test)]
#[path = "tests/lesavka_relayctl.rs"]
mod tests;