// server/src/gadget.rs use anyhow::{Context, Result}; use std::{ env, fs::{self, OpenOptions}, io::Write, path::Path, process::Command, thread, time::Duration, }; #[cfg(not(coverage))] use tracing::warn; use tracing::{info, trace}; #[derive(Clone)] pub struct UsbGadget { udc_file: &'static str, } impl UsbGadget { fn sysfs_root() -> String { env::var("LESAVKA_GADGET_SYSFS_ROOT").unwrap_or_else(|_| "/sys".to_string()) } fn configfs_root() -> String { env::var("LESAVKA_GADGET_CONFIGFS_ROOT") .unwrap_or_else(|_| "/sys/kernel/config/usb_gadget".to_string()) } pub fn new(name: &'static str) -> Self { Self { udc_file: Box::leak( format!("{}/{}{}", Self::configfs_root(), name, "/UDC").into_boxed_str(), ), } } pub fn state(ctrl: &str) -> anyhow::Result { let p = format!("{}/class/udc/{ctrl}/state", Self::sysfs_root()); Ok(std::fs::read_to_string(p)?.trim().to_owned()) } pub fn current_controller_state() -> anyhow::Result<(String, String)> { let ctrl = Self::find_controller()?; let state = Self::state(&ctrl)?; Ok((ctrl, state)) } pub fn host_attached_state(state: &str) -> bool { matches!( state, "configured" | "addressed" | "default" | "suspended" | "unknown" ) } pub fn host_enumerated_state(state: &str) -> bool { matches!(state, "configured" | "addressed" | "default" | "suspended") } pub fn current_state_detail() -> String { match Self::current_controller_state() { Ok((ctrl, state)) => format!("UDC {ctrl} state={state}"), Err(err) => format!("UDC state unavailable: {err:#}"), } } /*---- helpers ----*/ /// Find the first controller in /sys/class/udc (e.g. `1000480000.usb`) pub fn find_controller() -> Result { Ok(fs::read_dir(format!("{}/class/udc", Self::sysfs_root()))? .next() .transpose()? .context("no UDC present")? .file_name() .to_string_lossy() .into_owned()) } /// Busy-loop (≤ `limit_ms`) until `state` matches `wanted` fn wait_state(ctrl: &str, wanted: &str, limit_ms: u64) -> Result<()> { let path = format!("{}/class/udc/{ctrl}/state", Self::sysfs_root()); for _ in 0..=limit_ms / 50 { let s = fs::read_to_string(&path).unwrap_or_default(); trace!("⏳ state={s:?}, want={wanted}"); if s.trim() == wanted { return Ok(()); } thread::sleep(Duration::from_millis(50)); } Err(anyhow::anyhow!( "UDC never reached '{wanted}' (last = {:?})", fs::read_to_string(&path).unwrap_or_default() )) } pub fn wait_state_any(ctrl: &str, limit_ms: u64) -> anyhow::Result { let path = format!("{}/class/udc/{ctrl}/state", Self::sysfs_root()); for _ in 0..=limit_ms / 50 { if let Ok(s) = std::fs::read_to_string(&path) { let s = s.trim(); if matches!(s, "configured" | "not attached") { return Ok(s.to_owned()); } } std::thread::sleep(std::time::Duration::from_millis(50)); } Err(anyhow::anyhow!( "UDC state did not settle within {limit_ms} ms" )) } /// Write `value` (plus “\n”) into a sysfs attribute fn write_attr>(p: P, value: &str) -> Result<()> { OpenOptions::new() .write(true) .open(p)? .write_all(format!("{value}\n").as_bytes())?; Ok(()) } // Wait (≤ `limit_ms`) until `/sys/class/udc/` exists again. fn wait_udc_present(ctrl: &str, limit_ms: u64) -> Result<()> { for _ in 0..=limit_ms / 50 { if Path::new(&format!("{}/class/udc/{ctrl}", Self::sysfs_root())).exists() { return Ok(()); } thread::sleep(Duration::from_millis(50)); } Err(anyhow::anyhow!( "⚠️ UDC {ctrl} did not re-appear within {limit_ms} ms" )) } /// Scan platform devices when /sys/class/udc is empty fn probe_platform_udc() -> Result> { for entry in fs::read_dir(format!("{}/bus/platform/devices", Self::sysfs_root()))? { let p = entry?.file_name().into_string().unwrap(); if p.ends_with(".usb") { return Ok(Some(p)); } } Ok(None) } /*---- public API ----*/ /// 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) } pub fn recover_enumeration(&self) -> Result<()> { self.recover_enumeration_internal() } #[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")) })?; if !force_cycle { match Self::state(&ctrl) { Ok(state) if matches!( state.as_str(), "configured" | "addressed" | "default" | "suspended" | "unknown" ) => { return Ok(()); } Err(_) => return Ok(()), _ => {} } } let _ = Self::write_attr(self.udc_file, ""); let _ = Self::wait_state_any(&ctrl, 3_000); let _ = Self::rebind_driver(&ctrl); let _ = Self::wait_udc_present(&ctrl, 3_000); Self::write_attr(self.udc_file, &ctrl)?; let _ = Self::wait_state_any(&ctrl, 6_000); Ok(()) } #[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")) })?; match Self::state(&ctrl) { Ok(state) if !force_cycle && matches!( state.as_str(), "configured" | "addressed" | "default" | "suspended" ) => { warn!( "🔒 refusing gadget cycle while host attached (state={state}); set LESAVKA_GADGET_FORCE_CYCLE=1 to override" ); return Ok(()); } Ok(state) if !force_cycle && state == "unknown" => { warn!( "🔒 refusing gadget cycle with unknown UDC state; set LESAVKA_GADGET_FORCE_CYCLE=1 to override" ); return Ok(()); } Err(_) if !force_cycle => { warn!( "🔒 refusing gadget cycle without UDC state; set LESAVKA_GADGET_FORCE_CYCLE=1 to override" ); return Ok(()); } _ => {} } /* 1 - detach gadget */ info!("🔌 detaching gadget from {ctrl}"); // a) drop pull-ups (if the controller offers the switch) let sc = format!("{}/class/udc/{ctrl}/soft_connect", Self::sysfs_root()); let _ = Self::write_attr(&sc, "0"); // ignore errors - not all HW has it // b) clear the UDC attribute; the kernel may transiently answer EBUSY for attempt in 1..=10 { match Self::write_attr(self.udc_file, "") { Ok(_) => break, Err(err) if { // only swallow EBUSY err.downcast_ref::() .and_then(|io| io.raw_os_error()) == Some(libc::EBUSY) && attempt < 10 } => { trace!("⏳ UDC busy (attempt {attempt}/10) - retrying…"); thread::sleep(Duration::from_millis(100)); } Err(err) => return Err(err), } } Self::wait_state(&ctrl, "not attached", 3_000)?; /* 2 - reset driver */ Self::rebind_driver(&ctrl)?; /* 3 - wait UDC node to re-appear */ Self::wait_udc_present(&ctrl, 3_000)?; /* 4 - re-attach + pull-up */ info!("🔌 re-attaching gadget to {ctrl}"); Self::write_attr(self.udc_file, &ctrl)?; if Path::new(&sc).exists() { // try to set the pull-up; ignore if the kernel rejects it match Self::write_attr(&sc, "1") { Err(err) => { // only swallow specific errno values if let Some(io) = err.downcast_ref::() { match io.raw_os_error() { // EINVAL | EPERM | ENOENT Some(libc::EINVAL) | Some(libc::EPERM) | Some(libc::ENOENT) => { warn!("⚠️ soft_connect unsupported ({io}); continuing"); } _ => return Err(err), // propagate all other errors } } else { return Err(err); // non-IO errors: propagate } } Ok(_) => { /* success */ } } } /* 5 - wait for host (but tolerate sleep) */ Self::wait_state(&ctrl, "configured", 6_000).or_else(|e| { // If the host is physically absent (sleep / KVM paused) // we allow 'not attached' and continue - we can still // accept keyboard/mouse data and the host will enumerate // later without another reset. let last = fs::read_to_string(format!("{}/class/udc/{ctrl}/state", Self::sysfs_root())) .unwrap_or_default(); if last.trim() == "not attached" { warn!("⚠️ host did not enumerate within 6 s - continuing (state = {last:?})"); Ok(()) } else { Err(e) } })?; info!("✅ USB-gadget cycle complete"); Ok(()) } /// helper: unbind + 300 ms reset + bind #[cfg(coverage)] fn rebind_driver(ctrl: &str) -> Result<()> { for drv in ["dwc2", "dwc3"] { let root = format!("{}/bus/platform/drivers/{drv}", Self::sysfs_root()); if !Path::new(&root).exists() { continue; } Self::write_attr(format!("{root}/unbind"), ctrl)?; Self::write_attr(format!("{root}/bind"), ctrl)?; return Ok(()); } Err(anyhow::anyhow!("no dwc2/dwc3 driver nodes found")) } #[cfg(not(coverage))] fn rebind_driver(ctrl: &str) -> Result<()> { let cand = ["dwc2", "dwc3"]; for drv in cand { let root = format!("{}/bus/platform/drivers/{drv}", Self::sysfs_root()); if !Path::new(&root).exists() { continue; } /*----------- unbind ------------------------------------------------*/ info!("🔧 unbinding UDC driver ({drv})"); for attempt in 1..=20 { match Self::write_attr(format!("{root}/unbind"), ctrl) { Ok(_) => break, Err(err) if attempt < 20 && Self::is_still_detaching(&err) => { trace!("unbind in-progress (#{attempt}) - waiting…"); thread::sleep(Duration::from_millis(100)); } Err(err) => return Err(err).context("UDC unbind failed irrecoverably"), } } thread::sleep(Duration::from_millis(150)); // let the core quiesce /*----------- bind --------------------------------------------------*/ info!("🔧 binding UDC driver ({drv})"); for attempt in 1..=20 { match Self::write_attr(format!("{root}/bind"), ctrl) { Ok(_) => return Ok(()), // success 🎉 Err(err) if attempt < 20 && Self::is_still_detaching(&err) => { trace!("bind busy (#{attempt}) - retrying…"); thread::sleep(Duration::from_millis(100)); } Err(err) => return Err(err).context("UDC bind failed irrecoverably"), } } } Err(anyhow::anyhow!("no dwc2/dwc3 driver nodes found")) } fn is_still_detaching(err: &anyhow::Error) -> bool { err.downcast_ref::() .and_then(|io| io.raw_os_error()) .map_or(false, |code| { matches!(code, libc::EBUSY | libc::ENOENT | libc::ENODEV) }) } fn recover_enumeration_internal(&self) -> Result<()> { let mut steps = Vec::new(); steps.push(format!("initial {}", Self::current_state_detail())); let cycle_ok = match self.cycle_forced() { Ok(()) => { steps.push("forced UDC cycle succeeded".to_string()); true } Err(err) => { steps.push(format!("forced UDC cycle failed: {err:#}")); false } }; if cycle_ok { if let Some((ctrl, state)) = Self::wait_for_host_attach(Self::recovery_wait_ms("CYCLE", 2_000)) { info!("✅ USB host enumerated after UDC cycle ctrl={ctrl} state={state}"); return Ok(()); } } if !Self::rebuild_helper_available() { anyhow::bail!( "USB gadget recovery cannot continue because no UDC/controller is available for forced rebuild; {}", steps.join("; ") ); } match self.run_forced_core_rebuild() { Ok(summary) => steps.push(summary), Err(err) => steps.push(format!("forced core rebuild failed: {err:#}")), } if let Some((ctrl, state)) = Self::wait_for_host_attach(Self::recovery_wait_ms("REBUILD", 8_000)) { info!("✅ USB host enumerated after forced gadget rebuild ctrl={ctrl} state={state}"); return Ok(()); } match self.cycle_forced() { Ok(()) => steps.push("post-rebuild UDC cycle succeeded".to_string()), Err(err) => steps.push(format!("post-rebuild UDC cycle failed: {err:#}")), } if let Some((ctrl, state)) = Self::wait_for_host_attach(Self::recovery_wait_ms("FINAL", 4_000)) { info!("✅ USB host enumerated after post-rebuild UDC cycle ctrl={ctrl} state={state}"); return Ok(()); } anyhow::bail!( "USB gadget is still not attached after aggressive recovery; current {}; steps: {}", Self::current_state_detail(), steps.join("; ") ) } fn rebuild_helper_available() -> bool { Self::find_controller().is_ok() || matches!(Self::probe_platform_udc(), Ok(Some(_))) || env::var("LESAVKA_FORCE_CORE_REBUILD_WITHOUT_UDC").is_ok() } fn wait_for_host_attach(limit_ms: u64) -> Option<(String, String)> { for _ in 0..=limit_ms / 100 { if let Ok((ctrl, state)) = Self::current_controller_state() { if Self::host_enumerated_state(&state) { return Some((ctrl, state)); } } thread::sleep(Duration::from_millis(100)); } None } fn recovery_wait_ms(step: &str, default_ms: u64) -> u64 { env::var(format!("LESAVKA_USB_RECOVERY_{step}_WAIT_MS")) .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(default_ms) } fn core_helper_path() -> String { env::var("LESAVKA_CORE_HELPER").unwrap_or_else(|_| "/usr/local/bin/lesavka-core.sh".into()) } fn run_forced_core_rebuild(&self) -> Result { let helper = Self::core_helper_path(); let output = Command::new(&helper) .env("LESAVKA_ALLOW_GADGET_RESET", "1") .env("LESAVKA_ATTACH_WRITE_UDC", "1") .env("LESAVKA_DETACH_CLEAR_UDC", "1") .env("LESAVKA_RELOAD_UVCVIDEO", "1") .env("LESAVKA_UVC_FALLBACK", "1") .env( "LESAVKA_UVC_CODEC", env::var("LESAVKA_UVC_CODEC").unwrap_or_else(|_| "mjpeg".to_string()), ) .output() .with_context(|| format!("running {helper} with forced gadget rebuild"))?; let stdout = Self::tail_text(&output.stdout); let stderr = Self::tail_text(&output.stderr); if !output.status.success() { anyhow::bail!( "forced gadget rebuild helper exited with {}; stderr: {}; stdout: {}", output.status, stderr, stdout ); } Ok(format!( "forced gadget rebuild helper succeeded: stderr: {}; stdout: {}", stderr, stdout )) } fn tail_text(bytes: &[u8]) -> String { let text = String::from_utf8_lossy(bytes).trim().to_string(); const LIMIT: usize = 1_200; if text.chars().count() <= LIMIT { return text; } let tail: String = text .chars() .rev() .take(LIMIT) .collect::() .chars() .rev() .collect(); format!("...{tail}") } }