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]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.16.7"
|
version = "0.16.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.16.7"
|
version = "0.16.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.16.7"
|
version = "0.16.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.16.7"
|
version = "0.16.8"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -16,17 +16,24 @@ enum CommandKind {
|
|||||||
Auto,
|
Auto,
|
||||||
On,
|
On,
|
||||||
Off,
|
Off,
|
||||||
|
RecoverUsb,
|
||||||
|
RecoverUac,
|
||||||
|
RecoverUvc,
|
||||||
ResetUsb,
|
ResetUsb,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandKind {
|
impl CommandKind {
|
||||||
|
/// Parse the intentionally small CLI vocabulary into safe relay actions.
|
||||||
fn parse(value: &str) -> Option<Self> {
|
fn parse(value: &str) -> Option<Self> {
|
||||||
match value {
|
match value {
|
||||||
"status" | "get" => Some(Self::Status),
|
"status" | "get" => Some(Self::Status),
|
||||||
"auto" => Some(Self::Auto),
|
"auto" => Some(Self::Auto),
|
||||||
"on" | "force-on" => Some(Self::On),
|
"on" | "force-on" => Some(Self::On),
|
||||||
"off" | "force-off" => Some(Self::Off),
|
"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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -45,7 +52,7 @@ enum ParseOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> &'static str {
|
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>
|
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> {
|
fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest> {
|
||||||
let (enabled, command) = match command {
|
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::Auto => (false, CapturePowerCommand::Auto),
|
||||||
CommandKind::On => (true, CapturePowerCommand::ForceOn),
|
CommandKind::On => (true, CapturePowerCommand::ForceOn),
|
||||||
CommandKind::Off => (false, CapturePowerCommand::ForceOff),
|
CommandKind::Off => (false, CapturePowerCommand::ForceOff),
|
||||||
@ -160,7 +171,11 @@ async fn main() -> Result<()> {
|
|||||||
CommandKind::Auto => "setting capture power to auto",
|
CommandKind::Auto => "setting capture power to auto",
|
||||||
CommandKind::On => "forcing capture power on",
|
CommandKind::On => "forcing capture power on",
|
||||||
CommandKind::Off => "forcing capture power off",
|
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
|
let reply = client
|
||||||
.set_capture_power(Request::new(request))
|
.set_capture_power(Request::new(request))
|
||||||
@ -177,6 +192,33 @@ async fn main() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.context("querying capture power state")?
|
.context("querying capture power state")?
|
||||||
.into_inner(),
|
.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 => {
|
CommandKind::ResetUsb => {
|
||||||
let reply = client
|
let reply = client
|
||||||
.reset_usb(Request::new(Empty {}))
|
.reset_usb(Request::new(Empty {}))
|
||||||
@ -207,6 +249,7 @@ mod tests {
|
|||||||
use lesavka_common::lesavka::CapturePowerState;
|
use lesavka_common::lesavka::CapturePowerState;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
/// Verifies safe recovery commands stay separate from explicit hard reset.
|
||||||
fn command_aliases_parse_to_stable_actions() {
|
fn command_aliases_parse_to_stable_actions() {
|
||||||
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
|
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
|
||||||
assert_eq!(CommandKind::parse("get"), 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("force-off"), Some(CommandKind::Off));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
CommandKind::parse("recover-usb"),
|
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)
|
Some(CommandKind::ResetUsb)
|
||||||
);
|
);
|
||||||
assert_eq!(CommandKind::parse("wat"), None);
|
assert_eq!(CommandKind::parse("wat"), None);
|
||||||
@ -272,6 +327,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
/// Keeps status/read commands from accidentally mutating capture power.
|
||||||
fn mutating_commands_map_to_capture_power_requests() {
|
fn mutating_commands_map_to_capture_power_requests() {
|
||||||
let auto = capture_power_request(CommandKind::Auto).expect("auto request");
|
let auto = capture_power_request(CommandKind::Auto).expect("auto request");
|
||||||
assert!(!auto.enabled);
|
assert!(!auto.enabled);
|
||||||
@ -286,6 +342,9 @@ mod tests {
|
|||||||
assert_eq!(off.command, CapturePowerCommand::ForceOff as i32);
|
assert_eq!(off.command, CapturePowerCommand::ForceOff as i32);
|
||||||
|
|
||||||
assert!(capture_power_request(CommandKind::Status).is_none());
|
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());
|
assert!(capture_power_request(CommandKind::ResetUsb).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -61,3 +61,51 @@ include!("camera/encoder_selection.rs");
|
|||||||
include!("camera/source_description.rs");
|
include!("camera/source_description.rs");
|
||||||
include!("camera/preview_tap.rs");
|
include!("camera/preview_tap.rs");
|
||||||
include!("camera/bus_and_encoder.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),
|
|cfg| matches!(cfg.codec, CameraCodec::Mjpeg),
|
||||||
);
|
);
|
||||||
let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100);
|
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 (width, height, fps) = resolved_capture_profile(cfg);
|
||||||
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 keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps);
|
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 source_profile = camera_source_profile(allow_mjpg_source);
|
||||||
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;
|
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) {
|
fn log_camera_first_packet(packet_index: u64, bytes: usize, pts_us: u64) {
|
||||||
if packet_index == 0 {
|
if packet_index == 0 {
|
||||||
tracing::info!(bytes, pts_us, "📸 upstream webcam frames flowing");
|
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 {
|
with_runtime(async move {
|
||||||
let mut client = connect(server_addr).await?;
|
let mut client = connect(server_addr).await?;
|
||||||
let reply = client
|
let request = Request::new(Empty {});
|
||||||
.reset_usb(Request::new(Empty {}))
|
let response = match kind {
|
||||||
|
RecoveryKind::Usb => client
|
||||||
|
.recover_usb(request)
|
||||||
.await
|
.await
|
||||||
.context("requesting USB gadget reset")?
|
.context("requesting soft USB recovery")?,
|
||||||
.into_inner();
|
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 {
|
if reply.ok {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} 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(
|
async fn get_capture_power(
|
||||||
&self,
|
&self,
|
||||||
_request: Request<lesavka_common::lesavka::Empty>,
|
_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 futures::stream;
|
||||||
use lesavka_common::lesavka::{
|
use lesavka_common::lesavka::{
|
||||||
AudioPacket, CalibrationRequest, CalibrationState, CapturePowerState, Empty, KeyboardReport,
|
AudioPacket, CalibrationRequest, CalibrationState, CapturePowerState, Empty, KeyboardReport,
|
||||||
@ -104,6 +107,30 @@ impl Relay for UtilityRelay {
|
|||||||
Ok(Response::new(ResetUsbReply { ok: self.reset_ok }))
|
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(
|
async fn get_capture_power(
|
||||||
&self,
|
&self,
|
||||||
_request: Request<Empty>,
|
_request: Request<Empty>,
|
||||||
@ -183,13 +210,25 @@ fn recover_usb_action_reports_relay_success_and_failure() {
|
|||||||
let relay = UtilityRelay::new(true);
|
let relay = UtilityRelay::new(true);
|
||||||
let reset_count = Arc::clone(&relay.reset_count);
|
let reset_count = Arc::clone(&relay.reset_count);
|
||||||
let (_rt, addr) = serve(relay);
|
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);
|
assert_eq!(*reset_count.lock().expect("reset count"), 1);
|
||||||
|
|
||||||
let relay = UtilityRelay::new(false);
|
let relay = UtilityRelay::new(false);
|
||||||
let reset_count = Arc::clone(&relay.reset_count);
|
let reset_count = Arc::clone(&relay.reset_count);
|
||||||
let (_rt, addr) = serve(relay);
|
let (_rt, addr) = serve(relay);
|
||||||
let err = reset_usb_gadget(&addr).expect_err("reset failure");
|
let err = recover_usb_soft(&addr).expect_err("soft USB recovery failure");
|
||||||
assert!(format!("{err:#}").contains("relay reported USB reset failure"));
|
assert!(format!("{err:#}").contains("relay reported soft USB recovery failure"));
|
||||||
assert_eq!(*reset_count.lock().expect("reset count"), 1);
|
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::diagnostics::PerformanceSample,
|
||||||
super::launcher_clipboard_control_path,
|
super::launcher_clipboard_control_path,
|
||||||
super::launcher_focus_signal_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::preview::{LauncherPreview, PreviewSurface},
|
||||||
super::state::{
|
super::state::{
|
||||||
BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset,
|
BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset,
|
||||||
|
|||||||
@ -161,18 +161,18 @@
|
|||||||
widgets.usb_recover_button.connect_clicked(move |_| {
|
widgets.usb_recover_button.connect_clicked(move |_| {
|
||||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
widgets_for_click.status_label.set_text(
|
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();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
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 _ = tx.send(result);
|
||||||
});
|
});
|
||||||
let widgets = widgets_for_click.clone();
|
let widgets = widgets_for_click.clone();
|
||||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
widgets.status_label.set_text(
|
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
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
@ -202,17 +202,17 @@
|
|||||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
widgets_for_click
|
widgets_for_click
|
||||||
.status_label
|
.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();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
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 _ = tx.send(result);
|
||||||
});
|
});
|
||||||
let widgets = widgets_for_click.clone();
|
let widgets = widgets_for_click.clone();
|
||||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
widgets.status_label.set_text(
|
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
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
@ -242,17 +242,17 @@
|
|||||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
widgets_for_click
|
widgets_for_click
|
||||||
.status_label
|
.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();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
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 _ = tx.send(result);
|
||||||
});
|
});
|
||||||
let widgets = widgets_for_click.clone();
|
let widgets = widgets_for_click.clone();
|
||||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
widgets.status_label.set_text(
|
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
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,9 +38,18 @@
|
|||||||
let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
recovery_buttons.set_hexpand(true);
|
recovery_buttons.set_hexpand(true);
|
||||||
recovery_buttons.set_homogeneous(true);
|
recovery_buttons.set_homogeneous(true);
|
||||||
let usb_recover_button = rail_button("USB", "Re-enumerate remote USB gadget.");
|
let usb_recover_button = rail_button(
|
||||||
let uac_recover_button = rail_button("UAC", "Rebuild remote USB audio function.");
|
"USB",
|
||||||
let uvc_recover_button = rail_button("UVC", "Rebuild remote USB webcam function.");
|
"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(&usb_recover_button);
|
||||||
recovery_buttons.append(&uac_recover_button);
|
recovery_buttons.append(&uac_recover_button);
|
||||||
recovery_buttons.append(&uvc_recover_button);
|
recovery_buttons.append(&uvc_recover_button);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.16.7"
|
version = "0.16.8"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -114,6 +114,9 @@ service Relay {
|
|||||||
rpc StreamCamera (stream VideoPacket) returns (stream Empty);
|
rpc StreamCamera (stream VideoPacket) returns (stream Empty);
|
||||||
|
|
||||||
rpc PasteText (PasteRequest) returns (PasteReply);
|
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 ResetUsb (Empty) returns (ResetUsbReply);
|
||||||
rpc GetCapturePower (Empty) returns (CapturePowerState);
|
rpc GetCapturePower (Empty) returns (CapturePowerState);
|
||||||
rpc SetCapturePower (SetCapturePowerRequest) 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_FPS` | eye preview/video transport override |
|
||||||
| `LESAVKA_BREAKOUT_REQUEST_HEIGHT` | eye preview/video transport override |
|
| `LESAVKA_BREAKOUT_REQUEST_HEIGHT` | eye preview/video transport override |
|
||||||
| `LESAVKA_BREAKOUT_REQUEST_WIDTH` | 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_BY_ID_DIR` | client media capture/playback override |
|
||||||
| `LESAVKA_CAM_CODEC` | client media capture/playback override |
|
| `LESAVKA_CAM_CODEC` | client media capture/playback override |
|
||||||
| `LESAVKA_CAM_DEV_ROOT` | client media capture/playback override |
|
| `LESAVKA_CAM_DEV_ROOT` | client media capture/playback override |
|
||||||
|
|||||||
@ -37,8 +37,8 @@
|
|||||||
},
|
},
|
||||||
"client/src/bin/lesavka-relayctl.rs": {
|
"client/src/bin/lesavka-relayctl.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 6,
|
"doc_debt": 4,
|
||||||
"loc": 304
|
"loc": 363
|
||||||
},
|
},
|
||||||
"client/src/bin/lesavka-sync-analyze.rs": {
|
"client/src/bin/lesavka-sync-analyze.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -58,7 +58,7 @@
|
|||||||
"client/src/input/camera.rs": {
|
"client/src/input/camera.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 63
|
"loc": 111
|
||||||
},
|
},
|
||||||
"client/src/input/camera/bus_and_encoder.rs": {
|
"client/src/input/camera/bus_and_encoder.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -68,7 +68,7 @@
|
|||||||
"client/src/input/camera/capture_pipeline.rs": {
|
"client/src/input/camera/capture_pipeline.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 4,
|
"doc_debt": 4,
|
||||||
"loc": 295
|
"loc": 320
|
||||||
},
|
},
|
||||||
"client/src/input/camera/device_selection.rs": {
|
"client/src/input/camera/device_selection.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -228,12 +228,12 @@
|
|||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 5,
|
"doc_debt": 5,
|
||||||
"loc": 246
|
"loc": 240
|
||||||
},
|
},
|
||||||
"client/src/launcher/power.rs": {
|
"client/src/launcher/power.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 86
|
"loc": 120
|
||||||
},
|
},
|
||||||
"client/src/launcher/preview.rs": {
|
"client/src/launcher/preview.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -293,7 +293,7 @@
|
|||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 194
|
"loc": 197
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui/activation_context.rs": {
|
"client/src/launcher/ui/activation_context.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -408,7 +408,7 @@
|
|||||||
"client/src/launcher/ui_components/build_operations_rail.rs": {
|
"client/src/launcher/ui_components/build_operations_rail.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 323
|
"loc": 332
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/build_shell.rs": {
|
"client/src/launcher/ui_components/build_shell.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -506,9 +506,9 @@
|
|||||||
"loc": 429
|
"loc": 429
|
||||||
},
|
},
|
||||||
"client/src/live_media_control.rs": {
|
"client/src/live_media_control.rs": {
|
||||||
"loc": 200,
|
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3
|
"doc_debt": 3,
|
||||||
|
"loc": 200
|
||||||
},
|
},
|
||||||
"client/src/main.rs": {
|
"client/src/main.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -703,7 +703,7 @@
|
|||||||
"server/src/audio/ear_capture.rs": {
|
"server/src/audio/ear_capture.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 5,
|
"doc_debt": 5,
|
||||||
"loc": 460
|
"loc": 452
|
||||||
},
|
},
|
||||||
"server/src/audio/voice_input.rs": {
|
"server/src/audio/voice_input.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -758,7 +758,7 @@
|
|||||||
"server/src/camera_runtime.rs": {
|
"server/src/camera_runtime.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 211
|
"loc": 230
|
||||||
},
|
},
|
||||||
"server/src/capture_power.rs": {
|
"server/src/capture_power.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -813,7 +813,7 @@
|
|||||||
"server/src/main.rs": {
|
"server/src/main.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 100
|
"loc": 101
|
||||||
},
|
},
|
||||||
"server/src/main/entrypoint.rs": {
|
"server/src/main/entrypoint.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -843,7 +843,7 @@
|
|||||||
"server/src/main/relay_service_coverage.rs": {
|
"server/src/main/relay_service_coverage.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 5,
|
"doc_debt": 5,
|
||||||
"loc": 301
|
"loc": 322
|
||||||
},
|
},
|
||||||
"server/src/main/relay_service_tests.rs": {
|
"server/src/main/relay_service_tests.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -858,7 +858,7 @@
|
|||||||
"server/src/main/rpc_helpers.rs": {
|
"server/src/main/rpc_helpers.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 118
|
"loc": 167
|
||||||
},
|
},
|
||||||
"server/src/main/usb_recovery_helpers.rs": {
|
"server/src/main/usb_recovery_helpers.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -913,7 +913,7 @@
|
|||||||
"server/src/upstream_media_runtime/lease_lifecycle.rs": {
|
"server/src/upstream_media_runtime/lease_lifecycle.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 142
|
"loc": 153
|
||||||
},
|
},
|
||||||
"server/src/upstream_media_runtime/state.rs": {
|
"server/src/upstream_media_runtime/state.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
|
|||||||
@ -457,6 +457,15 @@ if [[ -z $DISABLE_UAC ]]; then
|
|||||||
echo adaptive >"$U/c_sync" 2>/dev/null || true
|
echo adaptive >"$U/c_sync" 2>/dev/null || true
|
||||||
# Optional: allocate a few extra request buffers
|
# Optional: allocate a few extra request buffers
|
||||||
echo 32 >"$U/req_number" 2>/dev/null || true
|
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
|
else
|
||||||
log "🔇 UAC2 disabled (LESAVKA_DISABLE_UAC set)"
|
log "🔇 UAC2 disabled (LESAVKA_DISABLE_UAC set)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.16.7"
|
version = "0.16.8"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -116,6 +116,25 @@ impl CameraRuntime {
|
|||||||
self.generation.load(Ordering::Relaxed) == session_id
|
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)]
|
#[allow(clippy::result_large_err)]
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn make_relay(&self, cfg: &camera::CameraConfig) -> Result<Arc<video::CameraRelay>, Status> {
|
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 VideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
|
||||||
type AudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, 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 {
|
fn hid_endpoint(index: u8) -> String {
|
||||||
std::env::var("LESAVKA_HID_DIR")
|
std::env::var("LESAVKA_HID_DIR")
|
||||||
|
|||||||
@ -460,10 +460,10 @@ impl Relay for Handler {
|
|||||||
self.paste_text_reply(req).await
|
self.paste_text_reply(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/*────────────── USB-reset RPC ────────────*/
|
async fn recover_usb(&self, _req: Request<Empty>) -> ResetReply { self.recover_usb_reply().await }
|
||||||
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> {
|
async fn recover_uac(&self, _req: Request<Empty>) -> ResetReply { self.recover_uac_reply().await }
|
||||||
self.reset_usb_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(
|
async fn get_capture_power(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@ -271,6 +271,27 @@ impl Relay for Handler {
|
|||||||
self.reset_usb_reply().await
|
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(
|
async fn get_capture_power(
|
||||||
&self,
|
&self,
|
||||||
_req: Request<Empty>,
|
_req: Request<Empty>,
|
||||||
|
|||||||
@ -1,4 +1,53 @@
|
|||||||
impl Handler {
|
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(
|
async fn paste_text_reply(
|
||||||
&self,
|
&self,
|
||||||
req: Request<PasteRequest>,
|
req: Request<PasteRequest>,
|
||||||
|
|||||||
@ -105,6 +105,17 @@ impl UpstreamMediaRuntime {
|
|||||||
self.close(UpstreamMediaKind::Microphone, generation);
|
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) {
|
fn close(&self, kind: UpstreamMediaKind, generation: u64) {
|
||||||
let mut state = self
|
let mut state = self
|
||||||
.state
|
.state
|
||||||
|
|||||||
@ -189,3 +189,37 @@ fn close_ignores_superseded_generation_values() {
|
|||||||
let next = runtime.activate_camera();
|
let next = runtime.activate_camera();
|
||||||
assert_eq!(next.session_id, 2);
|
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("pub fn set_camera_quality"));
|
||||||
assert!(DEVICE_TEST_SRC.contains("build_camera_preview_pipeline(&device, mode)"));
|
assert!(DEVICE_TEST_SRC.contains("build_camera_preview_pipeline(&device, mode)"));
|
||||||
assert!(DEVICE_TEST_SRC.contains("capsfilter caps=\\\"video/x-raw"));
|
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_WIDTH\""));
|
||||||
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\""));
|
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("recording saved to"));
|
||||||
assert!(UI_SRC.contains("press Stop to finish."));
|
assert!(UI_SRC.contains("press Stop to finish."));
|
||||||
assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked"));
|
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_soft(&server_addr)"));
|
||||||
assert!(UI_SRC.contains("Recover USB 2/3: relay acknowledged reset."));
|
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]
|
#[test]
|
||||||
|
|||||||
@ -22,10 +22,24 @@ mod relayctl_binary {
|
|||||||
assert_eq!(CommandKind::parse("reset-usb"), Some(CommandKind::ResetUsb));
|
assert_eq!(CommandKind::parse("reset-usb"), Some(CommandKind::ResetUsb));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
CommandKind::parse("recover-usb"),
|
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)
|
Some(CommandKind::ResetUsb)
|
||||||
);
|
);
|
||||||
assert_eq!(CommandKind::parse("bad"), None);
|
assert_eq!(CommandKind::parse("bad"), None);
|
||||||
assert!(usage().contains("lesavka-relayctl"));
|
assert!(usage().contains("lesavka-relayctl"));
|
||||||
|
assert!(usage().contains("recover-uac"));
|
||||||
|
assert!(usage().contains("recover-uvc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[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"
|
"LESAVKA_ALLOW_GADGET_RESET should permit recovery without causing unconditional hard rebuilds"
|
||||||
);
|
);
|
||||||
assert!(
|
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"
|
"LESAVKA_ALLOW_GADGET_RESET must not itself trigger a hard UDC detach/rebind"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user