lesavka: add USB recovery controls

This commit is contained in:
Brad Stein 2026-04-20 08:38:26 -03:00
parent 50b5f54d27
commit c6f9001323
12 changed files with 231 additions and 17 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.11.22"
version = "0.11.23"
edition = "2024"
[dependencies]

View File

@ -10,6 +10,7 @@ enum CommandKind {
Auto,
On,
Off,
ResetUsb,
}
impl CommandKind {
@ -19,6 +20,7 @@ impl CommandKind {
"auto" => Some(Self::Auto),
"on" | "force-on" => Some(Self::On),
"off" | "force-off" => Some(Self::Off),
"reset-usb" | "recover-usb" => Some(Self::ResetUsb),
_ => None,
}
}
@ -30,7 +32,7 @@ struct Config {
}
fn usage() -> &'static str {
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|auto|on|off>"
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|auto|on|off|reset-usb>"
}
fn parse_args() -> Result<Config> {
@ -122,6 +124,15 @@ async fn main() -> Result<()> {
.await
.context("forcing capture power off")?
.into_inner(),
CommandKind::ResetUsb => {
let reply = client
.reset_usb(Request::new(Empty {}))
.await
.context("forcing USB gadget recovery")?
.into_inner();
println!("ok={}", reply.ok);
return Ok(());
}
};
print_state(reply);

View File

@ -590,6 +590,8 @@ fn parse_caps_fraction_numerator(caps: &str, needle: &str) -> Option<u32> {
fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<String> {
let mut items = Vec::new();
let hardware_decode_active = log.latest().is_some_and(sample_uses_hardware_decode);
let software_decode_active = log.latest().is_some_and(sample_uses_software_decode);
if !state.server_available {
items.push(
"The server is not reachable from this launcher yet, so stream-quality results would not be meaningful."
@ -618,10 +620,13 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
if sample.left_present_fps + 1.0 < sample.left_receive_fps
|| sample.right_present_fps + 1.0 < sample.right_receive_fps
{
items.push(
items.push(if hardware_decode_active {
"The client is receiving more frames than it is presenting. That points at local decode/render pressure, so prefer lighter breakout sizes or a cheaper source mode before adding bitrate."
.to_string()
} else {
"The client is receiving more frames than it is presenting. That points at local decode/render pressure, so prefer lighter breakout sizes or hardware decode."
.to_string(),
);
.to_string()
});
}
if (sample.left_present_gap_peak_ms - sample.left_packet_gap_peak_ms) > 40.0
|| (sample.right_present_gap_peak_ms - sample.right_packet_gap_peak_ms) > 40.0
@ -666,10 +671,13 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
);
}
if sample.client_process_cpu_pct >= 85.0 {
items.push(
items.push(if hardware_decode_active {
"Client process CPU is high even though hardware decode is active. If motion still looks rough, favor lighter breakout layouts or a cheaper source mode before adding more bitrate."
.to_string()
} else {
"Client process CPU is high. If motion still looks rough, favor lighter breakout layouts or a hardware decoder before adding more bitrate."
.to_string(),
);
.to_string()
});
}
if sample.server_process_cpu_pct >= 85.0 {
items.push(
@ -710,6 +718,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
);
}
if let Some(sample) = log.latest()
&& software_decode_active
&& ((sample.left_decoder_label.contains("avdec")
&& sample.left_present_fps + 1.0 < sample.left_receive_fps)
|| (sample.right_decoder_label.contains("avdec")
@ -742,6 +751,26 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<Strin
items
}
fn sample_uses_hardware_decode(sample: &PerformanceSample) -> bool {
decoder_label_is_hardware(&sample.left_decoder_label)
|| decoder_label_is_hardware(&sample.right_decoder_label)
}
fn sample_uses_software_decode(sample: &PerformanceSample) -> bool {
sample.left_decoder_label.contains("avdec") || sample.right_decoder_label.contains("avdec")
}
fn decoder_label_is_hardware(label: &str) -> bool {
let lower = label.to_ascii_lowercase();
lower.contains("nvh264dec")
|| lower.contains("nvdec")
|| lower.contains("vah264dec")
|| lower.contains("vaapih264dec")
|| lower.contains("v4l2slh264dec")
|| lower.contains("d3d11")
|| lower.contains("vtdec")
}
#[cfg(test)]
mod tests {
use super::*;
@ -959,4 +988,30 @@ mod tests {
"1080p | 1920x1080 | 60 fps | bitrate est ~18000 kbit"
);
}
#[test]
fn recommendations_do_not_suggest_hardware_decode_when_nvdec_is_active() {
let mut log = DiagnosticsLog::new(1);
let mut sample = sample(1);
sample.client_process_cpu_pct = 96.0;
sample.left_receive_fps = 40.0;
sample.left_present_fps = 30.0;
sample.left_decoder_label = "nvh264dec".to_string();
sample.right_decoder_label = "nvh264dec".to_string();
log.record(sample);
let items = recommendations_for(&LauncherState::new(), &log);
let joined = items.join("\n");
assert!(!joined.contains("hardware decoder before adding more bitrate"));
assert!(!joined.contains("lighter breakout sizes or hardware decode"));
assert!(joined.contains("cheaper source mode"));
}
#[test]
fn hardware_decoder_detection_recognizes_nvdec_labels() {
let mut sample = sample(1);
sample.left_decoder_label = "nvh264dec".to_string();
assert!(sample_uses_hardware_decode(&sample));
assert!(!sample_uses_software_decode(&sample));
}
}

View File

@ -36,9 +36,25 @@ pub fn set_capture_power_mode(
})
}
fn with_runtime<F>(future: F) -> Result<CapturePowerStatus>
pub fn reset_usb_gadget(server_addr: &str) -> Result<()> {
with_runtime(async move {
let mut client = connect(server_addr).await?;
let reply = client
.reset_usb(Request::new(Empty {}))
.await
.context("requesting USB gadget reset")?
.into_inner();
if reply.ok {
Ok(())
} else {
anyhow::bail!("relay reported USB reset failure");
}
})
}
fn with_runtime<F, T>(future: F) -> Result<T>
where
F: std::future::Future<Output = Result<CapturePowerStatus>>,
F: std::future::Future<Output = Result<T>>,
{
tokio::runtime::Builder::new_current_thread()
.enable_all()

View File

@ -8,7 +8,7 @@ use {
super::diagnostics::{PerformanceSample, quality_probe_command},
super::launcher_clipboard_control_path,
super::launcher_focus_signal_path,
super::power::{fetch_capture_power, set_capture_power_mode},
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode},
super::state::{
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
FeedSourcePreset, InputRouting, LauncherState,
@ -1296,6 +1296,49 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
});
}
{
let widgets = widgets.clone();
let server_entry = server_entry.clone();
let server_addr_fallback = Rc::clone(&server_addr);
let widgets_for_click = widgets.clone();
widgets.usb_recover_button.connect_clicked(move |_| {
let server_addr =
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_for_click.status_label.set_text(
"Requesting a forced USB gadget re-enumeration on the relay host...",
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = reset_usb_gadget(&server_addr).map_err(|err| err.to_string());
let _ = tx.send(result);
});
let widgets = widgets_for_click.clone();
glib::timeout_add_local(Duration::from_millis(100), move || {
match rx.try_recv() {
Ok(Ok(())) => {
widgets.status_label.set_text(
"USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.",
);
glib::ControlFlow::Break
}
Ok(Err(err)) => {
widgets
.status_label
.set_text(&format!("USB gadget recovery failed: {err}"));
glib::ControlFlow::Break
}
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
widgets.status_label.set_text(
"USB gadget recovery ended unexpectedly before the relay answered.",
);
glib::ControlFlow::Break
}
}
});
});
}
{
let widgets = widgets.clone();
widgets.diagnostics_copy_button.connect_clicked(move |_| {

View File

@ -53,6 +53,7 @@ pub struct LauncherWidgets {
pub status_label: gtk::Label,
pub diagnostics_log: Rc<RefCell<DiagnosticsLog>>,
pub diagnostics_buffer: gtk::TextBuffer,
pub diagnostics_scroll: gtk::ScrolledWindow,
pub session_log_buffer: gtk::TextBuffer,
pub session_log_view: gtk::TextView,
pub summary: SummaryWidgets,
@ -67,6 +68,7 @@ pub struct LauncherWidgets {
pub input_toggle_button: gtk::Button,
pub clipboard_button: gtk::Button,
pub probe_button: gtk::Button,
pub usb_recover_button: gtk::Button,
pub swap_key_button: gtk::Button,
pub camera_test_button: gtk::Button,
pub microphone_test_button: gtk::Button,
@ -386,8 +388,15 @@ pub fn build_launcher_view(
probe_button.set_tooltip_text(Some(
"Copy the hygiene/quality probe command into the local clipboard.",
));
let usb_recover_button = gtk::Button::with_label("Recover USB");
usb_recover_button.set_hexpand(true);
stabilize_button(&usb_recover_button, 108);
usb_recover_button.set_tooltip_text(Some(
"Force the remote USB gadget to re-enumerate when keyboard, mouse, webcam, or audio stop showing up on the host.",
));
live_actions_row.append(&clipboard_button);
live_actions_row.append(&probe_button);
live_actions_row.append(&usb_recover_button);
connection_body.append(&live_actions_row);
connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
@ -616,6 +625,7 @@ pub fn build_launcher_view(
status_label: status_label.clone(),
diagnostics_log: diagnostics_log.clone(),
diagnostics_buffer: diagnostics_buffer.clone(),
diagnostics_scroll: diagnostics_scroll.clone(),
session_log_buffer: session_log_buffer.clone(),
session_log_view: session_log_view.clone(),
summary: SummaryWidgets {
@ -638,6 +648,7 @@ pub fn build_launcher_view(
input_toggle_button: input_toggle_button.clone(),
clipboard_button: clipboard_button.clone(),
probe_button: probe_button.clone(),
usb_recover_button: usb_recover_button.clone(),
swap_key_button: swap_key_button.clone(),
camera_test_button: camera_test_button.clone(),
microphone_test_button: microphone_test_button.clone(),

View File

@ -86,6 +86,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
}));
widgets.clipboard_button.set_sensitive(relay_live);
widgets.probe_button.set_sensitive(true);
widgets
.usb_recover_button
.set_sensitive(state.server_available);
widgets.input_toggle_button.set_label("Change Routing");
widgets
.input_toggle_button
@ -944,6 +947,16 @@ pub fn refresh_diagnostics_report(
state: &LauncherState,
child_running: bool,
) {
let diagnostics_adjustment = widgets.diagnostics_scroll.vadjustment();
let previous_max =
(diagnostics_adjustment.upper() - diagnostics_adjustment.page_size()).max(0.0);
let was_at_bottom =
previous_max <= 0.0 || diagnostics_adjustment.value() >= (previous_max - 4.0);
let previous_ratio = if previous_max > 0.0 {
(diagnostics_adjustment.value() / previous_max).clamp(0.0, 1.0)
} else {
0.0
};
let mut snapshot = SnapshotReport::from_state(
state,
&widgets.diagnostics_log.borrow(),
@ -958,6 +971,15 @@ pub fn refresh_diagnostics_report(
widgets
.diagnostics_buffer
.set_text(&snapshot.to_pretty_text());
glib::idle_add_local_once(move || {
let max = (diagnostics_adjustment.upper() - diagnostics_adjustment.page_size()).max(0.0);
let target = if was_at_bottom {
max
} else {
(previous_ratio * max).clamp(0.0, max)
};
diagnostics_adjustment.set_value(target);
});
}
pub fn open_session_log_popout(

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.11.22"
version = "0.11.23"
edition = "2024"
build = "build.rs"

View File

@ -17,6 +17,6 @@ mod tests {
#[test]
fn banner_includes_version() {
assert_eq!(banner("0.11.22"), "lesavka-common CLI (v0.11.22)");
assert_eq!(banner("0.11.23"), "lesavka-common CLI (v0.11.23)");
}
}

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.11.22"
version = "0.11.23"
edition = "2024"
autobins = false

View File

@ -122,10 +122,19 @@ impl UsbGadget {
/// Hard-reset the gadget → identical to a physical cable re-plug
#[cfg(coverage)]
pub fn cycle(&self) -> Result<()> {
self.cycle_internal(env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok())
}
#[cfg(coverage)]
pub fn cycle_forced(&self) -> Result<()> {
self.cycle_internal(true)
}
#[cfg(coverage)]
fn cycle_internal(&self, force_cycle: bool) -> Result<()> {
let ctrl = Self::find_controller().or_else(|_| {
Self::probe_platform_udc()?.ok_or_else(|| anyhow::anyhow!("no UDC present"))
})?;
let force_cycle = env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok();
if !force_cycle {
match Self::state(&ctrl) {
@ -153,11 +162,20 @@ impl UsbGadget {
#[cfg(not(coverage))]
pub fn cycle(&self) -> Result<()> {
self.cycle_internal(env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok())
}
#[cfg(not(coverage))]
pub fn cycle_forced(&self) -> Result<()> {
self.cycle_internal(true)
}
#[cfg(not(coverage))]
fn cycle_internal(&self, force_cycle: bool) -> Result<()> {
/* 0-ensure we *know* the controller even after a previous crash */
let ctrl = Self::find_controller().or_else(|_| {
Self::probe_platform_udc()?.ok_or_else(|| anyhow::anyhow!("no UDC present"))
})?;
let force_cycle = env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok();
match Self::state(&ctrl) {
Ok(state)
if !force_cycle

View File

@ -2,10 +2,12 @@
// server/src/main.rs
#[allow(clippy::useless_attribute)]
#[forbid(unsafe_code)]
use anyhow::Context;
use futures_util::{Stream, StreamExt};
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc, time::Duration};
use tokio::sync::{Mutex, broadcast};
@ -321,13 +323,18 @@ impl Handler {
#[cfg(not(coverage))]
info!("🔴 explicit ResetUsb() called");
match self.gadget.cycle() {
match self.gadget.cycle_forced() {
Ok(_) => {
if let Err(e) = self.reopen_hid().await {
#[cfg(not(coverage))]
error!("💥 reopen HID failed: {e:#}");
return Err(Status::internal(e.to_string()));
}
if let Err(e) = restart_uvc_helper() {
#[cfg(not(coverage))]
error!("💥 restart UVC helper failed: {e:#}");
return Err(Status::internal(e.to_string()));
}
Ok(Response::new(ResetUsbReply { ok: true }))
}
Err(e) => {
@ -369,6 +376,37 @@ impl Handler {
}
}
fn restart_uvc_helper() -> anyhow::Result<()> {
for args in [
["reset-failed", "lesavka-uvc.service"].as_slice(),
["restart", "lesavka-uvc.service"].as_slice(),
] {
let output = Command::new("systemctl")
.args(args)
.output()
.with_context(|| format!("running systemctl {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
anyhow::bail!(
"systemctl {} failed: {}{}",
args.join(" "),
if stderr.is_empty() {
stdout.as_str()
} else {
stderr.as_str()
},
if stderr.is_empty() || stdout.is_empty() || stderr == stdout {
""
} else {
" / also see stdout"
}
);
}
}
Ok(())
}
impl EyeHub {
fn spawn<S>(mut stream: S, lease: lesavka_server::capture_power::CapturePowerLease) -> Arc<Self>
where