diff --git a/Cargo.lock b/Cargo.lock index f5d82a4..85bd589 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.16.7" +version = "0.16.8" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.16.7" +version = "0.16.8" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.16.7" +version = "0.16.8" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index d367ddf..c522ef2 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/client/src/bin/lesavka-relayctl.rs b/client/src/bin/lesavka-relayctl.rs index d2cf6a2..032a433 100644 --- a/client/src/bin/lesavka-relayctl.rs +++ b/client/src/bin/lesavka-relayctl.rs @@ -16,17 +16,24 @@ enum CommandKind { Auto, On, Off, + RecoverUsb, + RecoverUac, + RecoverUvc, ResetUsb, } impl CommandKind { + /// Parse the intentionally small CLI vocabulary into safe relay actions. fn parse(value: &str) -> Option { match value { "status" | "get" => Some(Self::Status), "auto" => Some(Self::Auto), "on" | "force-on" => Some(Self::On), "off" | "force-off" => Some(Self::Off), - "reset-usb" | "recover-usb" => Some(Self::ResetUsb), + "recover-usb" => Some(Self::RecoverUsb), + "recover-uac" => Some(Self::RecoverUac), + "recover-uvc" => Some(Self::RecoverUvc), + "reset-usb" | "hard-reset-usb" => Some(Self::ResetUsb), _ => None, } } @@ -45,7 +52,7 @@ enum ParseOutcome { } fn usage() -> &'static str { - "Usage: lesavka-relayctl [--server http://HOST:50051] " + "Usage: lesavka-relayctl [--server http://HOST:50051] " } fn parse_args_outcome_from(args: I) -> Result @@ -103,7 +110,11 @@ fn parse_args() -> Result { fn capture_power_request(command: CommandKind) -> Option { let (enabled, command) = match command { - CommandKind::Status | CommandKind::ResetUsb => return None, + CommandKind::Status + | 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), @@ -160,7 +171,11 @@ async fn main() -> Result<()> { CommandKind::Auto => "setting capture power to auto", CommandKind::On => "forcing capture power on", CommandKind::Off => "forcing capture power off", - CommandKind::Status | CommandKind::ResetUsb => unreachable!(), + CommandKind::Status + | CommandKind::RecoverUsb + | CommandKind::RecoverUac + | CommandKind::RecoverUvc + | CommandKind::ResetUsb => unreachable!(), }; let reply = client .set_capture_power(Request::new(request)) @@ -177,6 +192,33 @@ async fn main() -> Result<()> { .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(()); + } CommandKind::ResetUsb => { let reply = client .reset_usb(Request::new(Empty {})) @@ -207,6 +249,7 @@ mod tests { 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)); @@ -214,6 +257,18 @@ mod tests { 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); @@ -272,6 +327,7 @@ mod tests { } #[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); @@ -286,6 +342,9 @@ mod tests { assert_eq!(off.command, CapturePowerCommand::ForceOff as i32); assert!(capture_power_request(CommandKind::Status).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()); } diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 657db91..8575b2f 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -61,3 +61,51 @@ include!("camera/encoder_selection.rs"); include!("camera/source_description.rs"); include!("camera/preview_tap.rs"); include!("camera/bus_and_encoder.rs"); + +#[cfg(test)] +mod tests { + use super::{CameraCodec, CameraConfig, resolved_capture_profile}; + use serial_test::serial; + + #[test] + #[serial] + /// Guards the browser-facing UVC contract against launcher preview overrides. + fn negotiated_capture_profile_overrides_launcher_quality_env_by_default() { + let cfg = CameraConfig { + codec: CameraCodec::Mjpeg, + width: 640, + height: 480, + fps: 20, + }; + temp_env::with_vars( + [ + ("LESAVKA_CAM_WIDTH", Some("1280")), + ("LESAVKA_CAM_HEIGHT", Some("720")), + ("LESAVKA_CAM_FPS", Some("30")), + ("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", None), + ], + || assert_eq!(resolved_capture_profile(Some(cfg)), (640, 480, 20)), + ); + } + + #[test] + #[serial] + /// Keeps the explicit lab override available for controlled camera debugging. + fn explicit_profile_override_keeps_lab_mode_available() { + let cfg = CameraConfig { + codec: CameraCodec::Mjpeg, + width: 640, + height: 480, + fps: 20, + }; + temp_env::with_vars( + [ + ("LESAVKA_CAM_WIDTH", Some("1280")), + ("LESAVKA_CAM_HEIGHT", Some("720")), + ("LESAVKA_CAM_FPS", Some("30")), + ("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", Some("1")), + ], + || assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)), + ); + } +} diff --git a/client/src/input/camera/capture_pipeline.rs b/client/src/input/camera/capture_pipeline.rs index d5ee5ba..d01a093 100644 --- a/client/src/input/camera/capture_pipeline.rs +++ b/client/src/input/camera/capture_pipeline.rs @@ -40,9 +40,7 @@ impl CameraCapture { |cfg| matches!(cfg.codec, CameraCodec::Mjpeg), ); let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100); - let width = env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)); - let height = env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)); - let fps = env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1); + let (width, height, fps) = resolved_capture_profile(cfg); let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps); let source_profile = camera_source_profile(allow_mjpg_source); let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg; @@ -261,6 +259,33 @@ impl CameraCapture { } } +/// Resolve the exact profile the client sends, preferring the server UVC contract. +fn resolved_capture_profile(cfg: Option) -> (u32, u32, u32) { + match cfg { + Some(cfg) if !env_flag_enabled("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE") => { + return (cfg.width, cfg.height, cfg.fps.max(1)); + } + _ => {} + } + + ( + env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)), + env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)), + env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1), + ) +} + +fn env_flag_enabled(name: &str) -> bool { + std::env::var(name).ok().is_some_and(|value| { + let trimmed = value.trim(); + !(trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("0") + || trimmed.eq_ignore_ascii_case("false") + || trimmed.eq_ignore_ascii_case("no") + || trimmed.eq_ignore_ascii_case("off")) + }) +} + fn log_camera_first_packet(packet_index: u64, bytes: usize, pts_us: u64) { if packet_index == 0 { tracing::info!(bytes, pts_us, "📸 upstream webcam frames flowing"); diff --git a/client/src/launcher/power.rs b/client/src/launcher/power.rs index 742ee44..28f86e4 100644 --- a/client/src/launcher/power.rs +++ b/client/src/launcher/power.rs @@ -37,18 +37,52 @@ pub fn set_capture_power_mode( }) } -pub fn reset_usb_gadget(server_addr: &str) -> Result<()> { +pub fn recover_usb_soft(server_addr: &str) -> Result<()> { + run_recovery(server_addr, RecoveryKind::Usb) +} + +pub fn recover_uac_soft(server_addr: &str) -> Result<()> { + run_recovery(server_addr, RecoveryKind::Uac) +} + +pub fn recover_uvc_soft(server_addr: &str) -> Result<()> { + run_recovery(server_addr, RecoveryKind::Uvc) +} + +#[derive(Clone, Copy)] +enum RecoveryKind { + Usb, + Uac, + Uvc, +} + +fn run_recovery(server_addr: &str, kind: RecoveryKind) -> 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(); + let request = Request::new(Empty {}); + let response = match kind { + RecoveryKind::Usb => client + .recover_usb(request) + .await + .context("requesting soft USB recovery")?, + RecoveryKind::Uac => client + .recover_uac(request) + .await + .context("requesting soft UAC recovery")?, + RecoveryKind::Uvc => client + .recover_uvc(request) + .await + .context("requesting soft UVC recovery")?, + }; + let reply = response.into_inner(); if reply.ok { Ok(()) } else { - anyhow::bail!("relay reported USB reset failure"); + match kind { + RecoveryKind::Usb => anyhow::bail!("relay reported soft USB recovery failure"), + RecoveryKind::Uac => anyhow::bail!("relay reported soft UAC recovery failure"), + RecoveryKind::Uvc => anyhow::bail!("relay reported soft UVC recovery failure"), + } } }) } diff --git a/client/src/launcher/tests/preview.rs b/client/src/launcher/tests/preview.rs index 8681a6e..2a4bc6e 100644 --- a/client/src/launcher/tests/preview.rs +++ b/client/src/launcher/tests/preview.rs @@ -109,6 +109,33 @@ impl Relay for ProbeRelay { })) } + async fn recover_usb( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(lesavka_common::lesavka::ResetUsbReply { + ok: true, + })) + } + + async fn recover_uac( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(lesavka_common::lesavka::ResetUsbReply { + ok: true, + })) + } + + async fn recover_uvc( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(lesavka_common::lesavka::ResetUsbReply { + ok: true, + })) + } + async fn get_capture_power( &self, _request: Request, diff --git a/client/src/launcher/tests/utility_actions.rs b/client/src/launcher/tests/utility_actions.rs index 9682874..149f1df 100644 --- a/client/src/launcher/tests/utility_actions.rs +++ b/client/src/launcher/tests/utility_actions.rs @@ -1,4 +1,7 @@ -use super::super::{clipboard::send_clipboard_text_to_remote, power::reset_usb_gadget}; +use super::super::{ + clipboard::send_clipboard_text_to_remote, + power::{recover_uac_soft, recover_usb_soft, recover_uvc_soft}, +}; use futures::stream; use lesavka_common::lesavka::{ AudioPacket, CalibrationRequest, CalibrationState, CapturePowerState, Empty, KeyboardReport, @@ -104,6 +107,30 @@ impl Relay for UtilityRelay { Ok(Response::new(ResetUsbReply { ok: self.reset_ok })) } + async fn recover_usb( + &self, + _request: Request, + ) -> Result, Status> { + *self.reset_count.lock().expect("reset count") += 1; + Ok(Response::new(ResetUsbReply { ok: self.reset_ok })) + } + + async fn recover_uac( + &self, + _request: Request, + ) -> Result, Status> { + *self.reset_count.lock().expect("reset count") += 1; + Ok(Response::new(ResetUsbReply { ok: self.reset_ok })) + } + + async fn recover_uvc( + &self, + _request: Request, + ) -> Result, Status> { + *self.reset_count.lock().expect("reset count") += 1; + Ok(Response::new(ResetUsbReply { ok: self.reset_ok })) + } + async fn get_capture_power( &self, _request: Request, @@ -183,13 +210,25 @@ fn recover_usb_action_reports_relay_success_and_failure() { let relay = UtilityRelay::new(true); let reset_count = Arc::clone(&relay.reset_count); let (_rt, addr) = serve(relay); - reset_usb_gadget(&addr).expect("successful reset"); + recover_usb_soft(&addr).expect("successful soft USB recovery"); assert_eq!(*reset_count.lock().expect("reset count"), 1); let relay = UtilityRelay::new(false); let reset_count = Arc::clone(&relay.reset_count); let (_rt, addr) = serve(relay); - let err = reset_usb_gadget(&addr).expect_err("reset failure"); - assert!(format!("{err:#}").contains("relay reported USB reset failure")); + let err = recover_usb_soft(&addr).expect_err("soft USB recovery failure"); + assert!(format!("{err:#}").contains("relay reported soft USB recovery failure")); assert_eq!(*reset_count.lock().expect("reset count"), 1); } + +#[test] +#[serial] +fn soft_recovery_actions_report_relay_success() { + let relay = UtilityRelay::new(true); + let reset_count = Arc::clone(&relay.reset_count); + let (_rt, addr) = serve(relay); + recover_usb_soft(&addr).expect("usb soft recover"); + recover_uac_soft(&addr).expect("uac soft recover"); + recover_uvc_soft(&addr).expect("uvc soft recover"); + assert_eq!(*reset_count.lock().expect("reset count"), 3); +} diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 7af663c..440e501 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -12,7 +12,10 @@ use { super::diagnostics::PerformanceSample, super::launcher_clipboard_control_path, super::launcher_focus_signal_path, - super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode}, + super::power::{ + fetch_capture_power, recover_uac_soft, recover_usb_soft, recover_uvc_soft, + set_capture_power_mode, + }, super::preview::{LauncherPreview, PreviewSurface}, super::state::{ BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset, diff --git a/client/src/launcher/ui/utility_button_bindings.rs b/client/src/launcher/ui/utility_button_bindings.rs index 85d3b47..ea8de9e 100644 --- a/client/src/launcher/ui/utility_button_bindings.rs +++ b/client/src/launcher/ui/utility_button_bindings.rs @@ -161,18 +161,18 @@ 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( - "Recover USB 1/3: sending gadget reset request to relay host...", + "Recover USB 1/3: reopening HID handles and checking enumeration...", ); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); + let result = recover_usb_soft(&server_addr).map_err(|err| format!("{err:#}")); 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( - "Recover USB 2/3: relay acknowledged reset. Recover USB 3/3: waiting for USB/UAC/UVC chips to settle.", + "Recover USB 2/3: HID reopened. Recover USB 3/3: watching chips for stable enumeration.", ); glib::ControlFlow::Break } @@ -202,17 +202,17 @@ let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click .status_label - .set_text("Recover UAC 1/3: sending gadget reset request to relay host..."); + .set_text("Recover UAC 1/3: asking microphone stream to stand down cleanly..."); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); + let result = recover_uac_soft(&server_addr).map_err(|err| format!("{err:#}")); 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( - "Recover UAC 2/3: relay acknowledged reset. Recover UAC 3/3: waiting for UAC chip to settle.", + "Recover UAC 2/3: old mic sink released. Recover UAC 3/3: client will reconnect without resetting USB.", ); glib::ControlFlow::Break } @@ -242,17 +242,17 @@ let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click .status_label - .set_text("Recover UVC 1/3: sending gadget reset request to relay host..."); + .set_text("Recover UVC 1/3: retiring the webcam spool pipeline safely..."); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); + let result = recover_uvc_soft(&server_addr).map_err(|err| format!("{err:#}")); 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( - "Recover UVC 2/3: relay acknowledged reset. Recover UVC 3/3: waiting for UVC chip to settle.", + "Recover UVC 2/3: webcam sink retired. Recover UVC 3/3: client will recreate the UVC spool on reconnect.", ); glib::ControlFlow::Break } diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs index 805c5d4..114eebb 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -38,9 +38,18 @@ let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); recovery_buttons.set_hexpand(true); recovery_buttons.set_homogeneous(true); - let usb_recover_button = rail_button("USB", "Re-enumerate remote USB gadget."); - let uac_recover_button = rail_button("UAC", "Rebuild remote USB audio function."); - let uvc_recover_button = rail_button("UVC", "Rebuild remote USB webcam function."); + let usb_recover_button = rail_button( + "USB", + "Soft recovery: reopen HID and verify enumeration without detaching the USB gadget.", + ); + let uac_recover_button = rail_button( + "UAC", + "Soft recovery: retire the active mic sink so the audio stream reconnects without resetting USB.", + ); + let uvc_recover_button = rail_button( + "UVC", + "Soft recovery: retire the webcam spool pipeline so video reconnects without restarting the gadget.", + ); recovery_buttons.append(&usb_recover_button); recovery_buttons.append(&uac_recover_button); recovery_buttons.append(&uvc_recover_button); diff --git a/common/Cargo.toml b/common/Cargo.toml index 08e585a..4fc2022 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.16.7" +version = "0.16.8" edition = "2024" build = "build.rs" diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index 5cc6783..f8f8bda 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -114,6 +114,9 @@ service Relay { rpc StreamCamera (stream VideoPacket) returns (stream Empty); rpc PasteText (PasteRequest) returns (PasteReply); + rpc RecoverUsb (Empty) returns (ResetUsbReply); + rpc RecoverUac (Empty) returns (ResetUsbReply); + rpc RecoverUvc (Empty) returns (ResetUsbReply); rpc ResetUsb (Empty) returns (ResetUsbReply); rpc GetCapturePower (Empty) returns (CapturePowerState); rpc SetCapturePower (SetCapturePowerRequest) returns (CapturePowerState); diff --git a/docs/operational-env.md b/docs/operational-env.md index 0a36d9d..9271b95 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -35,6 +35,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_BREAKOUT_REQUEST_FPS` | eye preview/video transport override | | `LESAVKA_BREAKOUT_REQUEST_HEIGHT` | eye preview/video transport override | | `LESAVKA_BREAKOUT_REQUEST_WIDTH` | eye preview/video transport override | +| `LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE` | lab-only override that lets launcher camera width/height/fps env values beat the server-negotiated UVC profile | | `LESAVKA_CAM_BY_ID_DIR` | client media capture/playback override | | `LESAVKA_CAM_CODEC` | client media capture/playback override | | `LESAVKA_CAM_DEV_ROOT` | client media capture/playback override | diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 83d1840..ae8b203 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -37,8 +37,8 @@ }, "client/src/bin/lesavka-relayctl.rs": { "clippy_warnings": 0, - "doc_debt": 6, - "loc": 304 + "doc_debt": 4, + "loc": 363 }, "client/src/bin/lesavka-sync-analyze.rs": { "clippy_warnings": 0, @@ -58,7 +58,7 @@ "client/src/input/camera.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 63 + "loc": 111 }, "client/src/input/camera/bus_and_encoder.rs": { "clippy_warnings": 0, @@ -68,7 +68,7 @@ "client/src/input/camera/capture_pipeline.rs": { "clippy_warnings": 0, "doc_debt": 4, - "loc": 295 + "loc": 320 }, "client/src/input/camera/device_selection.rs": { "clippy_warnings": 0, @@ -228,12 +228,12 @@ "client/src/launcher/mod.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 246 + "loc": 240 }, "client/src/launcher/power.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 86 + "loc": 120 }, "client/src/launcher/preview.rs": { "clippy_warnings": 0, @@ -293,7 +293,7 @@ "client/src/launcher/ui.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 194 + "loc": 197 }, "client/src/launcher/ui/activation_context.rs": { "clippy_warnings": 0, @@ -408,7 +408,7 @@ "client/src/launcher/ui_components/build_operations_rail.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 323 + "loc": 332 }, "client/src/launcher/ui_components/build_shell.rs": { "clippy_warnings": 0, @@ -506,9 +506,9 @@ "loc": 429 }, "client/src/live_media_control.rs": { - "loc": 200, "clippy_warnings": 0, - "doc_debt": 3 + "doc_debt": 3, + "loc": 200 }, "client/src/main.rs": { "clippy_warnings": 0, @@ -703,7 +703,7 @@ "server/src/audio/ear_capture.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 460 + "loc": 452 }, "server/src/audio/voice_input.rs": { "clippy_warnings": 0, @@ -758,7 +758,7 @@ "server/src/camera_runtime.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 211 + "loc": 230 }, "server/src/capture_power.rs": { "clippy_warnings": 0, @@ -813,7 +813,7 @@ "server/src/main.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 100 + "loc": 101 }, "server/src/main/entrypoint.rs": { "clippy_warnings": 0, @@ -843,7 +843,7 @@ "server/src/main/relay_service_coverage.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 301 + "loc": 322 }, "server/src/main/relay_service_tests.rs": { "clippy_warnings": 0, @@ -858,7 +858,7 @@ "server/src/main/rpc_helpers.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 118 + "loc": 167 }, "server/src/main/usb_recovery_helpers.rs": { "clippy_warnings": 0, @@ -913,7 +913,7 @@ "server/src/upstream_media_runtime/lease_lifecycle.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 142 + "loc": 153 }, "server/src/upstream_media_runtime/state.rs": { "clippy_warnings": 0, diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index aeb12a6..18a66dc 100755 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -457,6 +457,15 @@ if [[ -z $DISABLE_UAC ]]; then echo adaptive >"$U/c_sync" 2>/dev/null || true # Optional: allocate a few extra request buffers echo 32 >"$U/req_number" 2>/dev/null || true + # Friendly host-side labels when this kernel exposes UAC2 descriptor names. + # Browsers still choose their own wording, but these make Lesavka easier to + # recognize than the generic "Multifunction Composite Gadget" fallback. + echo "Lesavka Audio Bridge" >"$U/function_name" 2>/dev/null || true + echo "Lesavka Audio Control" >"$U/if_ctrl_name" 2>/dev/null || true + echo "Lesavka Speaker Playback" >"$U/p_it_name" 2>/dev/null || true + echo "Lesavka Host Speaker" >"$U/p_ot_name" 2>/dev/null || true + echo "Lesavka Remote Mic" >"$U/c_it_name" 2>/dev/null || true + echo "Lesavka Microphone" >"$U/c_ot_name" 2>/dev/null || true else log "🔇 UAC2 disabled (LESAVKA_DISABLE_UAC set)" fi diff --git a/server/Cargo.toml b/server/Cargo.toml index 71a44af..075ae97 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.16.7" +version = "0.16.8" edition = "2024" autobins = false diff --git a/server/src/camera_runtime.rs b/server/src/camera_runtime.rs index a789abe..598f249 100644 --- a/server/src/camera_runtime.rs +++ b/server/src/camera_runtime.rs @@ -116,6 +116,25 @@ impl CameraRuntime { self.generation.load(Ordering::Relaxed) == session_id } + /// Supersede the active camera stream and drop the userspace relay sink. + /// + /// Inputs: none. + /// Outputs: none. + /// Why: UVC recovery should make the client reconnect and recreate the + /// spool/appsrc pipeline without cycling the USB controller while a browser + /// may still own the gadget. + #[cfg(coverage)] + pub async fn soft_recover(&self) { + self.generation.fetch_add(1, Ordering::SeqCst); + } + + #[cfg(not(coverage))] + pub async fn soft_recover(&self) { + self.generation.fetch_add(1, Ordering::SeqCst); + let mut slot = self.slot.lock().await; + let _dropped = slot.take(); + } + #[allow(clippy::result_large_err)] #[cfg(not(coverage))] fn make_relay(&self, cfg: &camera::CameraConfig) -> Result, Status> { diff --git a/server/src/main.rs b/server/src/main.rs index 864a38c..6c3f558 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -36,6 +36,7 @@ const PKG_NAME: &str = env!("CARGO_PKG_NAME"); type VideoStream = Pin> + Send>>; type AudioStream = Pin> + Send>>; +type ResetReply = Result, Status>; fn hid_endpoint(index: u8) -> String { std::env::var("LESAVKA_HID_DIR") diff --git a/server/src/main/relay_service.rs b/server/src/main/relay_service.rs index 46615e9..8873069 100644 --- a/server/src/main/relay_service.rs +++ b/server/src/main/relay_service.rs @@ -460,10 +460,10 @@ impl Relay for Handler { self.paste_text_reply(req).await } - /*────────────── USB-reset RPC ────────────*/ - async fn reset_usb(&self, _req: Request) -> Result, Status> { - self.reset_usb_reply().await - } + async fn recover_usb(&self, _req: Request) -> ResetReply { self.recover_usb_reply().await } + async fn recover_uac(&self, _req: Request) -> ResetReply { self.recover_uac_reply().await } + async fn recover_uvc(&self, _req: Request) -> ResetReply { self.recover_uvc_reply().await } + async fn reset_usb(&self, _req: Request) -> ResetReply { self.reset_usb_reply().await } async fn get_capture_power( &self, diff --git a/server/src/main/relay_service_coverage.rs b/server/src/main/relay_service_coverage.rs index b5027f6..28d059a 100644 --- a/server/src/main/relay_service_coverage.rs +++ b/server/src/main/relay_service_coverage.rs @@ -271,6 +271,27 @@ impl Relay for Handler { self.reset_usb_reply().await } + async fn recover_usb( + &self, + _req: Request, + ) -> Result, Status> { + self.recover_usb_reply().await + } + + async fn recover_uac( + &self, + _req: Request, + ) -> Result, Status> { + self.recover_uac_reply().await + } + + async fn recover_uvc( + &self, + _req: Request, + ) -> Result, Status> { + self.recover_uvc_reply().await + } + async fn get_capture_power( &self, _req: Request, diff --git a/server/src/main/rpc_helpers.rs b/server/src/main/rpc_helpers.rs index 26ceacd..5a50f45 100644 --- a/server/src/main/rpc_helpers.rs +++ b/server/src/main/rpc_helpers.rs @@ -1,4 +1,53 @@ impl Handler { + /// Reopen HID handles and verify enumeration without cycling the USB gadget. + async fn recover_usb_reply(&self) -> Result, Status> { + #[cfg(not(coverage))] + info!("🛟 RecoverUsb() soft recovery called"); + + self.reopen_hid() + .await + .map_err(|e| Status::internal(format!("soft USB HID reopen failed: {e:#}")))?; + + match current_controller_state_after_recovery() { + Ok((ctrl, state)) if UsbGadget::host_enumerated_state(&state) => { + #[cfg(not(coverage))] + info!("✅ soft USB recovery verified host enumeration ctrl={ctrl} state={state}"); + Ok(Response::new(ResetUsbReply { ok: true })) + } + Ok((ctrl, state)) => Err(Status::failed_precondition(format!( + "soft USB recovery refused hard reset: UDC {ctrl} is {state}; use explicit hard ResetUsb only after closing remote UVC/audio consumers" + ))), + Err(err) => Err(Status::failed_precondition(format!( + "soft USB recovery could not read UDC state: {err:#}" + ))), + } + } + + async fn recover_uac_reply(&self) -> Result, Status> { + #[cfg(not(coverage))] + info!("🛟 RecoverUac() soft recovery called"); + + self.upstream_media_rt.soft_recover_microphone(); + Ok(Response::new(ResetUsbReply { ok: true })) + } + + /// Retire the current webcam relay sink without detaching the UVC gadget. + async fn recover_uvc_reply(&self) -> Result, Status> { + #[cfg(not(coverage))] + info!("🛟 RecoverUvc() soft recovery called"); + + let cfg = camera::current_camera_config(); + if cfg.output != camera::CameraOutput::Uvc { + return Err(Status::failed_precondition(format!( + "soft UVC recovery skipped because active camera output is {}", + cfg.output.as_str() + ))); + } + + self.camera_rt.soft_recover().await; + Ok(Response::new(ResetUsbReply { ok: true })) + } + async fn paste_text_reply( &self, req: Request, diff --git a/server/src/upstream_media_runtime/lease_lifecycle.rs b/server/src/upstream_media_runtime/lease_lifecycle.rs index e1e74d8..df4a797 100644 --- a/server/src/upstream_media_runtime/lease_lifecycle.rs +++ b/server/src/upstream_media_runtime/lease_lifecycle.rs @@ -105,6 +105,17 @@ impl UpstreamMediaRuntime { self.close(UpstreamMediaKind::Microphone, generation); } + /// Supersede any current microphone owner without touching the USB gadget. + /// + /// Inputs: none. + /// Outputs: none. + /// Why: UAC recovery should force the active gRPC/audio sink to drain and + /// reconnect, not reset UDC or disturb UVC/HID. + pub fn soft_recover_microphone(&self) { + let lease = self.activate_microphone(); + self.close_microphone(lease.generation); + } + fn close(&self, kind: UpstreamMediaKind, generation: u64) { let mut state = self .state diff --git a/server/src/upstream_media_runtime/tests/lifecycle.rs b/server/src/upstream_media_runtime/tests/lifecycle.rs index 07da357..68f363a 100644 --- a/server/src/upstream_media_runtime/tests/lifecycle.rs +++ b/server/src/upstream_media_runtime/tests/lifecycle.rs @@ -189,3 +189,37 @@ fn close_ignores_superseded_generation_values() { let next = runtime.activate_camera(); assert_eq!(next.session_id, 2); } + +#[test] +#[serial(upstream_media_runtime)] +fn soft_microphone_recovery_preserves_camera_and_shared_clock() { + let runtime = runtime_without_offsets(); + let camera = runtime.activate_camera(); + let microphone = runtime.activate_microphone(); + + assert!(matches!( + runtime.plan_video_pts(1_000_000, 16_666), + super::UpstreamPlanDecision::AwaitingPair + )); + let audio_first = play(runtime.plan_audio_pts(1_000_000)); + let video_first = play(runtime.plan_video_pts(1_000_000, 16_666)); + assert_eq!(audio_first.local_pts_us, video_first.local_pts_us); + + runtime.soft_recover_microphone(); + + assert!(runtime.is_camera_active(camera.generation)); + assert!(!runtime.is_microphone_active(microphone.generation)); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .expect("runtime"); + assert!( + rt.block_on(runtime.wait_for_audio_master(video_first.local_pts_us, video_first.due_at)), + "video should not wait on a soft-recovered microphone slot" + ); + + let next_microphone = runtime.activate_microphone(); + assert_eq!(next_microphone.session_id, camera.session_id); + let recovered_audio = play(runtime.plan_audio_pts(1_010_000)); + assert_eq!(recovered_audio.local_pts_us, 10_000); +} diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index b0b16ff..7e4e3a7 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -167,7 +167,8 @@ fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() { assert!(DEVICE_TEST_SRC.contains("pub fn set_camera_quality")); assert!(DEVICE_TEST_SRC.contains("build_camera_preview_pipeline(&device, mode)")); assert!(DEVICE_TEST_SRC.contains("capsfilter caps=\\\"video/x-raw")); - assert!(CAMERA_SRC.contains("env_u32(\"LESAVKA_CAM_WIDTH\", cfg.map_or")); + assert!(CAMERA_SRC.contains("fn resolved_capture_profile")); + assert!(CAMERA_SRC.contains("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE")); assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_WIDTH\"")); assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\"")); } @@ -191,8 +192,10 @@ fn launcher_utility_buttons_still_bind_to_live_actions() { assert!(UI_SRC.contains("recording saved to")); assert!(UI_SRC.contains("press Stop to finish.")); assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked")); - assert!(UI_SRC.contains("reset_usb_gadget(&server_addr)")); - assert!(UI_SRC.contains("Recover USB 2/3: relay acknowledged reset.")); + assert!(UI_SRC.contains("recover_usb_soft(&server_addr)")); + assert!(UI_SRC.contains("recover_uac_soft(&server_addr)")); + assert!(UI_SRC.contains("recover_uvc_soft(&server_addr)")); + assert!(UI_SRC.contains("Recover USB 2/3: HID reopened.")); } #[test] diff --git a/testing/tests/client_relayctl_binary_contract.rs b/testing/tests/client_relayctl_binary_contract.rs index f27e4ef..a379914 100644 --- a/testing/tests/client_relayctl_binary_contract.rs +++ b/testing/tests/client_relayctl_binary_contract.rs @@ -22,10 +22,24 @@ mod relayctl_binary { assert_eq!(CommandKind::parse("reset-usb"), Some(CommandKind::ResetUsb)); 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("bad"), None); assert!(usage().contains("lesavka-relayctl")); + assert!(usage().contains("recover-uac")); + assert!(usage().contains("recover-uvc")); } #[tokio::test(flavor = "current_thread")] diff --git a/testing/tests/server_install_script_contract.rs b/testing/tests/server_install_script_contract.rs index 198da6c..c9bc7c0 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/testing/tests/server_install_script_contract.rs @@ -115,7 +115,9 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "LESAVKA_ALLOW_GADGET_RESET should permit recovery without causing unconditional hard rebuilds" ); assert!( - !SERVER_INSTALL.contains("[[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]] || [[ \"$FORCE_GADGET_REBUILD\" == \"1\" ]]"), + !SERVER_INSTALL.contains( + "[[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]] || [[ \"$FORCE_GADGET_REBUILD\" == \"1\" ]]" + ), "LESAVKA_ALLOW_GADGET_RESET must not itself trigger a hard UDC detach/rebind" ); assert!(