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

409 lines
13 KiB
Rust
Raw Normal View History

use anyhow::{Context, Result, bail};
use lesavka_common::lesavka::{
2026-05-01 16:06:52 -03:00
CapturePowerCommand, HandshakeSet, 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,
Auto,
On,
Off,
RecoverUsb,
RecoverUac,
RecoverUvc,
2026-04-20 08:38:26 -03:00
ResetUsb,
}
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),
"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),
_ => None,
}
}
}
#[derive(Debug, Eq, PartialEq)]
struct Config {
server: String,
command: CommandKind,
}
#[derive(Debug, Eq, PartialEq)]
enum ParseOutcome {
Run(Config),
Help,
}
fn usage() -> &'static str {
2026-05-01 16:06:52 -03:00
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|version|auto|on|off|recover-usb|recover-uac|recover-uvc|reset-usb>"
}
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;
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());
}
}
_ => bail!("unexpected argument `{arg}`\n{}", usage()),
}
}
Ok(ParseOutcome::Run(Config {
server,
command: command.unwrap_or(CommandKind::Status),
}))
}
#[cfg(test)]
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 {
CommandKind::Status
2026-05-01 16:06:52 -03:00
| CommandKind::Version
| CommandKind::RecoverUsb
| CommandKind::RecoverUac
| CommandKind::RecoverUvc
| CommandKind::ResetUsb => 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))]
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);
}
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()
};
println!("client_version={}", lesavka_client::VERSION);
println!("client_full_version={}", lesavka_client::FULL_VERSION);
println!("server_addr={server_addr}");
println!("server_version={server_version}");
println!("server_camera_output={}", caps.camera_output);
println!("server_camera_codec={}", caps.camera_codec);
}
#[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(());
}
};
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(());
}
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
2026-05-01 16:06:52 -03:00
| CommandKind::Version
| CommandKind::RecoverUsb
| CommandKind::RecoverUac
| CommandKind::RecoverUvc
| CommandKind::ResetUsb => unreachable!(),
};
let reply = client
.set_capture_power(Request::new(request))
.await
.context(context)?
.into_inner();
print_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(());
}
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 16:06:52 -03:00
CommandKind::Version | CommandKind::Auto | CommandKind::On | CommandKind::Off => {
unreachable!()
}
};
print_state(reply);
Ok(())
}
#[cfg(coverage)]
fn main() {
let _ = parse_args as fn() -> Result<ParseOutcome>;
}
#[cfg(test)]
mod tests {
use super::{
CapturePowerCommand, CommandKind, ParseOutcome, 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));
2026-05-01 16:06:52 -03:00
assert_eq!(CommandKind::parse("version"), Some(CommandKind::Version));
assert_eq!(CommandKind::parse("versions"), Some(CommandKind::Version));
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);
}
#[test]
fn parse_args_accepts_server_and_command() {
let config =
parse_args_from(["--server", " http://lab:50051 ", "reset-usb"]).expect("config");
assert_eq!(config.server, "http://lab:50051");
assert_eq!(config.command, CommandKind::ResetUsb);
}
#[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());
}
#[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());
2026-05-01 16:06:52 -03:00
assert!(capture_power_request(CommandKind::Version).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());
}
#[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(),
});
}
}