2026-04-19 03:28:23 -03:00
|
|
|
use anyhow::{Context, Result, bail};
|
|
|
|
|
use lesavka_common::lesavka::{
|
2026-05-02 13:44:32 -03:00
|
|
|
CalibrationAction, CalibrationRequest, CalibrationState, CapturePowerCommand, HandshakeSet,
|
|
|
|
|
SetCapturePowerRequest, relay_client::RelayClient,
|
2026-04-19 03:28:23 -03:00
|
|
|
};
|
2026-04-23 03:49:49 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-05-01 16:06:52 -03:00
|
|
|
use lesavka_common::lesavka::{Empty, handshake_client::HandshakeClient};
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-04-23 03:49:49 -03:00
|
|
|
use tonic::Request;
|
|
|
|
|
use tonic::transport::Channel;
|
2026-04-19 03:28:23 -03:00
|
|
|
|
2026-04-30 08:16:57 -03:00
|
|
|
use lesavka_client::relay_transport;
|
|
|
|
|
|
2026-04-19 03:28:23 -03:00
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
enum CommandKind {
|
|
|
|
|
Status,
|
2026-05-01 16:06:52 -03:00
|
|
|
Version,
|
2026-05-02 13:44:32 -03:00
|
|
|
CalibrationStatus,
|
|
|
|
|
CalibrationAdjust,
|
|
|
|
|
CalibrationRestoreDefault,
|
|
|
|
|
CalibrationRestoreFactory,
|
|
|
|
|
CalibrationSaveDefault,
|
2026-04-19 03:28:23 -03:00
|
|
|
Auto,
|
|
|
|
|
On,
|
|
|
|
|
Off,
|
2026-04-30 18:38:34 -03:00
|
|
|
RecoverUsb,
|
|
|
|
|
RecoverUac,
|
|
|
|
|
RecoverUvc,
|
2026-04-20 08:38:26 -03:00
|
|
|
ResetUsb,
|
2026-05-01 19:16:40 -03:00
|
|
|
UpstreamSync,
|
2026-04-19 03:28:23 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CommandKind {
|
2026-04-30 18:38:34 -03:00
|
|
|
/// Parse the intentionally small CLI vocabulary into safe relay actions.
|
2026-04-19 03:28:23 -03:00
|
|
|
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),
|
2026-05-02 13:44:32 -03:00
|
|
|
"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),
|
2026-04-19 03:28:23 -03:00
|
|
|
"auto" => Some(Self::Auto),
|
|
|
|
|
"on" | "force-on" => Some(Self::On),
|
|
|
|
|
"off" | "force-off" => Some(Self::Off),
|
2026-04-30 18:38:34 -03:00
|
|
|
"recover-usb" => Some(Self::RecoverUsb),
|
|
|
|
|
"recover-uac" => Some(Self::RecoverUac),
|
|
|
|
|
"recover-uvc" => Some(Self::RecoverUvc),
|
|
|
|
|
"reset-usb" | "hard-reset-usb" => Some(Self::ResetUsb),
|
2026-05-01 19:16:40 -03:00
|
|
|
"upstream-sync" | "sync" => Some(Self::UpstreamSync),
|
2026-04-19 03:28:23 -03:00
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 03:49:49 -03:00
|
|
|
#[derive(Debug, Eq, PartialEq)]
|
2026-04-19 03:28:23 -03:00
|
|
|
struct Config {
|
|
|
|
|
server: String,
|
|
|
|
|
command: CommandKind,
|
2026-05-02 13:44:32 -03:00
|
|
|
audio_delta_us: i64,
|
|
|
|
|
video_delta_us: i64,
|
|
|
|
|
note: String,
|
2026-04-19 03:28:23 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 03:49:49 -03:00
|
|
|
#[derive(Debug, Eq, PartialEq)]
|
|
|
|
|
enum ParseOutcome {
|
|
|
|
|
Run(Config),
|
|
|
|
|
Help,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:28:23 -03:00
|
|
|
fn usage() -> &'static str {
|
2026-05-02 13:44:32 -03:00
|
|
|
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|version|upstream-sync|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>"
|
2026-04-19 03:28:23 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 03:49:49 -03:00
|
|
|
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);
|
2026-04-19 03:28:23 -03:00
|
|
|
let mut server = "http://127.0.0.1:50051".to_string();
|
|
|
|
|
let mut command = None;
|
2026-05-02 13:44:32 -03:00
|
|
|
let mut command_args = Vec::new();
|
2026-04-19 03:28:23 -03:00
|
|
|
|
|
|
|
|
while let Some(arg) = args.next() {
|
|
|
|
|
match arg.as_str() {
|
|
|
|
|
"--server" => {
|
|
|
|
|
server = args
|
|
|
|
|
.next()
|
|
|
|
|
.context("missing value after --server")?
|
|
|
|
|
.trim()
|
|
|
|
|
.to_string();
|
|
|
|
|
}
|
|
|
|
|
"--help" | "-h" => {
|
2026-04-23 03:49:49 -03:00
|
|
|
return Ok(ParseOutcome::Help);
|
2026-04-19 03:28:23 -03:00
|
|
|
}
|
|
|
|
|
_ if command.is_none() => {
|
|
|
|
|
command = CommandKind::parse(arg.as_str());
|
|
|
|
|
if command.is_none() {
|
|
|
|
|
bail!("unknown command `{arg}`\n{}", usage());
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-02 13:44:32 -03:00
|
|
|
_ => command_args.push(arg),
|
2026-04-19 03:28:23 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:44:32 -03:00
|
|
|
let command = command.unwrap_or(CommandKind::Status);
|
|
|
|
|
let (audio_delta_us, video_delta_us, note) = parse_command_args(command, command_args)?;
|
2026-04-23 03:49:49 -03:00
|
|
|
Ok(ParseOutcome::Run(Config {
|
2026-04-19 03:28:23 -03:00
|
|
|
server,
|
2026-05-02 13:44:32 -03:00
|
|
|
command,
|
|
|
|
|
audio_delta_us,
|
|
|
|
|
video_delta_us,
|
|
|
|
|
note,
|
2026-04-23 03:49:49 -03:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:44:32 -03:00
|
|
|
fn parse_command_args(command: CommandKind, args: Vec<String>) -> 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::<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(" ");
|
|
|
|
|
Ok((audio_delta_us, video_delta_us, note))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 07:00:06 -03:00
|
|
|
#[cfg(test)]
|
2026-04-23 03:49:49 -03:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest> {
|
|
|
|
|
let (enabled, command) = match command {
|
2026-04-30 18:38:34 -03:00
|
|
|
CommandKind::Status
|
2026-05-01 16:06:52 -03:00
|
|
|
| CommandKind::Version
|
2026-05-02 13:44:32 -03:00
|
|
|
| CommandKind::CalibrationStatus
|
|
|
|
|
| CommandKind::CalibrationAdjust
|
|
|
|
|
| CommandKind::CalibrationRestoreDefault
|
|
|
|
|
| CommandKind::CalibrationRestoreFactory
|
|
|
|
|
| CommandKind::CalibrationSaveDefault
|
2026-04-30 18:38:34 -03:00
|
|
|
| CommandKind::RecoverUsb
|
|
|
|
|
| CommandKind::RecoverUac
|
|
|
|
|
| CommandKind::RecoverUvc
|
2026-05-01 19:16:40 -03:00
|
|
|
| CommandKind::ResetUsb
|
|
|
|
|
| CommandKind::UpstreamSync => return None,
|
2026-04-23 03:49:49 -03:00
|
|
|
CommandKind::Auto => (false, CapturePowerCommand::Auto),
|
|
|
|
|
CommandKind::On => (true, CapturePowerCommand::ForceOn),
|
|
|
|
|
CommandKind::Off => (false, CapturePowerCommand::ForceOff),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Some(SetCapturePowerRequest {
|
|
|
|
|
enabled,
|
|
|
|
|
command: command as i32,
|
2026-04-19 03:28:23 -03:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 03:49:49 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-04-19 03:28:23 -03:00
|
|
|
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
2026-04-30 08:16:57 -03:00
|
|
|
let channel = relay_transport::endpoint(server_addr)?
|
2026-04-19 03:28:23 -03:00
|
|
|
.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))]
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 03:49:49 -03:00
|
|
|
#[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)?
|
2026-04-23 03:49:49 -03:00
|
|
|
.tcp_nodelay(true)
|
|
|
|
|
.connect_lazy();
|
|
|
|
|
Ok(RelayClient::new(channel))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:28:23 -03:00
|
|
|
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);
|
2026-04-19 03:28:23 -03:00
|
|
|
println!("active_leases={}", state.active_leases);
|
|
|
|
|
println!("unit={}", state.unit);
|
|
|
|
|
println!("detail={}", state.detail);
|
|
|
|
|
}
|
|
|
|
|
|
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-01 19:16:40 -03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:44:32 -03:00
|
|
|
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<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
|
|
|
|
|
| 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(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 03:49:49 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-04-19 03:28:23 -03:00
|
|
|
#[tokio::main(flavor = "current_thread")]
|
|
|
|
|
async fn main() -> Result<()> {
|
2026-04-23 03:49:49 -03:00
|
|
|
let config = match parse_args()? {
|
|
|
|
|
ParseOutcome::Run(config) => config,
|
|
|
|
|
ParseOutcome::Help => {
|
|
|
|
|
println!("{}", usage());
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-05-01 16:06:52 -03:00
|
|
|
|
|
|
|
|
if config.command == CommandKind::Version {
|
|
|
|
|
let caps = get_server_capabilities(config.server.as_str()).await?;
|
|
|
|
|
print_versions(config.server.as_str(), &caps);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:28:23 -03:00
|
|
|
let mut client = connect(config.server.as_str()).await?;
|
|
|
|
|
|
2026-04-23 03:49:49 -03:00
|
|
|
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",
|
2026-04-30 18:38:34 -03:00
|
|
|
CommandKind::Status
|
2026-05-01 16:06:52 -03:00
|
|
|
| CommandKind::Version
|
2026-05-02 13:44:32 -03:00
|
|
|
| CommandKind::CalibrationStatus
|
|
|
|
|
| CommandKind::CalibrationAdjust
|
|
|
|
|
| CommandKind::CalibrationRestoreDefault
|
|
|
|
|
| CommandKind::CalibrationRestoreFactory
|
|
|
|
|
| CommandKind::CalibrationSaveDefault
|
2026-04-30 18:38:34 -03:00
|
|
|
| CommandKind::RecoverUsb
|
|
|
|
|
| CommandKind::RecoverUac
|
|
|
|
|
| CommandKind::RecoverUvc
|
2026-05-01 19:16:40 -03:00
|
|
|
| CommandKind::ResetUsb
|
|
|
|
|
| CommandKind::UpstreamSync => unreachable!(),
|
2026-04-23 03:49:49 -03:00
|
|
|
};
|
|
|
|
|
let reply = client
|
|
|
|
|
.set_capture_power(Request::new(request))
|
|
|
|
|
.await
|
|
|
|
|
.context(context)?
|
|
|
|
|
.into_inner();
|
|
|
|
|
print_state(reply);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:44:32 -03:00
|
|
|
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(());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:28:23 -03:00
|
|
|
let reply = match config.command {
|
|
|
|
|
CommandKind::Status => client
|
|
|
|
|
.get_capture_power(Request::new(Empty {}))
|
|
|
|
|
.await
|
|
|
|
|
.context("querying capture power state")?
|
|
|
|
|
.into_inner(),
|
2026-04-30 18:38:34 -03:00
|
|
|
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(());
|
|
|
|
|
}
|
2026-04-20 08:38:26 -03:00
|
|
|
CommandKind::ResetUsb => {
|
|
|
|
|
let reply = client
|
|
|
|
|
.reset_usb(Request::new(Empty {}))
|
|
|
|
|
.await
|
|
|
|
|
.context("forcing USB gadget recovery")?
|
|
|
|
|
.into_inner();
|
|
|
|
|
println!("ok={}", reply.ok);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
2026-05-01 19:16:40 -03:00
|
|
|
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(());
|
|
|
|
|
}
|
2026-05-02 13:44:32 -03:00
|
|
|
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(());
|
|
|
|
|
}
|
2026-05-01 16:06:52 -03:00
|
|
|
CommandKind::Version | CommandKind::Auto | CommandKind::On | CommandKind::Off => {
|
|
|
|
|
unreachable!()
|
|
|
|
|
}
|
2026-05-02 13:44:32 -03:00
|
|
|
CommandKind::CalibrationAdjust
|
|
|
|
|
| CommandKind::CalibrationRestoreDefault
|
|
|
|
|
| CommandKind::CalibrationRestoreFactory
|
|
|
|
|
| CommandKind::CalibrationSaveDefault => unreachable!(),
|
2026-04-19 03:28:23 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
print_state(reply);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-04-23 03:49:49 -03:00
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn main() {
|
|
|
|
|
let _ = parse_args as fn() -> Result<ParseOutcome>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::{
|
2026-05-02 13:44:32 -03:00
|
|
|
CalibrationAction, CapturePowerCommand, CommandKind, Config, ParseOutcome,
|
|
|
|
|
calibration_request_for, capture_power_request, parse_args_from, parse_args_outcome_from,
|
2026-04-23 03:49:49 -03:00
|
|
|
};
|
|
|
|
|
use lesavka_common::lesavka::CapturePowerState;
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-04-30 18:38:34 -03:00
|
|
|
/// Verifies safe recovery commands stay separate from explicit hard reset.
|
2026-04-23 03:49:49 -03:00
|
|
|
fn command_aliases_parse_to_stable_actions() {
|
|
|
|
|
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
|
|
|
|
|
assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status));
|
2026-05-01 16:06:52 -03:00
|
|
|
assert_eq!(CommandKind::parse("version"), Some(CommandKind::Version));
|
|
|
|
|
assert_eq!(CommandKind::parse("versions"), Some(CommandKind::Version));
|
2026-05-02 13:44:32 -03:00
|
|
|
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)
|
|
|
|
|
);
|
2026-05-01 19:16:40 -03:00
|
|
|
assert_eq!(
|
|
|
|
|
CommandKind::parse("upstream-sync"),
|
|
|
|
|
Some(CommandKind::UpstreamSync)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(CommandKind::parse("sync"), Some(CommandKind::UpstreamSync));
|
2026-04-23 03:49:49 -03:00
|
|
|
assert_eq!(CommandKind::parse("force-on"), Some(CommandKind::On));
|
|
|
|
|
assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
CommandKind::parse("recover-usb"),
|
2026-04-30 18:38:34 -03:00
|
|
|
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"),
|
2026-04-23 03:49:49 -03:00
|
|
|
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);
|
2026-05-02 13:44:32 -03:00
|
|
|
assert_eq!(config.audio_delta_us, 0);
|
|
|
|
|
assert_eq!(config.video_delta_us, 0);
|
|
|
|
|
assert!(config.note.is_empty());
|
2026-04-23 03:49:49 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_args_accepts_server_and_command() {
|
|
|
|
|
let config =
|
2026-05-01 19:16:40 -03:00
|
|
|
parse_args_from(["--server", " http://lab:50051 ", "upstream-sync"]).expect("config");
|
2026-04-23 03:49:49 -03:00
|
|
|
assert_eq!(config.server, "http://lab:50051");
|
2026-05-01 19:16:40 -03:00
|
|
|
assert_eq!(config.command, CommandKind::UpstreamSync);
|
2026-04-23 03:49:49 -03:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:44:32 -03:00
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 03:49:49 -03:00
|
|
|
#[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());
|
2026-05-02 13:44:32 -03:00
|
|
|
assert!(parse_args_from(["calibrate"]).is_err());
|
|
|
|
|
assert!(parse_args_from(["calibrate", "0", "not-int"]).is_err());
|
2026-04-23 03:49:49 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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]
|
2026-04-30 18:38:34 -03:00
|
|
|
/// Keeps status/read commands from accidentally mutating capture power.
|
2026-04-23 03:49:49 -03:00
|
|
|
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());
|
2026-05-01 16:06:52 -03:00
|
|
|
assert!(capture_power_request(CommandKind::Version).is_none());
|
2026-05-02 13:44:32 -03:00
|
|
|
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());
|
2026-04-30 18:38:34 -03:00
|
|
|
assert!(capture_power_request(CommandKind::RecoverUsb).is_none());
|
|
|
|
|
assert!(capture_power_request(CommandKind::RecoverUac).is_none());
|
|
|
|
|
assert!(capture_power_request(CommandKind::RecoverUvc).is_none());
|
2026-04-23 03:49:49 -03:00
|
|
|
assert!(capture_power_request(CommandKind::ResetUsb).is_none());
|
2026-05-01 19:16:40 -03:00
|
|
|
assert!(capture_power_request(CommandKind::UpstreamSync).is_none());
|
2026-04-23 03:49:49 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-02 13:44:32 -03:00
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
}
|
2026-04-23 03:49:49 -03:00
|
|
|
}
|