lesavka: add USB recovery controls
This commit is contained in:
parent
50b5f54d27
commit
c6f9001323
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.22"
|
||||
version = "0.11.23"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 |_| {
|
||||
|
||||
@ -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(>k::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(),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.22"
|
||||
version = "0.11.23"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.22"
|
||||
version = "0.11.23"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user