fix(recovery): make media recovery safe
This commit is contained in:
parent
d4a8b78eca
commit
8f319549e1
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.16.7"
|
||||
version = "0.16.8"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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<Self> {
|
||||
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] <status|auto|on|off|reset-usb>"
|
||||
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|auto|on|off|recover-usb|recover-uac|recover-uvc|reset-usb>"
|
||||
}
|
||||
|
||||
fn parse_args_outcome_from<I, S>(args: I) -> Result<ParseOutcome>
|
||||
@ -103,7 +110,11 @@ fn parse_args() -> Result<ParseOutcome> {
|
||||
|
||||
fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest> {
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CameraConfig>) -> (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");
|
||||
|
||||
@ -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 {}))
|
||||
let request = Request::new(Empty {});
|
||||
let response = match kind {
|
||||
RecoveryKind::Usb => client
|
||||
.recover_usb(request)
|
||||
.await
|
||||
.context("requesting USB gadget reset")?
|
||||
.into_inner();
|
||||
.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"),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -109,6 +109,33 @@ impl Relay for ProbeRelay {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn recover_usb(
|
||||
&self,
|
||||
_request: Request<lesavka_common::lesavka::Empty>,
|
||||
) -> Result<Response<lesavka_common::lesavka::ResetUsbReply>, Status> {
|
||||
Ok(Response::new(lesavka_common::lesavka::ResetUsbReply {
|
||||
ok: true,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn recover_uac(
|
||||
&self,
|
||||
_request: Request<lesavka_common::lesavka::Empty>,
|
||||
) -> Result<Response<lesavka_common::lesavka::ResetUsbReply>, Status> {
|
||||
Ok(Response::new(lesavka_common::lesavka::ResetUsbReply {
|
||||
ok: true,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn recover_uvc(
|
||||
&self,
|
||||
_request: Request<lesavka_common::lesavka::Empty>,
|
||||
) -> Result<Response<lesavka_common::lesavka::ResetUsbReply>, Status> {
|
||||
Ok(Response::new(lesavka_common::lesavka::ResetUsbReply {
|
||||
ok: true,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_capture_power(
|
||||
&self,
|
||||
_request: Request<lesavka_common::lesavka::Empty>,
|
||||
|
||||
@ -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<Empty>,
|
||||
) -> Result<Response<ResetUsbReply>, Status> {
|
||||
*self.reset_count.lock().expect("reset count") += 1;
|
||||
Ok(Response::new(ResetUsbReply { ok: self.reset_ok }))
|
||||
}
|
||||
|
||||
async fn recover_uac(
|
||||
&self,
|
||||
_request: Request<Empty>,
|
||||
) -> Result<Response<ResetUsbReply>, Status> {
|
||||
*self.reset_count.lock().expect("reset count") += 1;
|
||||
Ok(Response::new(ResetUsbReply { ok: self.reset_ok }))
|
||||
}
|
||||
|
||||
async fn recover_uvc(
|
||||
&self,
|
||||
_request: Request<Empty>,
|
||||
) -> Result<Response<ResetUsbReply>, 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<Empty>,
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.16.7"
|
||||
version = "0.16.8"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.16.7"
|
||||
version = "0.16.8"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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<Arc<video::CameraRelay>, Status> {
|
||||
|
||||
@ -36,6 +36,7 @@ const PKG_NAME: &str = env!("CARGO_PKG_NAME");
|
||||
|
||||
type VideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
|
||||
type AudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
|
||||
type ResetReply = Result<Response<ResetUsbReply>, Status>;
|
||||
|
||||
fn hid_endpoint(index: u8) -> String {
|
||||
std::env::var("LESAVKA_HID_DIR")
|
||||
|
||||
@ -460,10 +460,10 @@ impl Relay for Handler {
|
||||
self.paste_text_reply(req).await
|
||||
}
|
||||
|
||||
/*────────────── USB-reset RPC ────────────*/
|
||||
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> {
|
||||
self.reset_usb_reply().await
|
||||
}
|
||||
async fn recover_usb(&self, _req: Request<Empty>) -> ResetReply { self.recover_usb_reply().await }
|
||||
async fn recover_uac(&self, _req: Request<Empty>) -> ResetReply { self.recover_uac_reply().await }
|
||||
async fn recover_uvc(&self, _req: Request<Empty>) -> ResetReply { self.recover_uvc_reply().await }
|
||||
async fn reset_usb(&self, _req: Request<Empty>) -> ResetReply { self.reset_usb_reply().await }
|
||||
|
||||
async fn get_capture_power(
|
||||
&self,
|
||||
|
||||
@ -271,6 +271,27 @@ impl Relay for Handler {
|
||||
self.reset_usb_reply().await
|
||||
}
|
||||
|
||||
async fn recover_usb(
|
||||
&self,
|
||||
_req: Request<Empty>,
|
||||
) -> Result<Response<ResetUsbReply>, Status> {
|
||||
self.recover_usb_reply().await
|
||||
}
|
||||
|
||||
async fn recover_uac(
|
||||
&self,
|
||||
_req: Request<Empty>,
|
||||
) -> Result<Response<ResetUsbReply>, Status> {
|
||||
self.recover_uac_reply().await
|
||||
}
|
||||
|
||||
async fn recover_uvc(
|
||||
&self,
|
||||
_req: Request<Empty>,
|
||||
) -> Result<Response<ResetUsbReply>, Status> {
|
||||
self.recover_uvc_reply().await
|
||||
}
|
||||
|
||||
async fn get_capture_power(
|
||||
&self,
|
||||
_req: Request<Empty>,
|
||||
|
||||
@ -1,4 +1,53 @@
|
||||
impl Handler {
|
||||
/// Reopen HID handles and verify enumeration without cycling the USB gadget.
|
||||
async fn recover_usb_reply(&self) -> Result<Response<ResetUsbReply>, 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<Response<ResetUsbReply>, 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<Response<ResetUsbReply>, 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<PasteRequest>,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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!(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user