release: ship lesavka 0.16.0
This commit is contained in:
parent
0e3da31b29
commit
9ec915f91c
139
Cargo.lock
generated
139
Cargo.lock
generated
@ -482,6 +482,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@ -495,7 +505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@ -508,7 +518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation",
|
"core-foundation 0.9.4",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1642,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.15.5"
|
version = "0.16.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1676,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.15.5"
|
version = "0.16.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1688,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.15.5"
|
version = "0.16.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -2221,6 +2231,12 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-operations"
|
name = "option-operations"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@ -2574,6 +2590,20 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -2615,6 +2645,53 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@ -2639,6 +2716,15 @@ dependencies = [
|
|||||||
"sdd",
|
"sdd",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scoped-tls"
|
name = "scoped-tls"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@ -2670,6 +2756,29 @@ version = "3.0.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
@ -3088,6 +3197,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@ -3218,8 +3337,10 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"prost",
|
"prost",
|
||||||
|
"rustls-native-certs",
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
@ -3416,6 +3537,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "v4l"
|
name = "v4l"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@ -4038,7 +4165,7 @@ dependencies = [
|
|||||||
"calloop",
|
"calloop",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"core-foundation",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"cursor-icon",
|
"cursor-icon",
|
||||||
"dpi",
|
"dpi",
|
||||||
|
|||||||
@ -4,12 +4,12 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.15.5"
|
version = "0.16.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.45", features = ["full", "fs", "rt-multi-thread", "macros", "sync", "time"] }
|
tokio = { version = "1.45", features = ["full", "fs", "rt-multi-thread", "macros", "sync", "time"] }
|
||||||
tonic = { version = "0.13", features = ["transport"] }
|
tonic = { version = "0.13", features = ["transport", "tls-ring", "tls-native-roots"] }
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
lesavka_common = { path = "../common" }
|
lesavka_common = { path = "../common" }
|
||||||
|
|||||||
@ -26,7 +26,7 @@ use lesavka_common::lesavka::{
|
|||||||
use crate::output::video::{MonitorWindow, UnifiedMonitorWindow};
|
use crate::output::video::{MonitorWindow, UnifiedMonitorWindow};
|
||||||
use crate::{
|
use crate::{
|
||||||
app_support, handshake, input::camera::CameraCapture, input::inputs::InputAggregator,
|
app_support, handshake, input::camera::CameraCapture, input::inputs::InputAggregator,
|
||||||
input::microphone::MicrophoneCapture, output::audio::AudioOut, paste,
|
input::microphone::MicrophoneCapture, output::audio::AudioOut, paste, relay_transport,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct LesavkaClientApp {
|
pub struct LesavkaClientApp {
|
||||||
|
|||||||
@ -35,6 +35,50 @@ fn is_recoverable_remote_audio_error(message: &str) -> bool {
|
|||||||
|| message.contains("remote speaker capture cadence is too low")
|
|| message.contains("remote speaker capture cadence is too low")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct AudioFailureLogLimiter {
|
||||||
|
last_warn_at: Option<Instant>,
|
||||||
|
suppressed_repeats: u64,
|
||||||
|
last_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
/// Rate-limit repeated remote audio failures so operators see state changes, not log floods.
|
||||||
|
impl AudioFailureLogLimiter {
|
||||||
|
/// Emit the first failure promptly, then aggregate identical repeats.
|
||||||
|
fn record(&mut self, context: &'static str, message: &str) {
|
||||||
|
let same_message = self.last_message == message;
|
||||||
|
let should_warn = !same_message
|
||||||
|
|| self
|
||||||
|
.last_warn_at
|
||||||
|
.map(|last| last.elapsed() >= AUDIO_FAILURE_WARN_INTERVAL)
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if should_warn {
|
||||||
|
tracing::warn!(
|
||||||
|
context,
|
||||||
|
suppressed_repeats = self.suppressed_repeats,
|
||||||
|
"❌🔊 audio stream unhealthy: {message}"
|
||||||
|
);
|
||||||
|
self.last_warn_at = Some(Instant::now());
|
||||||
|
self.suppressed_repeats = 0;
|
||||||
|
self.last_message.clear();
|
||||||
|
self.last_message.push_str(message);
|
||||||
|
} else {
|
||||||
|
self.suppressed_repeats = self.suppressed_repeats.saturating_add(1);
|
||||||
|
tracing::debug!(
|
||||||
|
context,
|
||||||
|
suppressed_repeats = self.suppressed_repeats,
|
||||||
|
"audio stream repeated unhealthy state suppressed from WARN noise: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
const AUDIO_FAILURE_WARN_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
pub(crate) fn keyboard_stream_report(
|
pub(crate) fn keyboard_stream_report(
|
||||||
report: Result<KeyboardReport, BroadcastStreamRecvError>,
|
report: Result<KeyboardReport, BroadcastStreamRecvError>,
|
||||||
remote_capture_enabled: bool,
|
remote_capture_enabled: bool,
|
||||||
|
|||||||
@ -72,6 +72,7 @@ impl LesavkaClientApp {
|
|||||||
let mut consecutive_source_failures = 0_u32;
|
let mut consecutive_source_failures = 0_u32;
|
||||||
let mut last_usb_recovery_at: Option<Instant> = None;
|
let mut last_usb_recovery_at: Option<Instant> = None;
|
||||||
let mut delay = Duration::from_secs(1);
|
let mut delay = Duration::from_secs(1);
|
||||||
|
let mut audio_failure_log = AudioFailureLogLimiter::default();
|
||||||
loop {
|
loop {
|
||||||
let mut cli = RelayClient::new(ep.clone());
|
let mut cli = RelayClient::new(ep.clone());
|
||||||
let req = MonitorRequest {
|
let req = MonitorRequest {
|
||||||
@ -117,7 +118,7 @@ impl LesavkaClientApp {
|
|||||||
}
|
}
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
let message = err.to_string();
|
let message = err.to_string();
|
||||||
tracing::warn!("❌🔊 audio stream recv error: {message}");
|
audio_failure_log.record("recv", &message);
|
||||||
Self::maybe_recover_audio_usb(
|
Self::maybe_recover_audio_usb(
|
||||||
&ep,
|
&ep,
|
||||||
&mut consecutive_source_failures,
|
&mut consecutive_source_failures,
|
||||||
@ -141,7 +142,7 @@ impl LesavkaClientApp {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let message = e.to_string();
|
let message = e.to_string();
|
||||||
tracing::warn!("❌🔊 audio stream err: {message}");
|
audio_failure_log.record("connect", &message);
|
||||||
Self::maybe_recover_audio_usb(
|
Self::maybe_recover_audio_usb(
|
||||||
&ep,
|
&ep,
|
||||||
&mut consecutive_source_failures,
|
&mut consecutive_source_failures,
|
||||||
@ -170,7 +171,7 @@ impl LesavkaClientApp {
|
|||||||
*consecutive_source_failures = consecutive_source_failures.saturating_add(1);
|
*consecutive_source_failures = consecutive_source_failures.saturating_add(1);
|
||||||
let threshold = audio_usb_recover_after();
|
let threshold = audio_usb_recover_after();
|
||||||
if *consecutive_source_failures < threshold {
|
if *consecutive_source_failures < threshold {
|
||||||
tracing::warn!(
|
tracing::debug!(
|
||||||
failures = *consecutive_source_failures,
|
failures = *consecutive_source_failures,
|
||||||
threshold,
|
threshold,
|
||||||
"🔊🛟 remote speaker capture is unhealthy; waiting before USB recovery"
|
"🔊🛟 remote speaker capture is unhealthy; waiting before USB recovery"
|
||||||
|
|||||||
@ -34,6 +34,7 @@ impl LesavkaClientApp {
|
|||||||
/*──────────────── keyboard stream ───────────────*/
|
/*──────────────── keyboard stream ───────────────*/
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
async fn stream_loop_keyboard(&self, ep: Channel) {
|
async fn stream_loop_keyboard(&self, ep: Channel) {
|
||||||
|
let mut delay = INPUT_RECONNECT_BASE_DELAY;
|
||||||
loop {
|
loop {
|
||||||
info!("⌨️🤙 Keyboard dial {}", self.server_addr);
|
info!("⌨️🤙 Keyboard dial {}", self.server_addr);
|
||||||
let mut cli = RelayClient::new(ep.clone());
|
let mut cli = RelayClient::new(ep.clone());
|
||||||
@ -52,6 +53,7 @@ impl LesavkaClientApp {
|
|||||||
|
|
||||||
match cli.stream_keyboard(Request::new(outbound)).await {
|
match cli.stream_keyboard(Request::new(outbound)).await {
|
||||||
Ok(mut resp) => {
|
Ok(mut resp) => {
|
||||||
|
delay = INPUT_RECONNECT_BASE_DELAY;
|
||||||
while let Some(msg) = resp.get_mut().message().await.transpose() {
|
while let Some(msg) = resp.get_mut().message().await.transpose() {
|
||||||
if let Err(e) = msg {
|
if let Err(e) = msg {
|
||||||
warn!("⌨️ server err: {e}");
|
warn!("⌨️ server err: {e}");
|
||||||
@ -59,15 +61,19 @@ impl LesavkaClientApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => warn!("❌⌨️ connect failed: {e}"),
|
Err(e) => {
|
||||||
|
warn!("❌⌨️ connect failed: {e}");
|
||||||
|
delay = app_support::next_delay(delay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await; // retry
|
tokio::time::sleep(delay).await; // retry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*──────────────── mouse stream ──────────────────*/
|
/*──────────────── mouse stream ──────────────────*/
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
async fn stream_loop_mouse(&self, ep: Channel) {
|
async fn stream_loop_mouse(&self, ep: Channel) {
|
||||||
|
let mut delay = INPUT_RECONNECT_BASE_DELAY;
|
||||||
loop {
|
loop {
|
||||||
info!("🖱️🤙 Mouse dial {}", self.server_addr);
|
info!("🖱️🤙 Mouse dial {}", self.server_addr);
|
||||||
let mut cli = RelayClient::new(ep.clone());
|
let mut cli = RelayClient::new(ep.clone());
|
||||||
@ -86,6 +92,7 @@ impl LesavkaClientApp {
|
|||||||
|
|
||||||
match cli.stream_mouse(Request::new(outbound)).await {
|
match cli.stream_mouse(Request::new(outbound)).await {
|
||||||
Ok(mut resp) => {
|
Ok(mut resp) => {
|
||||||
|
delay = INPUT_RECONNECT_BASE_DELAY;
|
||||||
while let Some(msg) = resp.get_mut().message().await.transpose() {
|
while let Some(msg) = resp.get_mut().message().await.transpose() {
|
||||||
if let Err(e) = msg {
|
if let Err(e) = msg {
|
||||||
warn!("🖱️ server err: {e}");
|
warn!("🖱️ server err: {e}");
|
||||||
@ -93,10 +100,16 @@ impl LesavkaClientApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => warn!("❌🖱️ connect failed: {e}"),
|
Err(e) => {
|
||||||
|
warn!("❌🖱️ connect failed: {e}");
|
||||||
|
delay = app_support::next_delay(delay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
const INPUT_RECONNECT_BASE_DELAY: Duration = Duration::from_millis(250);
|
||||||
|
|||||||
@ -64,13 +64,13 @@ impl LesavkaClientApp {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/*────────── persistent gRPC channels ──────────*/
|
/*────────── persistent gRPC channels ──────────*/
|
||||||
let hid_ep = Channel::from_shared(self.server_addr.clone())?
|
let hid_ep = relay_transport::endpoint(&self.server_addr)?
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.concurrency_limit(4)
|
.concurrency_limit(4)
|
||||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||||
.connect_lazy();
|
.connect_lazy();
|
||||||
|
|
||||||
let vid_ep = Channel::from_shared(self.server_addr.clone())?
|
let vid_ep = relay_transport::endpoint(&self.server_addr)?
|
||||||
.initial_connection_window_size(4 << 20)
|
.initial_connection_window_size(4 << 20)
|
||||||
.initial_stream_window_size(4 << 20)
|
.initial_stream_window_size(4 << 20)
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
|
|||||||
@ -13,18 +13,25 @@ impl LesavkaClientApp {
|
|||||||
telemetry.record_reconnect_attempt();
|
telemetry.record_reconnect_attempt();
|
||||||
let mut cli = RelayClient::new(ep.clone());
|
let mut cli = RelayClient::new(ep.clone());
|
||||||
let queue = crate::uplink_fresh_queue::FreshPacketQueue::new(AUDIO_UPLINK_QUEUE);
|
let queue = crate::uplink_fresh_queue::FreshPacketQueue::new(AUDIO_UPLINK_QUEUE);
|
||||||
|
let drop_log = Arc::new(std::sync::Mutex::new(UplinkDropLogLimiter::new(
|
||||||
|
"microphone",
|
||||||
|
"🎤",
|
||||||
|
)));
|
||||||
|
|
||||||
let queue_stream = queue.clone();
|
let queue_stream = queue.clone();
|
||||||
let telemetry_stream = telemetry.clone();
|
let telemetry_stream = telemetry.clone();
|
||||||
|
let drop_log_stream = Arc::clone(&drop_log);
|
||||||
let outbound = async_stream::stream! {
|
let outbound = async_stream::stream! {
|
||||||
loop {
|
loop {
|
||||||
let next = queue_stream.pop_fresh().await;
|
let next = queue_stream.pop_fresh().await;
|
||||||
if next.dropped_stale > 0 {
|
if next.dropped_stale > 0 {
|
||||||
telemetry_stream.record_stale_drop(next.dropped_stale);
|
telemetry_stream.record_stale_drop(next.dropped_stale);
|
||||||
warn!(
|
log_uplink_drop(
|
||||||
dropped_stale = next.dropped_stale,
|
&drop_log_stream,
|
||||||
queue_depth = next.queue_depth,
|
UplinkDropReason::Stale,
|
||||||
"🎤 upstream microphone queue dropped stale packets"
|
next.dropped_stale,
|
||||||
|
next.queue_depth,
|
||||||
|
duration_ms(next.delivery_age),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(packet) = next.packet {
|
if let Some(packet) = next.packet {
|
||||||
@ -45,6 +52,7 @@ impl LesavkaClientApp {
|
|||||||
let mic_clone = mic.clone();
|
let mic_clone = mic.clone();
|
||||||
let telemetry_thread = telemetry.clone();
|
let telemetry_thread = telemetry.clone();
|
||||||
let queue_thread = queue.clone();
|
let queue_thread = queue.clone();
|
||||||
|
let drop_log_thread = Arc::clone(&drop_log);
|
||||||
let mic_worker = std::thread::spawn(move || {
|
let mic_worker = std::thread::spawn(move || {
|
||||||
while stop_rx.try_recv().is_err() {
|
while stop_rx.try_recv().is_err() {
|
||||||
if let Some(pkt) = mic_clone.pull() {
|
if let Some(pkt) = mic_clone.pull() {
|
||||||
@ -53,11 +61,12 @@ impl LesavkaClientApp {
|
|||||||
let stats = queue_thread.push(pkt, enqueue_age);
|
let stats = queue_thread.push(pkt, enqueue_age);
|
||||||
if stats.dropped_queue_full > 0 {
|
if stats.dropped_queue_full > 0 {
|
||||||
telemetry_thread.record_queue_full_drop(stats.dropped_queue_full);
|
telemetry_thread.record_queue_full_drop(stats.dropped_queue_full);
|
||||||
warn!(
|
log_uplink_drop(
|
||||||
dropped_queue_full = stats.dropped_queue_full,
|
&drop_log_thread,
|
||||||
queue_depth = stats.queue_depth,
|
UplinkDropReason::QueueFull,
|
||||||
enqueue_age_ms = duration_ms(enqueue_age),
|
stats.dropped_queue_full,
|
||||||
"🎤 upstream microphone queue dropped the oldest packet because it was full"
|
stats.queue_depth,
|
||||||
|
duration_ms(enqueue_age),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
telemetry_thread.record_enqueue(
|
telemetry_thread.record_enqueue(
|
||||||
@ -106,18 +115,23 @@ impl LesavkaClientApp {
|
|||||||
telemetry.record_reconnect_attempt();
|
telemetry.record_reconnect_attempt();
|
||||||
let mut cli = RelayClient::new(ep.clone());
|
let mut cli = RelayClient::new(ep.clone());
|
||||||
let queue = crate::uplink_fresh_queue::FreshPacketQueue::new(VIDEO_UPLINK_QUEUE);
|
let queue = crate::uplink_fresh_queue::FreshPacketQueue::new(VIDEO_UPLINK_QUEUE);
|
||||||
|
let drop_log =
|
||||||
|
Arc::new(std::sync::Mutex::new(UplinkDropLogLimiter::new("camera", "📸")));
|
||||||
|
|
||||||
let queue_stream = queue.clone();
|
let queue_stream = queue.clone();
|
||||||
let telemetry_stream = telemetry.clone();
|
let telemetry_stream = telemetry.clone();
|
||||||
|
let drop_log_stream = Arc::clone(&drop_log);
|
||||||
let outbound = async_stream::stream! {
|
let outbound = async_stream::stream! {
|
||||||
loop {
|
loop {
|
||||||
let next = queue_stream.pop_fresh().await;
|
let next = queue_stream.pop_fresh().await;
|
||||||
if next.dropped_stale > 0 {
|
if next.dropped_stale > 0 {
|
||||||
telemetry_stream.record_stale_drop(next.dropped_stale);
|
telemetry_stream.record_stale_drop(next.dropped_stale);
|
||||||
warn!(
|
log_uplink_drop(
|
||||||
dropped_stale = next.dropped_stale,
|
&drop_log_stream,
|
||||||
queue_depth = next.queue_depth,
|
UplinkDropReason::Stale,
|
||||||
"📸 upstream camera queue dropped stale packets"
|
next.dropped_stale,
|
||||||
|
next.queue_depth,
|
||||||
|
duration_ms(next.delivery_age),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(packet) = next.packet {
|
if let Some(packet) = next.packet {
|
||||||
@ -139,6 +153,7 @@ impl LesavkaClientApp {
|
|||||||
let cam = cam.clone();
|
let cam = cam.clone();
|
||||||
let telemetry = telemetry.clone();
|
let telemetry = telemetry.clone();
|
||||||
let queue = queue.clone();
|
let queue = queue.clone();
|
||||||
|
let drop_log = Arc::clone(&drop_log);
|
||||||
move || loop {
|
move || loop {
|
||||||
if stop_rx.try_recv().is_ok() {
|
if stop_rx.try_recv().is_ok() {
|
||||||
break;
|
break;
|
||||||
@ -158,11 +173,12 @@ impl LesavkaClientApp {
|
|||||||
let stats = queue.push(pkt, enqueue_age);
|
let stats = queue.push(pkt, enqueue_age);
|
||||||
if stats.dropped_queue_full > 0 {
|
if stats.dropped_queue_full > 0 {
|
||||||
telemetry.record_queue_full_drop(stats.dropped_queue_full);
|
telemetry.record_queue_full_drop(stats.dropped_queue_full);
|
||||||
warn!(
|
log_uplink_drop(
|
||||||
dropped_queue_full = stats.dropped_queue_full,
|
&drop_log,
|
||||||
queue_depth = stats.queue_depth,
|
UplinkDropReason::QueueFull,
|
||||||
enqueue_age_ms = duration_ms(enqueue_age),
|
stats.dropped_queue_full,
|
||||||
"📸 upstream camera queue dropped the oldest frame because it was full"
|
stats.queue_depth,
|
||||||
|
duration_ms(enqueue_age),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
telemetry.record_enqueue(
|
telemetry.record_enqueue(
|
||||||
@ -222,3 +238,92 @@ fn queue_depth_u32(depth: usize) -> u32 {
|
|||||||
fn duration_ms(duration: Duration) -> f32 {
|
fn duration_ms(duration: Duration) -> f32 {
|
||||||
duration.as_secs_f32() * 1_000.0
|
duration.as_secs_f32() * 1_000.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum UplinkDropReason {
|
||||||
|
QueueFull,
|
||||||
|
Stale,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UplinkDropLogLimiter {
|
||||||
|
stream: &'static str,
|
||||||
|
icon: &'static str,
|
||||||
|
last_warn_at: Option<Instant>,
|
||||||
|
suppressed_full: u64,
|
||||||
|
suppressed_stale: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
/// Aggregate freshness-first upstream drops into periodic warnings per stream.
|
||||||
|
impl UplinkDropLogLimiter {
|
||||||
|
fn new(stream: &'static str, icon: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
stream,
|
||||||
|
icon,
|
||||||
|
last_warn_at: None,
|
||||||
|
suppressed_full: 0,
|
||||||
|
suppressed_stale: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold full-queue and stale-packet drops into one periodic warning.
|
||||||
|
fn record(&mut self, reason: UplinkDropReason, count: u64, queue_depth: usize, age_ms: f32) {
|
||||||
|
match reason {
|
||||||
|
UplinkDropReason::QueueFull => {
|
||||||
|
self.suppressed_full = self.suppressed_full.saturating_add(count)
|
||||||
|
}
|
||||||
|
UplinkDropReason::Stale => {
|
||||||
|
self.suppressed_stale = self.suppressed_stale.saturating_add(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let should_warn = self
|
||||||
|
.last_warn_at
|
||||||
|
.map(|last| last.elapsed() >= UPLINK_DROP_WARN_INTERVAL)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if should_warn {
|
||||||
|
warn!(
|
||||||
|
stream = self.stream,
|
||||||
|
dropped_queue_full = self.suppressed_full,
|
||||||
|
dropped_stale = self.suppressed_stale,
|
||||||
|
queue_depth,
|
||||||
|
latest_age_ms = age_ms,
|
||||||
|
"{} upstream {} queue is dropping stale/superseded packets to preserve live A/V sync",
|
||||||
|
self.icon,
|
||||||
|
self.stream
|
||||||
|
);
|
||||||
|
self.suppressed_full = 0;
|
||||||
|
self.suppressed_stale = 0;
|
||||||
|
self.last_warn_at = Some(Instant::now());
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
stream = self.stream,
|
||||||
|
?reason,
|
||||||
|
count,
|
||||||
|
queue_depth,
|
||||||
|
latest_age_ms = age_ms,
|
||||||
|
"upstream media queue drop suppressed from WARN noise"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
const UPLINK_DROP_WARN_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
/// Report an upstream queue drop through the shared rate limiter.
|
||||||
|
fn log_uplink_drop(
|
||||||
|
limiter: &Arc<std::sync::Mutex<UplinkDropLogLimiter>>,
|
||||||
|
reason: UplinkDropReason,
|
||||||
|
count: u64,
|
||||||
|
queue_depth: usize,
|
||||||
|
age_ms: f32,
|
||||||
|
) {
|
||||||
|
if let Ok(mut limiter) = limiter.lock() {
|
||||||
|
limiter.record(reason, count, queue_depth, age_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use std::time::Duration;
|
|||||||
use crate::handshake::PeerCaps;
|
use crate::handshake::PeerCaps;
|
||||||
use crate::input::camera::{CameraCodec, CameraConfig};
|
use crate::input::camera::{CameraCodec, CameraConfig};
|
||||||
|
|
||||||
pub const DEFAULT_SERVER_ADDR: &str = "http://38.28.125.112:50051";
|
pub const DEFAULT_SERVER_ADDR: &str = "https://38.28.125.112:50051";
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
/// Resolve the server address from `--server`, positional args, env, or default.
|
/// Resolve the server address from `--server`, positional args, env, or default.
|
||||||
|
|||||||
@ -8,6 +8,8 @@ use lesavka_common::lesavka::{
|
|||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
use tonic::transport::Channel;
|
use tonic::transport::Channel;
|
||||||
|
|
||||||
|
use lesavka_client::relay_transport;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum CommandKind {
|
enum CommandKind {
|
||||||
Status,
|
Status,
|
||||||
@ -115,8 +117,7 @@ fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest>
|
|||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||||
let channel = Channel::from_shared(server_addr.to_string())
|
let channel = relay_transport::endpoint(server_addr)?
|
||||||
.context("invalid relay server address")?
|
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.connect()
|
.connect()
|
||||||
.await
|
.await
|
||||||
@ -126,8 +127,7 @@ async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
|||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||||
let channel = Channel::from_shared(server_addr.to_string())
|
let channel = relay_transport::endpoint(server_addr)?
|
||||||
.context("invalid relay server address")?
|
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.connect_lazy();
|
.connect_lazy();
|
||||||
Ok(RelayClient::new(channel))
|
Ok(RelayClient::new(channel))
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
use lesavka_common::lesavka::{self as pb, handshake_client::HandshakeClient};
|
use lesavka_common::lesavka::{self as pb, handshake_client::HandshakeClient};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tonic::{Code, transport::Endpoint};
|
use tonic::Code;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
#[derive(Default, Clone, Debug)]
|
#[derive(Default, Clone, Debug)]
|
||||||
@ -53,7 +53,7 @@ pub async fn negotiate(uri: &str) -> PeerCaps {
|
|||||||
return PeerCaps::default();
|
return PeerCaps::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
let ep = match Endpoint::from_shared(uri.to_owned()) {
|
let ep = match crate::relay_transport::endpoint(uri) {
|
||||||
Ok(ep) => ep
|
Ok(ep) => ep
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||||
@ -97,7 +97,7 @@ pub async fn probe(uri: &str) -> HandshakeProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let ep = match Endpoint::from_shared(uri.to_owned()) {
|
let ep = match crate::relay_transport::endpoint(uri) {
|
||||||
Ok(ep) => ep
|
Ok(ep) => ep
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||||
@ -155,7 +155,7 @@ pub async fn negotiate(uri: &str) -> PeerCaps {
|
|||||||
info!(%uri, "🤝 dial handshake");
|
info!(%uri, "🤝 dial handshake");
|
||||||
|
|
||||||
let Some(hint) = likely_port_typo_hint(uri) else {
|
let Some(hint) = likely_port_typo_hint(uri) else {
|
||||||
let ep = match Endpoint::from_shared(uri.to_owned()) {
|
let ep = match crate::relay_transport::endpoint(uri) {
|
||||||
Ok(ep) => ep
|
Ok(ep) => ep
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||||
@ -270,7 +270,7 @@ pub async fn probe(uri: &str) -> HandshakeProbe {
|
|||||||
|
|
||||||
let Some(hint) = likely_port_typo_hint(uri) else {
|
let Some(hint) = likely_port_typo_hint(uri) else {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let ep = match Endpoint::from_shared(uri.to_owned()) {
|
let ep = match crate::relay_transport::endpoint(uri) {
|
||||||
Ok(ep) => ep
|
Ok(ep) => ep
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||||
|
|||||||
121
client/src/launcher/calibration.rs
Normal file
121
client/src/launcher/calibration.rs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use lesavka_common::lesavka::{
|
||||||
|
CalibrationAction, CalibrationRequest, Empty, relay_client::RelayClient,
|
||||||
|
};
|
||||||
|
use tonic::{Request, transport::Channel};
|
||||||
|
|
||||||
|
use super::state::CalibrationStatus;
|
||||||
|
use crate::relay_transport;
|
||||||
|
|
||||||
|
pub fn fetch_calibration(server_addr: &str) -> Result<CalibrationStatus> {
|
||||||
|
with_runtime(async move {
|
||||||
|
let mut client = connect(server_addr).await?;
|
||||||
|
let reply = client
|
||||||
|
.get_calibration(Request::new(Empty {}))
|
||||||
|
.await
|
||||||
|
.context("querying upstream A/V calibration")?
|
||||||
|
.into_inner();
|
||||||
|
Ok(CalibrationStatus::from_proto(reply))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_default_calibration(server_addr: &str) -> Result<CalibrationStatus> {
|
||||||
|
send_calibration_request(
|
||||||
|
server_addr,
|
||||||
|
CalibrationAction::RestoreDefault,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
"restore saved default upstream A/V calibration",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_factory_calibration(server_addr: &str) -> Result<CalibrationStatus> {
|
||||||
|
send_calibration_request(
|
||||||
|
server_addr,
|
||||||
|
CalibrationAction::RestoreFactory,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
"restore factory MJPEG upstream A/V calibration",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nudge_audio_calibration(server_addr: &str, delta_us: i64) -> Result<CalibrationStatus> {
|
||||||
|
send_calibration_request(
|
||||||
|
server_addr,
|
||||||
|
CalibrationAction::AdjustActive,
|
||||||
|
delta_us,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
"manual upstream A/V calibration nudge",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blind_calibration_estimate(
|
||||||
|
server_addr: &str,
|
||||||
|
audio_delta_us: i64,
|
||||||
|
delivery_skew_ms: f32,
|
||||||
|
enqueue_skew_ms: f32,
|
||||||
|
note: &str,
|
||||||
|
) -> Result<CalibrationStatus> {
|
||||||
|
send_calibration_request(
|
||||||
|
server_addr,
|
||||||
|
CalibrationAction::BlindEstimate,
|
||||||
|
audio_delta_us,
|
||||||
|
0,
|
||||||
|
delivery_skew_ms,
|
||||||
|
enqueue_skew_ms,
|
||||||
|
note,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_calibration_request(
|
||||||
|
server_addr: &str,
|
||||||
|
action: CalibrationAction,
|
||||||
|
audio_delta_us: i64,
|
||||||
|
video_delta_us: i64,
|
||||||
|
observed_delivery_skew_ms: f32,
|
||||||
|
observed_enqueue_skew_ms: f32,
|
||||||
|
note: &str,
|
||||||
|
) -> Result<CalibrationStatus> {
|
||||||
|
with_runtime(async move {
|
||||||
|
let mut client = connect(server_addr).await?;
|
||||||
|
let reply = client
|
||||||
|
.calibrate(Request::new(CalibrationRequest {
|
||||||
|
action: action as i32,
|
||||||
|
audio_delta_us,
|
||||||
|
video_delta_us,
|
||||||
|
observed_delivery_skew_ms,
|
||||||
|
observed_enqueue_skew_ms,
|
||||||
|
note: note.to_string(),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.context("applying upstream A/V calibration")?
|
||||||
|
.into_inner();
|
||||||
|
Ok(CalibrationStatus::from_proto(reply))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_runtime<F, T>(future: F) -> Result<T>
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = Result<T>>,
|
||||||
|
{
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("building launcher calibration runtime")?
|
||||||
|
.block_on(future)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||||
|
let channel = relay_transport::endpoint(server_addr)?
|
||||||
|
.tcp_nodelay(true)
|
||||||
|
.connect()
|
||||||
|
.await
|
||||||
|
.context("connecting launcher to relay host")?;
|
||||||
|
Ok(RelayClient::new(channel))
|
||||||
|
}
|
||||||
@ -4,11 +4,9 @@ use std::time::Duration;
|
|||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use {
|
use {
|
||||||
crate::paste,
|
crate::paste, crate::relay_transport, async_stream::stream,
|
||||||
async_stream::stream,
|
lesavka_common::lesavka::relay_client::RelayClient, tokio::runtime::Builder as RuntimeBuilder,
|
||||||
lesavka_common::lesavka::relay_client::RelayClient,
|
tonic::Request,
|
||||||
tokio::runtime::Builder as RuntimeBuilder,
|
|
||||||
tonic::{Request, transport::Channel},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
@ -34,12 +32,9 @@ fn send_clipboard_via_rpc(server_addr: &str, text: &str) -> Result<()> {
|
|||||||
let timeout = clipboard_transport_timeout();
|
let timeout = clipboard_transport_timeout();
|
||||||
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
|
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let channel = tokio::time::timeout(
|
let channel = tokio::time::timeout(timeout, relay_transport::connect(server_addr))
|
||||||
timeout,
|
.await
|
||||||
Channel::from_shared(server_addr.to_string())?.connect(),
|
.map_err(|_| anyhow!("timed out connecting paste RPC after {:?}", timeout))??;
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow!("timed out connecting paste RPC after {:?}", timeout))??;
|
|
||||||
let mut cli = RelayClient::new(channel);
|
let mut cli = RelayClient::new(channel);
|
||||||
let reply = tokio::time::timeout(timeout, cli.paste_text(Request::new(req)))
|
let reply = tokio::time::timeout(timeout, cli.paste_text(Request::new(req)))
|
||||||
.await
|
.await
|
||||||
@ -62,7 +57,7 @@ fn send_clipboard_via_hid(server_addr: &str, text: &str) -> Result<()> {
|
|||||||
let timeout = clipboard_transport_timeout();
|
let timeout = clipboard_transport_timeout();
|
||||||
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
|
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let channel = tokio::time::timeout(timeout, Channel::from_shared(server_addr.to_string())?.connect())
|
let channel = tokio::time::timeout(timeout, relay_transport::connect(server_addr))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("timed out connecting keyboard fallback stream after {:?}", timeout))??;
|
.map_err(|_| anyhow!("timed out connecting keyboard fallback stream after {:?}", timeout))??;
|
||||||
let mut cli = RelayClient::new(channel);
|
let mut cli = RelayClient::new(channel);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// Launcher diagnostics snapshots, summaries, and operator recommendations.
|
// Launcher diagnostics snapshots, summaries, and operator recommendations.
|
||||||
include!("diagnostics/diagnostics_models.rs");
|
include!("diagnostics/diagnostics_models.rs");
|
||||||
include!("diagnostics/snapshot_report.rs");
|
include!("diagnostics/snapshot_report.rs");
|
||||||
|
include!("diagnostics/snapshot_report_text.rs");
|
||||||
include!("diagnostics/recommendations.rs");
|
include!("diagnostics/recommendations.rs");
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -163,6 +163,18 @@ pub struct SnapshotReport {
|
|||||||
pub av_delivery_skew_ms: f32,
|
pub av_delivery_skew_ms: f32,
|
||||||
pub av_enqueue_skew_ms: f32,
|
pub av_enqueue_skew_ms: f32,
|
||||||
pub av_sync_health: String,
|
pub av_sync_health: String,
|
||||||
|
pub calibration_available: bool,
|
||||||
|
pub calibration_profile: String,
|
||||||
|
pub calibration_source: String,
|
||||||
|
pub calibration_confidence: String,
|
||||||
|
pub calibration_detail: String,
|
||||||
|
pub calibration_updated_at: String,
|
||||||
|
pub factory_audio_offset_us: i64,
|
||||||
|
pub factory_video_offset_us: i64,
|
||||||
|
pub default_audio_offset_us: i64,
|
||||||
|
pub default_video_offset_us: i64,
|
||||||
|
pub active_audio_offset_us: i64,
|
||||||
|
pub active_video_offset_us: i64,
|
||||||
pub selected_keyboard: Option<String>,
|
pub selected_keyboard: Option<String>,
|
||||||
pub selected_mouse: Option<String>,
|
pub selected_mouse: Option<String>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
|||||||
@ -238,6 +238,18 @@ impl SnapshotReport {
|
|||||||
av_delivery_skew_ms,
|
av_delivery_skew_ms,
|
||||||
av_enqueue_skew_ms,
|
av_enqueue_skew_ms,
|
||||||
av_sync_health,
|
av_sync_health,
|
||||||
|
calibration_available: state.calibration.available,
|
||||||
|
calibration_profile: state.calibration.profile.clone(),
|
||||||
|
calibration_source: state.calibration.source.clone(),
|
||||||
|
calibration_confidence: state.calibration.confidence.clone(),
|
||||||
|
calibration_detail: state.calibration.detail.clone(),
|
||||||
|
calibration_updated_at: state.calibration.updated_at.clone(),
|
||||||
|
factory_audio_offset_us: state.calibration.factory_audio_offset_us,
|
||||||
|
factory_video_offset_us: state.calibration.factory_video_offset_us,
|
||||||
|
default_audio_offset_us: state.calibration.default_audio_offset_us,
|
||||||
|
default_video_offset_us: state.calibration.default_video_offset_us,
|
||||||
|
active_audio_offset_us: state.calibration.active_audio_offset_us,
|
||||||
|
active_video_offset_us: state.calibration.active_video_offset_us,
|
||||||
selected_keyboard: state.devices.keyboard.clone(),
|
selected_keyboard: state.devices.keyboard.clone(),
|
||||||
selected_mouse: state.devices.mouse.clone(),
|
selected_mouse: state.devices.mouse.clone(),
|
||||||
status: state.status_line(),
|
status: state.status_line(),
|
||||||
@ -247,230 +259,7 @@ impl SnapshotReport {
|
|||||||
probe_command,
|
probe_command,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
|
|
||||||
serde_json::to_string_pretty(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_pretty_text(&self) -> String {
|
|
||||||
let mut text = String::new();
|
|
||||||
let server_version = self.server_version.as_deref().unwrap_or("unknown");
|
|
||||||
let server_state = if self.server_available {
|
|
||||||
"reachable"
|
|
||||||
} else {
|
|
||||||
"unreachable"
|
|
||||||
};
|
|
||||||
let _ = writeln!(text, "Lesavka Diagnostics");
|
|
||||||
let _ = writeln!(text, "client: v{}", self.client_version);
|
|
||||||
let _ = writeln!(text, "server: {server_version} ({server_state})");
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
"session: routing={:?} view={:?} relay={} capture_power={}",
|
|
||||||
self.routing,
|
|
||||||
self.view_mode,
|
|
||||||
if self.remote_active { "active" } else { "idle" },
|
|
||||||
self.power_state
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
"runtime: client CPU {:.1}% | server CPU {:.1}%",
|
|
||||||
self.client_process_cpu_pct, self.server_process_cpu_pct
|
|
||||||
);
|
|
||||||
let _ = writeln!(text, "source feed: {}", self.preview_source);
|
|
||||||
let _ = writeln!(text, "display limit: {}", self.client_display_limit);
|
|
||||||
let _ = writeln!(text);
|
|
||||||
let _ = writeln!(text, "left eye");
|
|
||||||
let _ = writeln!(text, " surface: {}", self.left_surface);
|
|
||||||
let _ = writeln!(text, " source: {}", self.left_feed_source);
|
|
||||||
let _ = writeln!(text, " capture: {}", self.left_capture_profile);
|
|
||||||
let _ = writeln!(text, " transport: {}", self.left_capture_transport);
|
|
||||||
let _ = writeln!(text, " breakout: {}", self.left_breakout_profile);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}",
|
|
||||||
self.left_decoder_label,
|
|
||||||
self.left_stream_spread_ms,
|
|
||||||
self.left_packet_gap_peak_ms,
|
|
||||||
self.left_present_gap_peak_ms,
|
|
||||||
self.left_queue_depth,
|
|
||||||
self.left_queue_peak
|
|
||||||
);
|
|
||||||
let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label);
|
|
||||||
let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label);
|
|
||||||
let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
|
|
||||||
self.left_server_encoder_label,
|
|
||||||
self.server_process_cpu_pct,
|
|
||||||
self.left_server_source_gap_peak_ms,
|
|
||||||
self.left_server_send_gap_peak_ms,
|
|
||||||
self.left_server_queue_peak
|
|
||||||
);
|
|
||||||
let _ = writeln!(text, "right eye");
|
|
||||||
let _ = writeln!(text, " surface: {}", self.right_surface);
|
|
||||||
let _ = writeln!(text, " source: {}", self.right_feed_source);
|
|
||||||
let _ = writeln!(text, " capture: {}", self.right_capture_profile);
|
|
||||||
let _ = writeln!(text, " transport: {}", self.right_capture_transport);
|
|
||||||
let _ = writeln!(text, " breakout: {}", self.right_breakout_profile);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}",
|
|
||||||
self.right_decoder_label,
|
|
||||||
self.right_stream_spread_ms,
|
|
||||||
self.right_packet_gap_peak_ms,
|
|
||||||
self.right_present_gap_peak_ms,
|
|
||||||
self.right_queue_depth,
|
|
||||||
self.right_queue_peak
|
|
||||||
);
|
|
||||||
let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label);
|
|
||||||
let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label);
|
|
||||||
let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
|
|
||||||
self.right_server_encoder_label,
|
|
||||||
self.server_process_cpu_pct,
|
|
||||||
self.right_server_source_gap_peak_ms,
|
|
||||||
self.right_server_send_gap_peak_ms,
|
|
||||||
self.right_server_queue_peak
|
|
||||||
);
|
|
||||||
let _ = writeln!(text);
|
|
||||||
let _ = writeln!(text, "media staging");
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" camera: {} | quality={} | enabled={}",
|
|
||||||
self.selected_camera.as_deref().unwrap_or("auto"),
|
|
||||||
self.camera_quality_label,
|
|
||||||
self.media_channels.camera
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" speaker: {} | volume={} | enabled={}",
|
|
||||||
self.selected_speaker.as_deref().unwrap_or("auto"),
|
|
||||||
self.audio_gain_label,
|
|
||||||
self.media_channels.audio
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" microphone: {} | gain={} | enabled={}",
|
|
||||||
self.selected_microphone.as_deref().unwrap_or("auto"),
|
|
||||||
self.mic_gain_label,
|
|
||||||
self.media_channels.microphone
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" uplink camera: {}",
|
|
||||||
uplink_summary(&self.upstream_camera)
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" uplink microphone: {}",
|
|
||||||
uplink_summary(&self.upstream_microphone)
|
|
||||||
);
|
|
||||||
let _ = writeln!(text, "av sync guardrails");
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" health: {} (target <= {:.0}ms skew, preferred <= {:.0}ms)",
|
|
||||||
self.av_sync_health, AV_SYNC_WATCH_MS, AV_SYNC_GOOD_MS
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" delivery skew: {:.1}ms | enqueue skew: {:.1}ms",
|
|
||||||
self.av_delivery_skew_ms, self.av_enqueue_skew_ms
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" camera ages: enqueue={:.1}ms delivery={:.1}ms",
|
|
||||||
self.upstream_camera.latest_enqueue_age_ms, self.upstream_camera.latest_delivery_age_ms
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" microphone ages: enqueue={:.1}ms delivery={:.1}ms",
|
|
||||||
self.upstream_microphone.latest_enqueue_age_ms,
|
|
||||||
self.upstream_microphone.latest_delivery_age_ms
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" keyboard: {}",
|
|
||||||
self.selected_keyboard.as_deref().unwrap_or("all")
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" mouse: {}",
|
|
||||||
self.selected_mouse.as_deref().unwrap_or("all")
|
|
||||||
);
|
|
||||||
let _ = writeln!(text);
|
|
||||||
let _ = writeln!(text, "current UI state");
|
|
||||||
let _ = writeln!(text, " {}", self.status);
|
|
||||||
let _ = writeln!(text);
|
|
||||||
let _ = writeln!(text, "recent samples");
|
|
||||||
if self.recent_samples.is_empty() {
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" no live RTT/probe-spread/loss samples yet; this report is currently a launcher state snapshot."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
for sample in &self.recent_samples {
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" rtt={:.1}ms probe-spread={:.1}ms input-floor={:.1}ms cpu={:.1}/{:.1}% probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}/{} peaks=l{:.0}/{:.0}ms r{:.0}/{:.0}ms server=l{}:{:.0}/{:.0}/{} r{}:{:.0}/{:.0}/{}",
|
|
||||||
sample.rtt_ms,
|
|
||||||
sample.probe_spread_ms,
|
|
||||||
sample.input_latency_ms,
|
|
||||||
sample.client_process_cpu_pct,
|
|
||||||
sample.server_process_cpu_pct,
|
|
||||||
sample.probe_loss_pct,
|
|
||||||
sample.video_loss_pct,
|
|
||||||
sample.left_receive_fps,
|
|
||||||
sample.left_present_fps,
|
|
||||||
sample.left_server_fps,
|
|
||||||
sample.right_receive_fps,
|
|
||||||
sample.right_present_fps,
|
|
||||||
sample.right_server_fps,
|
|
||||||
sample.dropped_frames,
|
|
||||||
sample.queue_depth,
|
|
||||||
sample.left_queue_peak.max(sample.right_queue_peak),
|
|
||||||
sample.left_packet_gap_peak_ms,
|
|
||||||
sample.left_present_gap_peak_ms,
|
|
||||||
sample.right_packet_gap_peak_ms,
|
|
||||||
sample.right_present_gap_peak_ms,
|
|
||||||
sample.left_server_encoder_label,
|
|
||||||
sample.left_server_source_gap_peak_ms,
|
|
||||||
sample.left_server_send_gap_peak_ms,
|
|
||||||
sample.left_server_queue_peak,
|
|
||||||
sample.right_server_encoder_label,
|
|
||||||
sample.right_server_source_gap_peak_ms,
|
|
||||||
sample.right_server_send_gap_peak_ms,
|
|
||||||
sample.right_server_queue_peak
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
text,
|
|
||||||
" uplink: cam={} mic={}",
|
|
||||||
uplink_summary(&sample.upstream_camera),
|
|
||||||
uplink_summary(&sample.upstream_microphone)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = writeln!(text);
|
|
||||||
let _ = writeln!(text, "recommendations");
|
|
||||||
for item in &self.recommendations {
|
|
||||||
let _ = writeln!(text, " - {item}");
|
|
||||||
}
|
|
||||||
if !self.notes.is_empty() {
|
|
||||||
let _ = writeln!(text);
|
|
||||||
let _ = writeln!(text, "notes");
|
|
||||||
for item in &self.notes {
|
|
||||||
let _ = writeln!(text, " - {item}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = writeln!(text);
|
|
||||||
let _ = writeln!(text, "quality probe");
|
|
||||||
let _ = writeln!(text, " {}", self.probe_command);
|
|
||||||
text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AV_SYNC_GOOD_MS: f32 = 35.0;
|
const AV_SYNC_GOOD_MS: f32 = 35.0;
|
||||||
const AV_SYNC_WATCH_MS: f32 = 80.0;
|
const AV_SYNC_WATCH_MS: f32 = 80.0;
|
||||||
|
|
||||||
@ -495,36 +284,3 @@ fn av_sync_health_label(
|
|||||||
"drift risk"
|
"drift risk"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uplink_summary(stream: &crate::uplink_telemetry::UpstreamStreamTelemetry) -> String {
|
|
||||||
if !stream.enabled {
|
|
||||||
return "disabled".to_string();
|
|
||||||
}
|
|
||||||
let connection = if stream.connected {
|
|
||||||
"live"
|
|
||||||
} else if stream.reconnect_count > 0 {
|
|
||||||
"reconnecting"
|
|
||||||
} else {
|
|
||||||
"idle"
|
|
||||||
};
|
|
||||||
let error = if stream.last_error.is_empty() {
|
|
||||||
"ok".to_string()
|
|
||||||
} else {
|
|
||||||
stream.last_error.clone()
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
"{connection} queue={}/{} enq-age={:.0}/{:.0}ms delivery={:.0}/{:.0}ms block-peak={:.0}ms reconnects={} streamed={} drops(total/full/stale)={}/{}/{} error={error}",
|
|
||||||
stream.queue_depth,
|
|
||||||
stream.queue_peak,
|
|
||||||
stream.latest_enqueue_age_ms,
|
|
||||||
stream.enqueue_age_peak_ms,
|
|
||||||
stream.latest_delivery_age_ms,
|
|
||||||
stream.delivery_age_peak_ms,
|
|
||||||
stream.enqueue_block_peak_ms,
|
|
||||||
stream.reconnect_count,
|
|
||||||
stream.packets_streamed,
|
|
||||||
stream.dropped_packets,
|
|
||||||
stream.dropped_queue_full_packets,
|
|
||||||
stream.dropped_stale_packets
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
292
client/src/launcher/diagnostics/snapshot_report_text.rs
Normal file
292
client/src/launcher/diagnostics/snapshot_report_text.rs
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
impl SnapshotReport {
|
||||||
|
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
|
||||||
|
serde_json::to_string_pretty(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_pretty_text(&self) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
let server_version = self.server_version.as_deref().unwrap_or("unknown");
|
||||||
|
let server_state = if self.server_available {
|
||||||
|
"reachable"
|
||||||
|
} else {
|
||||||
|
"unreachable"
|
||||||
|
};
|
||||||
|
let _ = writeln!(text, "Lesavka Diagnostics");
|
||||||
|
let _ = writeln!(text, "client: v{}", self.client_version);
|
||||||
|
let _ = writeln!(text, "server: {server_version} ({server_state})");
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
"session: routing={:?} view={:?} relay={} capture_power={}",
|
||||||
|
self.routing,
|
||||||
|
self.view_mode,
|
||||||
|
if self.remote_active { "active" } else { "idle" },
|
||||||
|
self.power_state
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
"runtime: client CPU {:.1}% | server CPU {:.1}%",
|
||||||
|
self.client_process_cpu_pct, self.server_process_cpu_pct
|
||||||
|
);
|
||||||
|
let _ = writeln!(text, "source feed: {}", self.preview_source);
|
||||||
|
let _ = writeln!(text, "display limit: {}", self.client_display_limit);
|
||||||
|
let _ = writeln!(text);
|
||||||
|
let _ = writeln!(text, "left eye");
|
||||||
|
let _ = writeln!(text, " surface: {}", self.left_surface);
|
||||||
|
let _ = writeln!(text, " source: {}", self.left_feed_source);
|
||||||
|
let _ = writeln!(text, " capture: {}", self.left_capture_profile);
|
||||||
|
let _ = writeln!(text, " transport: {}", self.left_capture_transport);
|
||||||
|
let _ = writeln!(text, " breakout: {}", self.left_breakout_profile);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}",
|
||||||
|
self.left_decoder_label,
|
||||||
|
self.left_stream_spread_ms,
|
||||||
|
self.left_packet_gap_peak_ms,
|
||||||
|
self.left_present_gap_peak_ms,
|
||||||
|
self.left_queue_depth,
|
||||||
|
self.left_queue_peak
|
||||||
|
);
|
||||||
|
let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label);
|
||||||
|
let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label);
|
||||||
|
let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
|
||||||
|
self.left_server_encoder_label,
|
||||||
|
self.server_process_cpu_pct,
|
||||||
|
self.left_server_source_gap_peak_ms,
|
||||||
|
self.left_server_send_gap_peak_ms,
|
||||||
|
self.left_server_queue_peak
|
||||||
|
);
|
||||||
|
let _ = writeln!(text, "right eye");
|
||||||
|
let _ = writeln!(text, " surface: {}", self.right_surface);
|
||||||
|
let _ = writeln!(text, " source: {}", self.right_feed_source);
|
||||||
|
let _ = writeln!(text, " capture: {}", self.right_capture_profile);
|
||||||
|
let _ = writeln!(text, " transport: {}", self.right_capture_transport);
|
||||||
|
let _ = writeln!(text, " breakout: {}", self.right_breakout_profile);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}",
|
||||||
|
self.right_decoder_label,
|
||||||
|
self.right_stream_spread_ms,
|
||||||
|
self.right_packet_gap_peak_ms,
|
||||||
|
self.right_present_gap_peak_ms,
|
||||||
|
self.right_queue_depth,
|
||||||
|
self.right_queue_peak
|
||||||
|
);
|
||||||
|
let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label);
|
||||||
|
let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label);
|
||||||
|
let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
|
||||||
|
self.right_server_encoder_label,
|
||||||
|
self.server_process_cpu_pct,
|
||||||
|
self.right_server_source_gap_peak_ms,
|
||||||
|
self.right_server_send_gap_peak_ms,
|
||||||
|
self.right_server_queue_peak
|
||||||
|
);
|
||||||
|
let _ = writeln!(text);
|
||||||
|
let _ = writeln!(text, "media staging");
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" camera: {} | quality={} | enabled={}",
|
||||||
|
self.selected_camera.as_deref().unwrap_or("auto"),
|
||||||
|
self.camera_quality_label,
|
||||||
|
self.media_channels.camera
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" speaker: {} | volume={} | enabled={}",
|
||||||
|
self.selected_speaker.as_deref().unwrap_or("auto"),
|
||||||
|
self.audio_gain_label,
|
||||||
|
self.media_channels.audio
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" microphone: {} | gain={} | enabled={}",
|
||||||
|
self.selected_microphone.as_deref().unwrap_or("auto"),
|
||||||
|
self.mic_gain_label,
|
||||||
|
self.media_channels.microphone
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" uplink camera: {}",
|
||||||
|
uplink_summary(&self.upstream_camera)
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" uplink microphone: {}",
|
||||||
|
uplink_summary(&self.upstream_microphone)
|
||||||
|
);
|
||||||
|
let _ = writeln!(text, "av sync guardrails");
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" health: {} (target <= {:.0}ms skew, preferred <= {:.0}ms)",
|
||||||
|
self.av_sync_health, AV_SYNC_WATCH_MS, AV_SYNC_GOOD_MS
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" delivery skew: {:.1}ms | enqueue skew: {:.1}ms",
|
||||||
|
self.av_delivery_skew_ms, self.av_enqueue_skew_ms
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" camera ages: enqueue={:.1}ms delivery={:.1}ms",
|
||||||
|
self.upstream_camera.latest_enqueue_age_ms, self.upstream_camera.latest_delivery_age_ms
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" microphone ages: enqueue={:.1}ms delivery={:.1}ms",
|
||||||
|
self.upstream_microphone.latest_enqueue_age_ms,
|
||||||
|
self.upstream_microphone.latest_delivery_age_ms
|
||||||
|
);
|
||||||
|
let _ = writeln!(text, "calibration");
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" profile: {} | status={} | source={} | confidence={}",
|
||||||
|
self.calibration_profile,
|
||||||
|
if self.calibration_available {
|
||||||
|
"available"
|
||||||
|
} else {
|
||||||
|
"unavailable"
|
||||||
|
},
|
||||||
|
self.calibration_source,
|
||||||
|
self.calibration_confidence
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" active: audio={:+.1}ms video={:+.1}ms",
|
||||||
|
self.active_audio_offset_us as f64 / 1000.0,
|
||||||
|
self.active_video_offset_us as f64 / 1000.0
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" default: audio={:+.1}ms video={:+.1}ms | factory: audio={:+.1}ms video={:+.1}ms",
|
||||||
|
self.default_audio_offset_us as f64 / 1000.0,
|
||||||
|
self.default_video_offset_us as f64 / 1000.0,
|
||||||
|
self.factory_audio_offset_us as f64 / 1000.0,
|
||||||
|
self.factory_video_offset_us as f64 / 1000.0
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" updated: {} | detail: {}",
|
||||||
|
if self.calibration_updated_at.is_empty() {
|
||||||
|
"unknown"
|
||||||
|
} else {
|
||||||
|
&self.calibration_updated_at
|
||||||
|
},
|
||||||
|
self.calibration_detail
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" keyboard: {}",
|
||||||
|
self.selected_keyboard.as_deref().unwrap_or("all")
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" mouse: {}",
|
||||||
|
self.selected_mouse.as_deref().unwrap_or("all")
|
||||||
|
);
|
||||||
|
let _ = writeln!(text);
|
||||||
|
let _ = writeln!(text, "current UI state");
|
||||||
|
let _ = writeln!(text, " {}", self.status);
|
||||||
|
let _ = writeln!(text);
|
||||||
|
let _ = writeln!(text, "recent samples");
|
||||||
|
if self.recent_samples.is_empty() {
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" no live RTT/probe-spread/loss samples yet; this report is currently a launcher state snapshot."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for sample in &self.recent_samples {
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" rtt={:.1}ms probe-spread={:.1}ms input-floor={:.1}ms cpu={:.1}/{:.1}% probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}/{} peaks=l{:.0}/{:.0}ms r{:.0}/{:.0}ms server=l{}:{:.0}/{:.0}/{} r{}:{:.0}/{:.0}/{}",
|
||||||
|
sample.rtt_ms,
|
||||||
|
sample.probe_spread_ms,
|
||||||
|
sample.input_latency_ms,
|
||||||
|
sample.client_process_cpu_pct,
|
||||||
|
sample.server_process_cpu_pct,
|
||||||
|
sample.probe_loss_pct,
|
||||||
|
sample.video_loss_pct,
|
||||||
|
sample.left_receive_fps,
|
||||||
|
sample.left_present_fps,
|
||||||
|
sample.left_server_fps,
|
||||||
|
sample.right_receive_fps,
|
||||||
|
sample.right_present_fps,
|
||||||
|
sample.right_server_fps,
|
||||||
|
sample.dropped_frames,
|
||||||
|
sample.queue_depth,
|
||||||
|
sample.left_queue_peak.max(sample.right_queue_peak),
|
||||||
|
sample.left_packet_gap_peak_ms,
|
||||||
|
sample.left_present_gap_peak_ms,
|
||||||
|
sample.right_packet_gap_peak_ms,
|
||||||
|
sample.right_present_gap_peak_ms,
|
||||||
|
sample.left_server_encoder_label,
|
||||||
|
sample.left_server_source_gap_peak_ms,
|
||||||
|
sample.left_server_send_gap_peak_ms,
|
||||||
|
sample.left_server_queue_peak,
|
||||||
|
sample.right_server_encoder_label,
|
||||||
|
sample.right_server_source_gap_peak_ms,
|
||||||
|
sample.right_server_send_gap_peak_ms,
|
||||||
|
sample.right_server_queue_peak
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
" uplink: cam={} mic={}",
|
||||||
|
uplink_summary(&sample.upstream_camera),
|
||||||
|
uplink_summary(&sample.upstream_microphone)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = writeln!(text);
|
||||||
|
let _ = writeln!(text, "recommendations");
|
||||||
|
for item in &self.recommendations {
|
||||||
|
let _ = writeln!(text, " - {item}");
|
||||||
|
}
|
||||||
|
if !self.notes.is_empty() {
|
||||||
|
let _ = writeln!(text);
|
||||||
|
let _ = writeln!(text, "notes");
|
||||||
|
for item in &self.notes {
|
||||||
|
let _ = writeln!(text, " - {item}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = writeln!(text);
|
||||||
|
let _ = writeln!(text, "quality probe");
|
||||||
|
let _ = writeln!(text, " {}", self.probe_command);
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn uplink_summary(stream: &crate::uplink_telemetry::UpstreamStreamTelemetry) -> String {
|
||||||
|
if !stream.enabled {
|
||||||
|
return "disabled".to_string();
|
||||||
|
}
|
||||||
|
let connection = if stream.connected {
|
||||||
|
"live"
|
||||||
|
} else if stream.reconnect_count > 0 {
|
||||||
|
"reconnecting"
|
||||||
|
} else {
|
||||||
|
"idle"
|
||||||
|
};
|
||||||
|
let error = if stream.last_error.is_empty() {
|
||||||
|
"ok".to_string()
|
||||||
|
} else {
|
||||||
|
stream.last_error.clone()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{connection} queue={}/{} enq-age={:.0}/{:.0}ms delivery={:.0}/{:.0}ms block-peak={:.0}ms reconnects={} streamed={} drops(total/full/stale)={}/{}/{} error={error}",
|
||||||
|
stream.queue_depth,
|
||||||
|
stream.queue_peak,
|
||||||
|
stream.latest_enqueue_age_ms,
|
||||||
|
stream.enqueue_age_peak_ms,
|
||||||
|
stream.latest_delivery_age_ms,
|
||||||
|
stream.delivery_age_peak_ms,
|
||||||
|
stream.enqueue_block_peak_ms,
|
||||||
|
stream.reconnect_count,
|
||||||
|
stream.packets_streamed,
|
||||||
|
stream.dropped_packets,
|
||||||
|
stream.dropped_queue_full_packets,
|
||||||
|
stream.dropped_stale_packets
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,6 +2,8 @@ pub mod devices;
|
|||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
mod calibration;
|
||||||
mod clipboard;
|
mod clipboard;
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
mod device_test;
|
mod device_test;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ use lesavka_common::lesavka::{
|
|||||||
use tonic::{Request, transport::Channel};
|
use tonic::{Request, transport::Channel};
|
||||||
|
|
||||||
use super::state::CapturePowerStatus;
|
use super::state::CapturePowerStatus;
|
||||||
|
use crate::relay_transport;
|
||||||
|
|
||||||
pub fn fetch_capture_power(server_addr: &str) -> Result<CapturePowerStatus> {
|
pub fn fetch_capture_power(server_addr: &str) -> Result<CapturePowerStatus> {
|
||||||
with_runtime(async move {
|
with_runtime(async move {
|
||||||
@ -64,8 +65,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||||
let channel = Channel::from_shared(server_addr.to_string())
|
let channel = relay_transport::endpoint(server_addr)?
|
||||||
.context("invalid launcher server address")?
|
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.connect()
|
.connect()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -327,7 +327,7 @@ fn run_preview_feed(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let channel = match Channel::from_shared(current_addr.clone()) {
|
let channel = match crate::relay_transport::endpoint(¤t_addr) {
|
||||||
Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await {
|
Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await {
|
||||||
Ok(channel) => channel,
|
Ok(channel) => channel,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|||||||
@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use tonic::{Request, transport::Channel};
|
use tonic::Request;
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// Launcher state model, selection normalization, and media profile choices.
|
// Launcher state model, selection normalization, and media profile choices.
|
||||||
include!("state/selection_models.rs");
|
include!("state/selection_models.rs");
|
||||||
include!("state/launcher_state_impl.rs");
|
include!("state/launcher_state_impl.rs");
|
||||||
|
include!("state/launcher_status_line.rs");
|
||||||
include!("state/profile_helpers.rs");
|
include!("state/profile_helpers.rs");
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -449,48 +449,8 @@ impl LauncherState {
|
|||||||
self.capture_power = power;
|
self.capture_power = power;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status_line(&self) -> String {
|
pub fn set_calibration(&mut self, calibration: CalibrationStatus) {
|
||||||
format!(
|
self.calibration = calibration;
|
||||||
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
|
|
||||||
self.server_available,
|
|
||||||
match self.routing {
|
|
||||||
InputRouting::Local => "local",
|
|
||||||
InputRouting::Remote => "remote",
|
|
||||||
},
|
|
||||||
match self.view_mode {
|
|
||||||
ViewMode::Unified => "unified",
|
|
||||||
ViewMode::Breakout => "breakout",
|
|
||||||
},
|
|
||||||
self.remote_active,
|
|
||||||
if self.capture_power.enabled {
|
|
||||||
"on"
|
|
||||||
} else {
|
|
||||||
"off"
|
|
||||||
},
|
|
||||||
self.preview_source.width,
|
|
||||||
self.preview_source.height,
|
|
||||||
self.displays[0].label(),
|
|
||||||
self.displays[1].label(),
|
|
||||||
self.feed_source_preset(0).as_id(),
|
|
||||||
self.feed_source_preset(1).as_id(),
|
|
||||||
media_status_label(self.channels.camera, self.devices.camera.as_deref()),
|
|
||||||
self.camera_quality
|
|
||||||
.map(CameraMode::short_label)
|
|
||||||
.unwrap_or_else(|| "default".to_string()),
|
|
||||||
media_status_label(self.channels.microphone, self.devices.microphone.as_deref()),
|
|
||||||
media_status_label(self.channels.audio, self.devices.speaker.as_deref()),
|
|
||||||
self.channels.camera,
|
|
||||||
self.channels.microphone,
|
|
||||||
self.channels.audio,
|
|
||||||
self.server_camera,
|
|
||||||
self.server_microphone,
|
|
||||||
self.server_camera_output,
|
|
||||||
self.server_camera_codec,
|
|
||||||
self.audio_gain_label(),
|
|
||||||
self.mic_gain_label(),
|
|
||||||
self.devices.keyboard.as_deref().unwrap_or("all"),
|
|
||||||
self.devices.mouse.as_deref().unwrap_or("all"),
|
|
||||||
self.swap_key,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
48
client/src/launcher/state/launcher_status_line.rs
Normal file
48
client/src/launcher/state/launcher_status_line.rs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
impl LauncherState {
|
||||||
|
pub fn status_line(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
|
||||||
|
self.server_available,
|
||||||
|
match self.routing {
|
||||||
|
InputRouting::Local => "local",
|
||||||
|
InputRouting::Remote => "remote",
|
||||||
|
},
|
||||||
|
match self.view_mode {
|
||||||
|
ViewMode::Unified => "unified",
|
||||||
|
ViewMode::Breakout => "breakout",
|
||||||
|
},
|
||||||
|
self.remote_active,
|
||||||
|
if self.capture_power.enabled {
|
||||||
|
"on"
|
||||||
|
} else {
|
||||||
|
"off"
|
||||||
|
},
|
||||||
|
self.preview_source.width,
|
||||||
|
self.preview_source.height,
|
||||||
|
self.displays[0].label(),
|
||||||
|
self.displays[1].label(),
|
||||||
|
self.feed_source_preset(0).as_id(),
|
||||||
|
self.feed_source_preset(1).as_id(),
|
||||||
|
media_status_label(self.channels.camera, self.devices.camera.as_deref()),
|
||||||
|
self.camera_quality
|
||||||
|
.map(CameraMode::short_label)
|
||||||
|
.unwrap_or_else(|| "default".to_string()),
|
||||||
|
media_status_label(self.channels.microphone, self.devices.microphone.as_deref()),
|
||||||
|
media_status_label(self.channels.audio, self.devices.speaker.as_deref()),
|
||||||
|
self.channels.camera,
|
||||||
|
self.channels.microphone,
|
||||||
|
self.channels.audio,
|
||||||
|
self.server_camera,
|
||||||
|
self.server_microphone,
|
||||||
|
self.server_camera_output,
|
||||||
|
self.server_camera_codec,
|
||||||
|
self.calibration.source,
|
||||||
|
self.calibration.active_audio_offset_us as f64 / 1000.0,
|
||||||
|
self.audio_gain_label(),
|
||||||
|
self.mic_gain_label(),
|
||||||
|
self.devices.keyboard.as_deref().unwrap_or("all"),
|
||||||
|
self.devices.mouse.as_deref().unwrap_or("all"),
|
||||||
|
self.swap_key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -294,6 +294,72 @@ impl Default for CapturePowerStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CalibrationStatus {
|
||||||
|
pub available: bool,
|
||||||
|
pub profile: String,
|
||||||
|
pub factory_audio_offset_us: i64,
|
||||||
|
pub factory_video_offset_us: i64,
|
||||||
|
pub default_audio_offset_us: i64,
|
||||||
|
pub default_video_offset_us: i64,
|
||||||
|
pub active_audio_offset_us: i64,
|
||||||
|
pub active_video_offset_us: i64,
|
||||||
|
pub source: String,
|
||||||
|
pub confidence: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert relay calibration payloads into visible launcher state.
|
||||||
|
impl CalibrationStatus {
|
||||||
|
/// Convert the relay calibration RPC payload into launcher state.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_proto(reply: lesavka_common::lesavka::CalibrationState) -> Self {
|
||||||
|
Self {
|
||||||
|
available: true,
|
||||||
|
profile: reply.profile,
|
||||||
|
factory_audio_offset_us: reply.factory_audio_offset_us,
|
||||||
|
factory_video_offset_us: reply.factory_video_offset_us,
|
||||||
|
default_audio_offset_us: reply.default_audio_offset_us,
|
||||||
|
default_video_offset_us: reply.default_video_offset_us,
|
||||||
|
active_audio_offset_us: reply.active_audio_offset_us,
|
||||||
|
active_video_offset_us: reply.active_video_offset_us,
|
||||||
|
source: reply.source,
|
||||||
|
confidence: reply.confidence,
|
||||||
|
updated_at: reply.updated_at,
|
||||||
|
detail: reply.detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub fn unavailable(detail: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
detail: detail.into(),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide factory MJPEG offsets until the relay reports saved calibration.
|
||||||
|
impl Default for CalibrationStatus {
|
||||||
|
/// Start with the current lab-validated MJPEG baseline.
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
available: false,
|
||||||
|
profile: "mjpeg".to_string(),
|
||||||
|
factory_audio_offset_us: -45_000,
|
||||||
|
factory_video_offset_us: 0,
|
||||||
|
default_audio_offset_us: -45_000,
|
||||||
|
default_video_offset_us: 0,
|
||||||
|
active_audio_offset_us: -45_000,
|
||||||
|
active_video_offset_us: 0,
|
||||||
|
source: "unknown".to_string(),
|
||||||
|
confidence: "unknown".to_string(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
detail: "calibration status unavailable".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
pub struct DeviceSelection {
|
pub struct DeviceSelection {
|
||||||
pub camera: Option<String>,
|
pub camera: Option<String>,
|
||||||
@ -348,6 +414,7 @@ pub struct LauncherState {
|
|||||||
pub swap_key_binding: bool,
|
pub swap_key_binding: bool,
|
||||||
pub swap_key_binding_token: u64,
|
pub swap_key_binding_token: u64,
|
||||||
pub capture_power: CapturePowerStatus,
|
pub capture_power: CapturePowerStatus,
|
||||||
|
pub calibration: CalibrationStatus,
|
||||||
pub remote_active: bool,
|
pub remote_active: bool,
|
||||||
pub notes: Vec<String>,
|
pub notes: Vec<String>,
|
||||||
}
|
}
|
||||||
@ -381,6 +448,7 @@ impl Default for LauncherState {
|
|||||||
swap_key_binding: false,
|
swap_key_binding: false,
|
||||||
swap_key_binding_token: 0,
|
swap_key_binding_token: 0,
|
||||||
capture_power: CapturePowerStatus::default(),
|
capture_power: CapturePowerStatus::default(),
|
||||||
|
calibration: CalibrationStatus::default(),
|
||||||
remote_active: false,
|
remote_active: false,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,6 +131,23 @@ impl Relay for ProbeRelay {
|
|||||||
self.get_capture_power(Request::new(lesavka_common::lesavka::Empty {}))
|
self.get_capture_power(Request::new(lesavka_common::lesavka::Empty {}))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_calibration(
|
||||||
|
&self,
|
||||||
|
_request: Request<lesavka_common::lesavka::Empty>,
|
||||||
|
) -> Result<Response<lesavka_common::lesavka::CalibrationState>, Status> {
|
||||||
|
Ok(Response::new(
|
||||||
|
lesavka_common::lesavka::CalibrationState::default(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn calibrate(
|
||||||
|
&self,
|
||||||
|
_request: Request<lesavka_common::lesavka::CalibrationRequest>,
|
||||||
|
) -> Result<Response<lesavka_common::lesavka::CalibrationState>, Status> {
|
||||||
|
self.get_calibration(Request::new(lesavka_common::lesavka::Empty {}))
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -401,6 +401,47 @@ fn capture_power_status_updates_snapshot_state() {
|
|||||||
assert!(state.status_line().contains("power=on"));
|
assert!(state.status_line().contains("power=on"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calibration_status_tracks_proto_unavailable_and_status_line() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
assert!(!state.calibration.available);
|
||||||
|
assert_eq!(state.calibration.active_audio_offset_us, -45_000);
|
||||||
|
|
||||||
|
let unavailable = CalibrationStatus::unavailable("server unreachable");
|
||||||
|
assert!(!unavailable.available);
|
||||||
|
assert_eq!(unavailable.detail, "server unreachable");
|
||||||
|
|
||||||
|
state.set_calibration(CalibrationStatus::from_proto(
|
||||||
|
lesavka_common::lesavka::CalibrationState {
|
||||||
|
profile: "mjpeg".to_string(),
|
||||||
|
factory_audio_offset_us: -45_000,
|
||||||
|
factory_video_offset_us: 0,
|
||||||
|
default_audio_offset_us: -40_000,
|
||||||
|
default_video_offset_us: 1_000,
|
||||||
|
active_audio_offset_us: -35_000,
|
||||||
|
active_video_offset_us: 2_000,
|
||||||
|
source: "manual".to_string(),
|
||||||
|
confidence: "measured".to_string(),
|
||||||
|
updated_at: "now".to_string(),
|
||||||
|
detail: "operator-set".to_string(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(state.calibration.available);
|
||||||
|
assert_eq!(state.calibration.profile, "mjpeg");
|
||||||
|
assert_eq!(state.calibration.factory_audio_offset_us, -45_000);
|
||||||
|
assert_eq!(state.calibration.factory_video_offset_us, 0);
|
||||||
|
assert_eq!(state.calibration.default_audio_offset_us, -40_000);
|
||||||
|
assert_eq!(state.calibration.default_video_offset_us, 1_000);
|
||||||
|
assert_eq!(state.calibration.active_audio_offset_us, -35_000);
|
||||||
|
assert_eq!(state.calibration.active_video_offset_us, 2_000);
|
||||||
|
assert_eq!(state.calibration.source, "manual");
|
||||||
|
assert_eq!(state.calibration.confidence, "measured");
|
||||||
|
assert_eq!(state.calibration.updated_at, "now");
|
||||||
|
assert_eq!(state.calibration.detail, "operator-set");
|
||||||
|
assert!(state.status_line().contains("cal=manual:-35.0ms"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_availability_tracks_reachability() {
|
fn server_availability_tracks_reachability() {
|
||||||
let mut state = LauncherState::new();
|
let mut state = LauncherState::new();
|
||||||
@ -409,6 +450,38 @@ fn server_availability_tracks_reachability() {
|
|||||||
assert!(state.server_available);
|
assert!(state.server_available);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_identity_and_media_caps_trim_blank_values() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_server_version(Some(" ".to_string()));
|
||||||
|
assert_eq!(state.server_version, None);
|
||||||
|
|
||||||
|
state.set_server_version(Some(" 0.16.0 ".to_string()));
|
||||||
|
assert_eq!(state.server_version.as_deref(), Some("0.16.0"));
|
||||||
|
|
||||||
|
state.set_server_media_caps(
|
||||||
|
Some(true),
|
||||||
|
Some(false),
|
||||||
|
Some(" ".to_string()),
|
||||||
|
Some(" mjpeg ".to_string()),
|
||||||
|
);
|
||||||
|
assert_eq!(state.server_camera, Some(true));
|
||||||
|
assert_eq!(state.server_microphone, Some(false));
|
||||||
|
assert_eq!(state.server_camera_output, None);
|
||||||
|
assert_eq!(state.server_camera_codec.as_deref(), Some("mjpeg"));
|
||||||
|
|
||||||
|
state.set_server_media_caps(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(" uvc ".to_string()),
|
||||||
|
Some(" ".to_string()),
|
||||||
|
);
|
||||||
|
assert_eq!(state.server_camera, None);
|
||||||
|
assert_eq!(state.server_microphone, None);
|
||||||
|
assert_eq!(state.server_camera_output.as_deref(), Some("uvc"));
|
||||||
|
assert_eq!(state.server_camera_codec, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn breakout_size_choices_track_the_negotiated_source_size() {
|
fn breakout_size_choices_track_the_negotiated_source_size() {
|
||||||
let mut state = LauncherState::new();
|
let mut state = LauncherState::new();
|
||||||
@ -463,6 +536,20 @@ fn breakout_size_choices_track_the_negotiated_source_size() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_option_methods_report_native_timing_and_bitrate_tiers() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_preview_source_profile(1920, 1080, 30);
|
||||||
|
|
||||||
|
let fps_options = state.capture_fps_options();
|
||||||
|
assert_eq!(fps_options.len(), 1);
|
||||||
|
assert_eq!(fps_options[0].fps, 30);
|
||||||
|
|
||||||
|
let bitrate_options = state.capture_bitrate_options();
|
||||||
|
assert_eq!(bitrate_options.len(), 1);
|
||||||
|
assert_eq!(bitrate_options[0].max_bitrate_kbit, 12_000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn swap_key_binding_tracks_selected_key_and_binding_mode() {
|
fn swap_key_binding_tracks_selected_key_and_binding_mode() {
|
||||||
let mut state = LauncherState::new();
|
let mut state = LauncherState::new();
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
use super::super::{clipboard::send_clipboard_text_to_remote, power::reset_usb_gadget};
|
use super::super::{clipboard::send_clipboard_text_to_remote, power::reset_usb_gadget};
|
||||||
use futures::stream;
|
use futures::stream;
|
||||||
use lesavka_common::lesavka::{
|
use lesavka_common::lesavka::{
|
||||||
AudioPacket, CapturePowerState, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply,
|
AudioPacket, CalibrationRequest, CalibrationState, CapturePowerState, Empty, KeyboardReport,
|
||||||
PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket,
|
MonitorRequest, MouseReport, PasteReply, PasteRequest, ResetUsbReply, SetCapturePowerRequest,
|
||||||
|
VideoPacket,
|
||||||
relay_server::{Relay, RelayServer},
|
relay_server::{Relay, RelayServer},
|
||||||
};
|
};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
@ -116,6 +117,20 @@ impl Relay for UtilityRelay {
|
|||||||
) -> Result<Response<CapturePowerState>, Status> {
|
) -> Result<Response<CapturePowerState>, Status> {
|
||||||
Ok(Response::new(CapturePowerState::default()))
|
Ok(Response::new(CapturePowerState::default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_calibration(
|
||||||
|
&self,
|
||||||
|
_request: Request<Empty>,
|
||||||
|
) -> Result<Response<CalibrationState>, Status> {
|
||||||
|
Ok(Response::new(CalibrationState::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn calibrate(
|
||||||
|
&self,
|
||||||
|
_request: Request<CalibrationRequest>,
|
||||||
|
) -> Result<Response<CalibrationState>, Status> {
|
||||||
|
Ok(Response::new(CalibrationState::default()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serve(relay: UtilityRelay) -> (tokio::runtime::Runtime, String) {
|
fn serve(relay: UtilityRelay) -> (tokio::runtime::Runtime, String) {
|
||||||
|
|||||||
@ -2,17 +2,21 @@ use anyhow::Result;
|
|||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use {
|
use {
|
||||||
|
super::calibration::{
|
||||||
|
blind_calibration_estimate, fetch_calibration, nudge_audio_calibration,
|
||||||
|
restore_default_calibration, restore_factory_calibration,
|
||||||
|
},
|
||||||
super::clipboard::send_clipboard_text_to_remote,
|
super::clipboard::send_clipboard_text_to_remote,
|
||||||
super::device_test::{DeviceTestController, DeviceTestKind},
|
super::device_test::{DeviceTestController, DeviceTestKind},
|
||||||
super::devices::{CameraMode, DeviceCatalog},
|
super::devices::{CameraMode, DeviceCatalog},
|
||||||
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::preview::{LauncherPreview, PreviewSurface},
|
|
||||||
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode},
|
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode},
|
||||||
|
super::preview::{LauncherPreview, PreviewSurface},
|
||||||
super::state::{
|
super::state::{
|
||||||
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
|
BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset,
|
||||||
FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
|
DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
|
||||||
MAX_MIC_GAIN_PERCENT,
|
MAX_MIC_GAIN_PERCENT,
|
||||||
},
|
},
|
||||||
super::ui_components::{
|
super::ui_components::{
|
||||||
@ -41,7 +45,7 @@ use {
|
|||||||
serde_json::json,
|
serde_json::json,
|
||||||
std::cell::{Cell, RefCell},
|
std::cell::{Cell, RefCell},
|
||||||
std::collections::VecDeque,
|
std::collections::VecDeque,
|
||||||
std::path::PathBuf,
|
std::path::{Path, PathBuf},
|
||||||
std::process::Command,
|
std::process::Command,
|
||||||
std::rc::Rc,
|
std::rc::Rc,
|
||||||
std::time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
std::time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
@ -133,6 +137,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
power_tx,
|
power_tx,
|
||||||
power_rx,
|
power_rx,
|
||||||
power_request_in_flight,
|
power_request_in_flight,
|
||||||
|
calibration_tx,
|
||||||
|
calibration_rx,
|
||||||
|
calibration_request_in_flight,
|
||||||
relay_tx,
|
relay_tx,
|
||||||
relay_rx,
|
relay_rx,
|
||||||
relay_request_in_flight,
|
relay_request_in_flight,
|
||||||
@ -142,6 +149,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
diagnostics_network,
|
diagnostics_network,
|
||||||
diagnostics_process,
|
diagnostics_process,
|
||||||
next_power_probe,
|
next_power_probe,
|
||||||
|
next_calibration_probe,
|
||||||
next_diagnostics_probe,
|
next_diagnostics_probe,
|
||||||
next_diagnostics_sample,
|
next_diagnostics_sample,
|
||||||
preview_session_active,
|
preview_session_active,
|
||||||
@ -156,6 +164,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
include!("ui/media_device_bindings.rs");
|
include!("ui/media_device_bindings.rs");
|
||||||
let _: () = include!("ui/device_refresh_binding.rs");
|
let _: () = include!("ui/device_refresh_binding.rs");
|
||||||
include!("ui/relay_input_bindings.rs");
|
include!("ui/relay_input_bindings.rs");
|
||||||
|
include!("ui/eye_capture_bindings.rs");
|
||||||
include!("ui/utility_button_bindings.rs");
|
include!("ui/utility_button_bindings.rs");
|
||||||
include!("ui/local_test_bindings.rs");
|
include!("ui/local_test_bindings.rs");
|
||||||
include!("ui/power_display_key_bindings.rs");
|
include!("ui/power_display_key_bindings.rs");
|
||||||
|
|||||||
@ -18,6 +18,9 @@ struct ActivationContext {
|
|||||||
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
||||||
power_rx: std::sync::mpsc::Receiver<PowerMessage>,
|
power_rx: std::sync::mpsc::Receiver<PowerMessage>,
|
||||||
power_request_in_flight: Rc<Cell<bool>>,
|
power_request_in_flight: Rc<Cell<bool>>,
|
||||||
|
calibration_tx: std::sync::mpsc::Sender<CalibrationMessage>,
|
||||||
|
calibration_rx: std::sync::mpsc::Receiver<CalibrationMessage>,
|
||||||
|
calibration_request_in_flight: Rc<Cell<bool>>,
|
||||||
relay_tx: std::sync::mpsc::Sender<RelayMessage>,
|
relay_tx: std::sync::mpsc::Sender<RelayMessage>,
|
||||||
relay_rx: std::sync::mpsc::Receiver<RelayMessage>,
|
relay_rx: std::sync::mpsc::Receiver<RelayMessage>,
|
||||||
relay_request_in_flight: Rc<Cell<bool>>,
|
relay_request_in_flight: Rc<Cell<bool>>,
|
||||||
@ -27,6 +30,7 @@ struct ActivationContext {
|
|||||||
diagnostics_network: Rc<RefCell<NetworkTelemetry>>,
|
diagnostics_network: Rc<RefCell<NetworkTelemetry>>,
|
||||||
diagnostics_process: Rc<RefCell<ProcessCpuSampler>>,
|
diagnostics_process: Rc<RefCell<ProcessCpuSampler>>,
|
||||||
next_power_probe: Rc<Cell<Instant>>,
|
next_power_probe: Rc<Cell<Instant>>,
|
||||||
|
next_calibration_probe: Rc<Cell<Instant>>,
|
||||||
next_diagnostics_probe: Rc<Cell<Instant>>,
|
next_diagnostics_probe: Rc<Cell<Instant>>,
|
||||||
next_diagnostics_sample: Rc<Cell<Instant>>,
|
next_diagnostics_sample: Rc<Cell<Instant>>,
|
||||||
preview_session_active: Rc<Cell<bool>>,
|
preview_session_active: Rc<Cell<bool>>,
|
||||||
|
|||||||
@ -109,6 +109,9 @@
|
|||||||
|
|
||||||
let (power_tx, power_rx) = std::sync::mpsc::channel::<PowerMessage>();
|
let (power_tx, power_rx) = std::sync::mpsc::channel::<PowerMessage>();
|
||||||
let power_request_in_flight = Rc::new(Cell::new(false));
|
let power_request_in_flight = Rc::new(Cell::new(false));
|
||||||
|
let (calibration_tx, calibration_rx) =
|
||||||
|
std::sync::mpsc::channel::<CalibrationMessage>();
|
||||||
|
let calibration_request_in_flight = Rc::new(Cell::new(false));
|
||||||
let (relay_tx, relay_rx) = std::sync::mpsc::channel::<RelayMessage>();
|
let (relay_tx, relay_rx) = std::sync::mpsc::channel::<RelayMessage>();
|
||||||
let relay_request_in_flight = Rc::new(Cell::new(false));
|
let relay_request_in_flight = Rc::new(Cell::new(false));
|
||||||
let (caps_tx, caps_rx) = std::sync::mpsc::channel::<CapsMessage>();
|
let (caps_tx, caps_rx) = std::sync::mpsc::channel::<CapsMessage>();
|
||||||
@ -117,6 +120,8 @@
|
|||||||
let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new()));
|
let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new()));
|
||||||
let next_power_probe =
|
let next_power_probe =
|
||||||
Rc::new(Cell::new(Instant::now() + Duration::from_millis(500)));
|
Rc::new(Cell::new(Instant::now() + Duration::from_millis(500)));
|
||||||
|
let next_calibration_probe =
|
||||||
|
Rc::new(Cell::new(Instant::now() + Duration::from_millis(650)));
|
||||||
let next_diagnostics_probe =
|
let next_diagnostics_probe =
|
||||||
Rc::new(Cell::new(Instant::now() + Duration::from_millis(250)));
|
Rc::new(Cell::new(Instant::now() + Duration::from_millis(250)));
|
||||||
let next_diagnostics_sample =
|
let next_diagnostics_sample =
|
||||||
@ -149,6 +154,9 @@
|
|||||||
power_tx,
|
power_tx,
|
||||||
power_rx,
|
power_rx,
|
||||||
power_request_in_flight,
|
power_request_in_flight,
|
||||||
|
calibration_tx,
|
||||||
|
calibration_rx,
|
||||||
|
calibration_request_in_flight,
|
||||||
relay_tx,
|
relay_tx,
|
||||||
relay_rx,
|
relay_rx,
|
||||||
relay_request_in_flight,
|
relay_request_in_flight,
|
||||||
@ -158,6 +166,7 @@
|
|||||||
diagnostics_network,
|
diagnostics_network,
|
||||||
diagnostics_process,
|
diagnostics_process,
|
||||||
next_power_probe,
|
next_power_probe,
|
||||||
|
next_calibration_probe,
|
||||||
next_diagnostics_probe,
|
next_diagnostics_probe,
|
||||||
next_diagnostics_sample,
|
next_diagnostics_sample,
|
||||||
preview_session_active,
|
preview_session_active,
|
||||||
|
|||||||
@ -104,6 +104,7 @@ fn apply_mic_gain_change(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
|
/// Refresh relay capture-power state in the background so GTK stays responsive.
|
||||||
fn request_capture_power_refresh(
|
fn request_capture_power_refresh(
|
||||||
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
||||||
server_addr: String,
|
server_addr: String,
|
||||||
@ -131,6 +132,37 @@ fn request_capture_power_command(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
|
/// Refresh upstream calibration state in the background so the UI can poll safely.
|
||||||
|
fn request_calibration_refresh(
|
||||||
|
calibration_tx: std::sync::mpsc::Sender<CalibrationMessage>,
|
||||||
|
server_addr: String,
|
||||||
|
delay: Duration,
|
||||||
|
) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if !delay.is_zero() {
|
||||||
|
std::thread::sleep(delay);
|
||||||
|
}
|
||||||
|
let result = fetch_calibration(&server_addr).map_err(|err| err.to_string());
|
||||||
|
let _ = calibration_tx.send(CalibrationMessage::Refresh(result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn request_calibration_command<F>(
|
||||||
|
calibration_tx: std::sync::mpsc::Sender<CalibrationMessage>,
|
||||||
|
server_addr: String,
|
||||||
|
action: F,
|
||||||
|
) where
|
||||||
|
F: FnOnce(&str) -> anyhow::Result<CalibrationStatus> + Send + 'static,
|
||||||
|
{
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = action(&server_addr).map_err(|err| err.to_string());
|
||||||
|
let _ = calibration_tx.send(CalibrationMessage::Command(result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
/// Probe server capabilities on a short-lived runtime without blocking the UI thread.
|
||||||
fn request_handshake_caps(
|
fn request_handshake_caps(
|
||||||
caps_tx: std::sync::mpsc::Sender<CapsMessage>,
|
caps_tx: std::sync::mpsc::Sender<CapsMessage>,
|
||||||
server_addr: String,
|
server_addr: String,
|
||||||
@ -163,3 +195,20 @@ fn unavailable_capture_power(detail: String) -> CapturePowerStatus {
|
|||||||
detected_devices: 0,
|
detected_devices: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn unavailable_calibration(detail: String) -> CalibrationStatus {
|
||||||
|
CalibrationStatus::unavailable(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn calibration_summary(calibration: &CalibrationStatus) -> String {
|
||||||
|
format!(
|
||||||
|
"Upstream A/V calibration: {} audio {:+.1} ms, video {:+.1} ms ({}, {}).",
|
||||||
|
calibration.profile,
|
||||||
|
calibration.active_audio_offset_us as f64 / 1000.0,
|
||||||
|
calibration.active_video_offset_us as f64 / 1000.0,
|
||||||
|
calibration.source,
|
||||||
|
calibration.confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
471
client/src/launcher/ui/eye_capture_bindings.rs
Normal file
471
client/src/launcher/ui/eye_capture_bindings.rs
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
{
|
||||||
|
const DEFAULT_EYE_RECORD_FPS: u32 = 30;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct EyeRecordState {
|
||||||
|
save_dir_override: Option<PathBuf>,
|
||||||
|
timer: Option<glib::SourceId>,
|
||||||
|
frame_dir: Option<PathBuf>,
|
||||||
|
frame_writer_tx: Option<std::sync::mpsc::Sender<RecordFrameTask>>,
|
||||||
|
finalize_rx: Option<std::sync::mpsc::Receiver<Result<PathBuf, String>>>,
|
||||||
|
next_frame_index: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RecordFrameTask {
|
||||||
|
Frame {
|
||||||
|
texture: gtk::gdk::Texture,
|
||||||
|
frame_path: PathBuf,
|
||||||
|
},
|
||||||
|
Finish,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eye_slug(title: &str) -> &'static str {
|
||||||
|
if title.to_ascii_lowercase().contains("left") {
|
||||||
|
"left-eye"
|
||||||
|
} else {
|
||||||
|
"right-eye"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp_slug() -> String {
|
||||||
|
if let Ok(now) = glib::DateTime::now_local()
|
||||||
|
&& let Ok(stamp) = now.format("%Y%m%d-%H%M%S")
|
||||||
|
{
|
||||||
|
return stamp.to_string();
|
||||||
|
}
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{}-{:03}", now.as_secs(), now.subsec_millis())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_home_token(raw: &str) -> PathBuf {
|
||||||
|
if raw.contains("$HOME") && let Some(home) = std::env::var_os("HOME") {
|
||||||
|
return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy()));
|
||||||
|
}
|
||||||
|
if let Some(rest) = raw.strip_prefix("~/")
|
||||||
|
&& let Some(home) = std::env::var_os("HOME")
|
||||||
|
{
|
||||||
|
return PathBuf::from(home).join(rest);
|
||||||
|
}
|
||||||
|
PathBuf::from(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_eye_capture_root() -> PathBuf {
|
||||||
|
if let Some(raw) = std::env::var_os("XDG_PICTURES_DIR") {
|
||||||
|
let path = expand_home_token(&raw.to_string_lossy());
|
||||||
|
if !path.as_os_str().is_empty() {
|
||||||
|
return path.join("lesavka");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
return PathBuf::from(home).join("Pictures").join("lesavka");
|
||||||
|
}
|
||||||
|
if let Some(profile) = std::env::var_os("USERPROFILE") {
|
||||||
|
return PathBuf::from(profile).join("Pictures").join("lesavka");
|
||||||
|
}
|
||||||
|
std::env::current_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("lesavka")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_eye_capture_root(override_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||||
|
let root = override_dir
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(default_eye_capture_root);
|
||||||
|
std::fs::create_dir_all(&root)
|
||||||
|
.map_err(|err| format!("could not create {}: {err}", root.display()))?;
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_capture_path(root: &Path, stem: &str, ext: &str) -> PathBuf {
|
||||||
|
let mut candidate = root.join(format!("{stem}.{ext}"));
|
||||||
|
if !candidate.exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
for idx in 1..1000 {
|
||||||
|
candidate = root.join(format!("{stem}-{idx}.{ext}"));
|
||||||
|
if !candidate.exists() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_eye_texture(picture: >k::Picture) -> Result<gtk::gdk::Texture, String> {
|
||||||
|
let paintable = picture
|
||||||
|
.paintable()
|
||||||
|
.ok_or_else(|| "no live frame is available yet".to_string())?;
|
||||||
|
paintable
|
||||||
|
.downcast::<gtk::gdk::Texture>()
|
||||||
|
.map_err(|_| "the current frame is not directly exportable".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_texture_png(texture: >k::gdk::Texture, output_path: &Path) -> Result<(), String> {
|
||||||
|
texture
|
||||||
|
.save_to_png(output_path)
|
||||||
|
.map_err(|err| format!("could not write {}: {err}", output_path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recording_interval_ms(record_fps: u32) -> u64 {
|
||||||
|
let fps = record_fps.max(1);
|
||||||
|
(1000_u64 / fps as u64).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn best_effort_recording_profile(
|
||||||
|
state: &LauncherState,
|
||||||
|
preview: Option<&LauncherPreview>,
|
||||||
|
monitor_id: usize,
|
||||||
|
) -> (u32, u32) {
|
||||||
|
let choice = state
|
||||||
|
.display_capture_size_choice(monitor_id)
|
||||||
|
.unwrap_or_else(|| state.capture_size_choice(monitor_id));
|
||||||
|
let mut fps = if choice.fps == 0 {
|
||||||
|
DEFAULT_EYE_RECORD_FPS
|
||||||
|
} else {
|
||||||
|
choice.fps.max(1)
|
||||||
|
};
|
||||||
|
if let Some(snapshot) =
|
||||||
|
preview.and_then(|feed| feed.snapshot_metrics(monitor_id, PreviewSurface::Inline))
|
||||||
|
&& snapshot.server_fps.is_finite()
|
||||||
|
&& snapshot.server_fps >= 1.0
|
||||||
|
{
|
||||||
|
fps = snapshot.server_fps.round().clamp(1.0, 120.0) as u32;
|
||||||
|
}
|
||||||
|
let bitrate_kbit = choice.max_bitrate_kbit.max(800);
|
||||||
|
(fps, bitrate_kbit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_record_frame(state: &mut EyeRecordState, picture: >k::Picture) -> Result<(), String> {
|
||||||
|
let frame_dir = state
|
||||||
|
.frame_dir
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "recording session is not initialized".to_string())?
|
||||||
|
.clone();
|
||||||
|
let frame_writer_tx = state
|
||||||
|
.frame_writer_tx
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "recording worker is not initialized".to_string())?
|
||||||
|
.clone();
|
||||||
|
let texture = current_eye_texture(picture)?;
|
||||||
|
let frame_path = frame_dir.join(format!("frame-{:06}.png", state.next_frame_index));
|
||||||
|
frame_writer_tx
|
||||||
|
.send(RecordFrameTask::Frame {
|
||||||
|
texture,
|
||||||
|
frame_path,
|
||||||
|
})
|
||||||
|
.map_err(|_| "recording worker stopped unexpectedly".to_string())?;
|
||||||
|
state.next_frame_index = state.next_frame_index.saturating_add(1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_recording(
|
||||||
|
frame_dir: &Path,
|
||||||
|
output_path: &Path,
|
||||||
|
encode_fps: u32,
|
||||||
|
encode_bitrate_kbit: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let frame_pattern = frame_dir.join("frame-%06d.png");
|
||||||
|
let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800));
|
||||||
|
let encode = Command::new("ffmpeg")
|
||||||
|
.args([
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-framerate",
|
||||||
|
&encode_fps.max(1).to_string(),
|
||||||
|
"-i",
|
||||||
|
&frame_pattern.to_string_lossy(),
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-r",
|
||||||
|
&encode_fps.max(1).to_string(),
|
||||||
|
"-b:v",
|
||||||
|
&bitrate_arg,
|
||||||
|
&output_path.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("ffmpeg is unavailable: {err}"))?;
|
||||||
|
|
||||||
|
if !encode.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"ffmpeg failed while encoding {}; frame data is still in {}",
|
||||||
|
output_path.display(),
|
||||||
|
frame_dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_recording_worker(
|
||||||
|
frame_rx: std::sync::mpsc::Receiver<RecordFrameTask>,
|
||||||
|
frame_dir: PathBuf,
|
||||||
|
output_path: PathBuf,
|
||||||
|
encode_fps: u32,
|
||||||
|
encode_bitrate_kbit: u32,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let mut captured_frames = 0_u32;
|
||||||
|
while let Ok(task) = frame_rx.recv() {
|
||||||
|
match task {
|
||||||
|
RecordFrameTask::Frame {
|
||||||
|
texture,
|
||||||
|
frame_path,
|
||||||
|
} => {
|
||||||
|
save_texture_png(&texture, &frame_path)?;
|
||||||
|
captured_frames = captured_frames.saturating_add(1);
|
||||||
|
}
|
||||||
|
RecordFrameTask::Finish => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if captured_frames < 2 {
|
||||||
|
let _ = std::fs::remove_dir_all(&frame_dir);
|
||||||
|
return Err("need at least two captured frames to build a recording".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
encode_recording(&frame_dir, &output_path, encode_fps, encode_bitrate_kbit)?;
|
||||||
|
let _ = std::fs::remove_dir_all(&frame_dir);
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
|
for monitor_id in 0..2 {
|
||||||
|
let pane = widgets.display_panes[monitor_id].clone();
|
||||||
|
let widgets_for_ui = widgets.clone();
|
||||||
|
let save_state = Rc::new(RefCell::new(EyeRecordState::default()));
|
||||||
|
|
||||||
|
{
|
||||||
|
let pane = pane.clone();
|
||||||
|
let widgets = widgets_for_ui.clone();
|
||||||
|
let save_state = Rc::clone(&save_state);
|
||||||
|
let window_for_save = window.clone();
|
||||||
|
pane.save_button.connect_clicked(move |_| {
|
||||||
|
let chooser = gtk::FileChooserNative::new(
|
||||||
|
Some("Choose Eye Capture Folder"),
|
||||||
|
Some(&window_for_save),
|
||||||
|
gtk::FileChooserAction::SelectFolder,
|
||||||
|
Some("Select"),
|
||||||
|
Some("Cancel"),
|
||||||
|
);
|
||||||
|
chooser.set_modal(true);
|
||||||
|
let save_state = Rc::clone(&save_state);
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
let eye_name = pane.title.clone();
|
||||||
|
chooser.connect_response(move |dialog, response| {
|
||||||
|
if response == gtk::ResponseType::Accept {
|
||||||
|
if let Some(folder) = dialog.file().and_then(|file| file.path()) {
|
||||||
|
save_state.borrow_mut().save_dir_override = Some(folder.clone());
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"{} saves now go to {}.",
|
||||||
|
eye_name,
|
||||||
|
folder.display()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
widgets.status_label.set_text(
|
||||||
|
"Capture folder selection did not return a filesystem path.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.destroy();
|
||||||
|
});
|
||||||
|
chooser.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let pane = pane.clone();
|
||||||
|
let widgets = widgets_for_ui.clone();
|
||||||
|
let save_state = Rc::clone(&save_state);
|
||||||
|
pane.clip_button.connect_clicked(move |_| {
|
||||||
|
let root = {
|
||||||
|
let borrowed = save_state.borrow();
|
||||||
|
match ensure_eye_capture_root(borrowed.save_dir_override.as_deref()) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(err) => {
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("Could not prepare capture folder: {err}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stem = format!("{}-clip-{}", eye_slug(&pane.title), timestamp_slug());
|
||||||
|
let clip_path = unique_capture_path(&root, &stem, "png");
|
||||||
|
match current_eye_texture(&pane.picture)
|
||||||
|
.and_then(|texture| save_texture_png(&texture, &clip_path))
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"{} clip saved to {}.",
|
||||||
|
pane.title,
|
||||||
|
clip_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("{} clip failed: {err}", pane.title));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let pane = pane.clone();
|
||||||
|
let widgets = widgets_for_ui.clone();
|
||||||
|
let save_state = Rc::clone(&save_state);
|
||||||
|
let state = Rc::clone(&state);
|
||||||
|
let preview = preview.clone();
|
||||||
|
let record_button = pane.record_button.clone();
|
||||||
|
record_button.connect_clicked(move |button| {
|
||||||
|
if save_state.borrow().timer.is_some() {
|
||||||
|
let finalize_rx = {
|
||||||
|
let mut state = save_state.borrow_mut();
|
||||||
|
if let Some(timer) = state.timer.take() {
|
||||||
|
timer.remove();
|
||||||
|
}
|
||||||
|
if let Some(frame_writer_tx) = state.frame_writer_tx.take() {
|
||||||
|
let _ = frame_writer_tx.send(RecordFrameTask::Finish);
|
||||||
|
}
|
||||||
|
state.next_frame_index = 0;
|
||||||
|
state.frame_dir = None;
|
||||||
|
state.finalize_rx.take()
|
||||||
|
};
|
||||||
|
let Some(finalize_rx) = finalize_rx else {
|
||||||
|
button.set_label("Record");
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"{} recording stop failed: recording worker state was missing.",
|
||||||
|
pane.title
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
button.set_sensitive(false);
|
||||||
|
button.set_label("Finishing...");
|
||||||
|
let button = button.clone();
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
let pane_title = pane.title.clone();
|
||||||
|
glib::timeout_add_local(Duration::from_millis(100), move || match finalize_rx
|
||||||
|
.try_recv()
|
||||||
|
{
|
||||||
|
Ok(Ok(output)) => {
|
||||||
|
button.set_sensitive(true);
|
||||||
|
button.set_label("Record");
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"{} recording saved to {}.",
|
||||||
|
pane_title,
|
||||||
|
output.display()
|
||||||
|
));
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
button.set_sensitive(true);
|
||||||
|
button.set_label("Record");
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"{} recording stop failed: {err}",
|
||||||
|
pane_title,
|
||||||
|
));
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||||
|
button.set_sensitive(true);
|
||||||
|
button.set_label("Record");
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"{} recording stop failed: recording worker disconnected.",
|
||||||
|
pane_title
|
||||||
|
));
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (record_fps, record_bitrate_kbit) = {
|
||||||
|
let state = state.borrow();
|
||||||
|
best_effort_recording_profile(&state, preview.as_deref(), monitor_id)
|
||||||
|
};
|
||||||
|
let root = {
|
||||||
|
let borrowed = save_state.borrow();
|
||||||
|
match ensure_eye_capture_root(borrowed.save_dir_override.as_deref()) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(err) => {
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("Could not prepare capture folder: {err}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let recording_stem = format!("{}-record-{}", eye_slug(&pane.title), timestamp_slug());
|
||||||
|
let output_path = unique_capture_path(&root, &recording_stem, "mp4");
|
||||||
|
let frame_dir = root.join(format!("{}.frames", recording_stem));
|
||||||
|
if let Err(err) = std::fs::create_dir_all(&frame_dir) {
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"{} record failed creating frame cache {}: {err}",
|
||||||
|
pane.title,
|
||||||
|
frame_dir.display()
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (frame_tx, frame_rx) = std::sync::mpsc::channel::<RecordFrameTask>();
|
||||||
|
let (result_tx, result_rx) = std::sync::mpsc::channel::<Result<PathBuf, String>>();
|
||||||
|
let frame_dir_worker = frame_dir.clone();
|
||||||
|
let output_path_worker = output_path.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = run_recording_worker(
|
||||||
|
frame_rx,
|
||||||
|
frame_dir_worker,
|
||||||
|
output_path_worker,
|
||||||
|
record_fps,
|
||||||
|
record_bitrate_kbit,
|
||||||
|
);
|
||||||
|
let _ = result_tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut state = save_state.borrow_mut();
|
||||||
|
state.frame_dir = Some(frame_dir);
|
||||||
|
state.frame_writer_tx = Some(frame_tx);
|
||||||
|
state.finalize_rx = Some(result_rx);
|
||||||
|
state.next_frame_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pane_for_tick = pane.clone();
|
||||||
|
let widgets_for_tick = widgets.clone();
|
||||||
|
let save_state_for_tick = Rc::clone(&save_state);
|
||||||
|
let timer = glib::timeout_add_local(
|
||||||
|
Duration::from_millis(recording_interval_ms(record_fps)),
|
||||||
|
move || {
|
||||||
|
let mut state = save_state_for_tick.borrow_mut();
|
||||||
|
if state.frame_dir.is_none() {
|
||||||
|
return glib::ControlFlow::Break;
|
||||||
|
}
|
||||||
|
if let Err(err) = queue_record_frame(&mut state, &pane_for_tick.picture) {
|
||||||
|
if let Some(frame_writer_tx) = state.frame_writer_tx.take() {
|
||||||
|
let _ = frame_writer_tx.send(RecordFrameTask::Finish);
|
||||||
|
}
|
||||||
|
widgets_for_tick.status_label.set_text(&format!(
|
||||||
|
"{} recording frame skipped: {err}",
|
||||||
|
pane_for_tick.title
|
||||||
|
));
|
||||||
|
return glib::ControlFlow::Break;
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Continue
|
||||||
|
},
|
||||||
|
);
|
||||||
|
save_state.borrow_mut().timer = Some(timer);
|
||||||
|
button.set_sensitive(true);
|
||||||
|
button.set_label("Stop");
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"Recording {} at {} fps (~{} kbit)... press Stop to finish.",
|
||||||
|
pane.title, record_fps, record_bitrate_kbit
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,12 @@ enum PowerMessage {
|
|||||||
Command(std::result::Result<CapturePowerStatus, String>),
|
Command(std::result::Result<CapturePowerStatus, String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
enum CalibrationMessage {
|
||||||
|
Refresh(std::result::Result<CalibrationStatus, String>),
|
||||||
|
Command(std::result::Result<CalibrationStatus, String>),
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
enum RelayMessage {
|
enum RelayMessage {
|
||||||
Spawned(std::result::Result<RelayChild, String>),
|
Spawned(std::result::Result<RelayChild, String>),
|
||||||
|
|||||||
@ -12,9 +12,11 @@
|
|||||||
let last_focus_marker =
|
let last_focus_marker =
|
||||||
Rc::new(RefCell::new(path_marker(focus_signal_path.as_path())));
|
Rc::new(RefCell::new(path_marker(focus_signal_path.as_path())));
|
||||||
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
||||||
|
let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight);
|
||||||
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
||||||
let preview = preview.clone();
|
let preview = preview.clone();
|
||||||
let power_tx = power_tx.clone();
|
let power_tx = power_tx.clone();
|
||||||
|
let calibration_tx = calibration_tx.clone();
|
||||||
let caps_tx = caps_tx.clone();
|
let caps_tx = caps_tx.clone();
|
||||||
let caps_request_in_flight = Rc::clone(&caps_request_in_flight);
|
let caps_request_in_flight = Rc::clone(&caps_request_in_flight);
|
||||||
let diagnostics_network = Rc::clone(&diagnostics_network);
|
let diagnostics_network = Rc::clone(&diagnostics_network);
|
||||||
@ -70,6 +72,11 @@
|
|||||||
server_addr.clone(),
|
server_addr.clone(),
|
||||||
Duration::from_millis(250),
|
Duration::from_millis(250),
|
||||||
);
|
);
|
||||||
|
request_calibration_refresh(
|
||||||
|
calibration_tx.clone(),
|
||||||
|
server_addr.clone(),
|
||||||
|
Duration::from_millis(350),
|
||||||
|
);
|
||||||
request_capture_power_refresh(
|
request_capture_power_refresh(
|
||||||
power_tx.clone(),
|
power_tx.clone(),
|
||||||
server_addr,
|
server_addr,
|
||||||
@ -144,6 +151,11 @@
|
|||||||
server_addr.clone(),
|
server_addr.clone(),
|
||||||
Duration::from_millis(250),
|
Duration::from_millis(250),
|
||||||
);
|
);
|
||||||
|
request_calibration_refresh(
|
||||||
|
calibration_tx.clone(),
|
||||||
|
server_addr.clone(),
|
||||||
|
Duration::from_millis(300),
|
||||||
|
);
|
||||||
request_capture_power_refresh(
|
request_capture_power_refresh(
|
||||||
power_tx.clone(),
|
power_tx.clone(),
|
||||||
server_addr,
|
server_addr,
|
||||||
@ -253,6 +265,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while let Ok(message) = calibration_rx.try_recv() {
|
||||||
|
calibration_request_in_flight.set(false);
|
||||||
|
let is_command = matches!(message, CalibrationMessage::Command(_));
|
||||||
|
match message {
|
||||||
|
CalibrationMessage::Refresh(Ok(calibration))
|
||||||
|
| CalibrationMessage::Command(Ok(calibration)) => {
|
||||||
|
let summary = calibration_summary(&calibration);
|
||||||
|
{
|
||||||
|
let mut state = state.borrow_mut();
|
||||||
|
state.set_server_available(true);
|
||||||
|
state.set_calibration(calibration);
|
||||||
|
}
|
||||||
|
if is_command {
|
||||||
|
widgets.status_label.set_text(&summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalibrationMessage::Refresh(Err(err)) => {
|
||||||
|
let relay_live = child_proc.borrow().is_some()
|
||||||
|
|| state.borrow().remote_active;
|
||||||
|
let mut state = state.borrow_mut();
|
||||||
|
if relay_live {
|
||||||
|
state.set_server_available(true);
|
||||||
|
}
|
||||||
|
state.set_calibration(unavailable_calibration(err));
|
||||||
|
}
|
||||||
|
CalibrationMessage::Command(Err(err)) => {
|
||||||
|
{
|
||||||
|
let mut state = state.borrow_mut();
|
||||||
|
state.set_calibration(unavailable_calibration(err.clone()));
|
||||||
|
}
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("Calibration update failed: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while let Ok(message) = caps_rx.try_recv() {
|
while let Ok(message) = caps_rx.try_recv() {
|
||||||
caps_request_in_flight.set(false);
|
caps_request_in_flight.set(false);
|
||||||
match message {
|
match message {
|
||||||
@ -329,6 +378,21 @@
|
|||||||
next_power_probe.set(now + Duration::from_secs(2));
|
next_power_probe.set(now + Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if now >= next_calibration_probe.get()
|
||||||
|
&& !calibration_request_in_flight.get()
|
||||||
|
&& (child_running || state.borrow().server_available)
|
||||||
|
{
|
||||||
|
calibration_request_in_flight.set(true);
|
||||||
|
let server_addr =
|
||||||
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
|
request_calibration_refresh(
|
||||||
|
calibration_tx.clone(),
|
||||||
|
server_addr,
|
||||||
|
Duration::ZERO,
|
||||||
|
);
|
||||||
|
next_calibration_probe.set(now + Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
|
||||||
if now >= next_diagnostics_probe.get() && !caps_request_in_flight.get() {
|
if now >= next_diagnostics_probe.get() && !caps_request_in_flight.get() {
|
||||||
caps_request_in_flight.set(true);
|
caps_request_in_flight.set(true);
|
||||||
let server_addr =
|
let server_addr =
|
||||||
|
|||||||
@ -1,238 +1,4 @@
|
|||||||
{
|
{
|
||||||
const DEFAULT_EYE_RECORD_FPS: u32 = 30;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct EyeRecordState {
|
|
||||||
save_dir_override: Option<PathBuf>,
|
|
||||||
timer: Option<glib::SourceId>,
|
|
||||||
frame_dir: Option<PathBuf>,
|
|
||||||
frame_writer_tx: Option<std::sync::mpsc::Sender<RecordFrameTask>>,
|
|
||||||
finalize_rx: Option<std::sync::mpsc::Receiver<Result<PathBuf, String>>>,
|
|
||||||
next_frame_index: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RecordFrameTask {
|
|
||||||
Frame {
|
|
||||||
texture: gtk::gdk::Texture,
|
|
||||||
frame_path: PathBuf,
|
|
||||||
},
|
|
||||||
Finish,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn eye_slug(title: &str) -> &'static str {
|
|
||||||
if title.to_ascii_lowercase().contains("left") {
|
|
||||||
"left-eye"
|
|
||||||
} else {
|
|
||||||
"right-eye"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn timestamp_slug() -> String {
|
|
||||||
if let Ok(now) = glib::DateTime::now_local()
|
|
||||||
&& let Ok(stamp) = now.format("%Y%m%d-%H%M%S")
|
|
||||||
{
|
|
||||||
return stamp.to_string();
|
|
||||||
}
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default();
|
|
||||||
format!("{}-{:03}", now.as_secs(), now.subsec_millis())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expand_home_token(raw: &str) -> PathBuf {
|
|
||||||
if raw.contains("$HOME") {
|
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
|
||||||
return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(rest) = raw.strip_prefix("~/")
|
|
||||||
&& let Some(home) = std::env::var_os("HOME")
|
|
||||||
{
|
|
||||||
return PathBuf::from(home).join(rest);
|
|
||||||
}
|
|
||||||
PathBuf::from(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_eye_capture_root() -> PathBuf {
|
|
||||||
if let Some(raw) = std::env::var_os("XDG_PICTURES_DIR") {
|
|
||||||
let path = expand_home_token(&raw.to_string_lossy());
|
|
||||||
if !path.as_os_str().is_empty() {
|
|
||||||
return path.join("lesavka");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
|
||||||
return PathBuf::from(home).join("Pictures").join("lesavka");
|
|
||||||
}
|
|
||||||
if let Some(profile) = std::env::var_os("USERPROFILE") {
|
|
||||||
return PathBuf::from(profile).join("Pictures").join("lesavka");
|
|
||||||
}
|
|
||||||
std::env::current_dir()
|
|
||||||
.unwrap_or_else(|_| PathBuf::from("."))
|
|
||||||
.join("lesavka")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_eye_capture_root(override_dir: Option<&PathBuf>) -> Result<PathBuf, String> {
|
|
||||||
let root = override_dir
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(default_eye_capture_root);
|
|
||||||
std::fs::create_dir_all(&root)
|
|
||||||
.map_err(|err| format!("could not create {}: {err}", root.display()))?;
|
|
||||||
Ok(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_capture_path(root: &PathBuf, stem: &str, ext: &str) -> PathBuf {
|
|
||||||
let mut candidate = root.join(format!("{stem}.{ext}"));
|
|
||||||
if !candidate.exists() {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
for idx in 1..1000 {
|
|
||||||
candidate = root.join(format!("{stem}-{idx}.{ext}"));
|
|
||||||
if !candidate.exists() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_eye_texture(picture: >k::Picture) -> Result<gtk::gdk::Texture, String> {
|
|
||||||
let paintable = picture
|
|
||||||
.paintable()
|
|
||||||
.ok_or_else(|| "no live frame is available yet".to_string())?;
|
|
||||||
paintable
|
|
||||||
.downcast::<gtk::gdk::Texture>()
|
|
||||||
.map_err(|_| "the current frame is not directly exportable".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_texture_png(texture: >k::gdk::Texture, output_path: &PathBuf) -> Result<(), String> {
|
|
||||||
texture
|
|
||||||
.save_to_png(output_path)
|
|
||||||
.map_err(|err| format!("could not write {}: {err}", output_path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recording_interval_ms(record_fps: u32) -> u64 {
|
|
||||||
let fps = record_fps.max(1);
|
|
||||||
(1000_u64 / fps as u64).max(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn best_effort_recording_profile(
|
|
||||||
state: &LauncherState,
|
|
||||||
preview: Option<&LauncherPreview>,
|
|
||||||
monitor_id: usize,
|
|
||||||
) -> (u32, u32) {
|
|
||||||
let choice = state
|
|
||||||
.display_capture_size_choice(monitor_id)
|
|
||||||
.unwrap_or_else(|| state.capture_size_choice(monitor_id));
|
|
||||||
let mut fps = if choice.fps == 0 {
|
|
||||||
DEFAULT_EYE_RECORD_FPS
|
|
||||||
} else {
|
|
||||||
choice.fps.max(1)
|
|
||||||
};
|
|
||||||
if let Some(snapshot) =
|
|
||||||
preview.and_then(|feed| feed.snapshot_metrics(monitor_id, PreviewSurface::Inline))
|
|
||||||
&& snapshot.server_fps.is_finite()
|
|
||||||
&& snapshot.server_fps >= 1.0
|
|
||||||
{
|
|
||||||
fps = snapshot.server_fps.round().clamp(1.0, 120.0) as u32;
|
|
||||||
}
|
|
||||||
let bitrate_kbit = choice.max_bitrate_kbit.max(800);
|
|
||||||
(fps, bitrate_kbit)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn queue_record_frame(state: &mut EyeRecordState, picture: >k::Picture) -> Result<(), String> {
|
|
||||||
let frame_dir = state
|
|
||||||
.frame_dir
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| "recording session is not initialized".to_string())?
|
|
||||||
.clone();
|
|
||||||
let frame_writer_tx = state
|
|
||||||
.frame_writer_tx
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| "recording worker is not initialized".to_string())?
|
|
||||||
.clone();
|
|
||||||
let texture = current_eye_texture(picture)?;
|
|
||||||
let frame_path = frame_dir.join(format!("frame-{:06}.png", state.next_frame_index));
|
|
||||||
frame_writer_tx
|
|
||||||
.send(RecordFrameTask::Frame {
|
|
||||||
texture,
|
|
||||||
frame_path,
|
|
||||||
})
|
|
||||||
.map_err(|_| "recording worker stopped unexpectedly".to_string())?;
|
|
||||||
state.next_frame_index = state.next_frame_index.saturating_add(1);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_recording(
|
|
||||||
frame_dir: &PathBuf,
|
|
||||||
output_path: &PathBuf,
|
|
||||||
encode_fps: u32,
|
|
||||||
encode_bitrate_kbit: u32,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let frame_pattern = frame_dir.join("frame-%06d.png");
|
|
||||||
let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800));
|
|
||||||
let encode = Command::new("ffmpeg")
|
|
||||||
.args([
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-y",
|
|
||||||
"-framerate",
|
|
||||||
&encode_fps.max(1).to_string(),
|
|
||||||
"-i",
|
|
||||||
&frame_pattern.to_string_lossy(),
|
|
||||||
"-c:v",
|
|
||||||
"libx264",
|
|
||||||
"-pix_fmt",
|
|
||||||
"yuv420p",
|
|
||||||
"-r",
|
|
||||||
&encode_fps.max(1).to_string(),
|
|
||||||
"-b:v",
|
|
||||||
&bitrate_arg,
|
|
||||||
&output_path.to_string_lossy(),
|
|
||||||
])
|
|
||||||
.status()
|
|
||||||
.map_err(|err| format!("ffmpeg is unavailable: {err}"))?;
|
|
||||||
|
|
||||||
if !encode.success() {
|
|
||||||
return Err(format!(
|
|
||||||
"ffmpeg failed while encoding {}; frame data is still in {}",
|
|
||||||
output_path.display(),
|
|
||||||
frame_dir.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_recording_worker(
|
|
||||||
frame_rx: std::sync::mpsc::Receiver<RecordFrameTask>,
|
|
||||||
frame_dir: PathBuf,
|
|
||||||
output_path: PathBuf,
|
|
||||||
encode_fps: u32,
|
|
||||||
encode_bitrate_kbit: u32,
|
|
||||||
) -> Result<PathBuf, String> {
|
|
||||||
let mut captured_frames = 0_u32;
|
|
||||||
loop {
|
|
||||||
match frame_rx.recv() {
|
|
||||||
Ok(RecordFrameTask::Frame {
|
|
||||||
texture,
|
|
||||||
frame_path,
|
|
||||||
}) => {
|
|
||||||
save_texture_png(&texture, &frame_path)?;
|
|
||||||
captured_frames = captured_frames.saturating_add(1);
|
|
||||||
}
|
|
||||||
Ok(RecordFrameTask::Finish) | Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if captured_frames < 2 {
|
|
||||||
let _ = std::fs::remove_dir_all(&frame_dir);
|
|
||||||
return Err("need at least two captured frames to build a recording".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
encode_recording(&frame_dir, &output_path, encode_fps, encode_bitrate_kbit)?;
|
|
||||||
let _ = std::fs::remove_dir_all(&frame_dir);
|
|
||||||
Ok(output_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
let child_proc = Rc::clone(&child_proc);
|
let child_proc = Rc::clone(&child_proc);
|
||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
@ -285,246 +51,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for monitor_id in 0..2 {
|
|
||||||
let pane = widgets.display_panes[monitor_id].clone();
|
|
||||||
let widgets_for_ui = widgets.clone();
|
|
||||||
let save_state = Rc::new(RefCell::new(EyeRecordState::default()));
|
|
||||||
|
|
||||||
{
|
|
||||||
let pane = pane.clone();
|
|
||||||
let widgets = widgets_for_ui.clone();
|
|
||||||
let save_state = Rc::clone(&save_state);
|
|
||||||
let window_for_save = window.clone();
|
|
||||||
pane.save_button.connect_clicked(move |_| {
|
|
||||||
let chooser = gtk::FileChooserNative::new(
|
|
||||||
Some("Choose Eye Capture Folder"),
|
|
||||||
Some(&window_for_save),
|
|
||||||
gtk::FileChooserAction::SelectFolder,
|
|
||||||
Some("Select"),
|
|
||||||
Some("Cancel"),
|
|
||||||
);
|
|
||||||
chooser.set_modal(true);
|
|
||||||
let save_state = Rc::clone(&save_state);
|
|
||||||
let widgets = widgets.clone();
|
|
||||||
let eye_name = pane.title.clone();
|
|
||||||
chooser.connect_response(move |dialog, response| {
|
|
||||||
if response == gtk::ResponseType::Accept {
|
|
||||||
if let Some(folder) = dialog.file().and_then(|file| file.path()) {
|
|
||||||
save_state.borrow_mut().save_dir_override = Some(folder.clone());
|
|
||||||
widgets.status_label.set_text(&format!(
|
|
||||||
"{} saves now go to {}.",
|
|
||||||
eye_name,
|
|
||||||
folder.display()
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
widgets.status_label.set_text(
|
|
||||||
"Capture folder selection did not return a filesystem path.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dialog.destroy();
|
|
||||||
});
|
|
||||||
chooser.show();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let pane = pane.clone();
|
|
||||||
let widgets = widgets_for_ui.clone();
|
|
||||||
let save_state = Rc::clone(&save_state);
|
|
||||||
pane.clip_button.connect_clicked(move |_| {
|
|
||||||
let root = {
|
|
||||||
let borrowed = save_state.borrow();
|
|
||||||
match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) {
|
|
||||||
Ok(path) => path,
|
|
||||||
Err(err) => {
|
|
||||||
widgets
|
|
||||||
.status_label
|
|
||||||
.set_text(&format!("Could not prepare capture folder: {err}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let stem = format!("{}-clip-{}", eye_slug(&pane.title), timestamp_slug());
|
|
||||||
let clip_path = unique_capture_path(&root, &stem, "png");
|
|
||||||
match current_eye_texture(&pane.picture)
|
|
||||||
.and_then(|texture| save_texture_png(&texture, &clip_path))
|
|
||||||
{
|
|
||||||
Ok(()) => {
|
|
||||||
widgets.status_label.set_text(&format!(
|
|
||||||
"{} clip saved to {}.",
|
|
||||||
pane.title,
|
|
||||||
clip_path.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
widgets
|
|
||||||
.status_label
|
|
||||||
.set_text(&format!("{} clip failed: {err}", pane.title));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let pane = pane.clone();
|
|
||||||
let widgets = widgets_for_ui.clone();
|
|
||||||
let save_state = Rc::clone(&save_state);
|
|
||||||
let state = Rc::clone(&state);
|
|
||||||
let preview = preview.clone();
|
|
||||||
let record_button = pane.record_button.clone();
|
|
||||||
record_button.connect_clicked(move |button| {
|
|
||||||
if save_state.borrow().timer.is_some() {
|
|
||||||
let finalize_rx = {
|
|
||||||
let mut state = save_state.borrow_mut();
|
|
||||||
if let Some(timer) = state.timer.take() {
|
|
||||||
timer.remove();
|
|
||||||
}
|
|
||||||
if let Some(frame_writer_tx) = state.frame_writer_tx.take() {
|
|
||||||
let _ = frame_writer_tx.send(RecordFrameTask::Finish);
|
|
||||||
}
|
|
||||||
state.next_frame_index = 0;
|
|
||||||
state.frame_dir = None;
|
|
||||||
state.finalize_rx.take()
|
|
||||||
};
|
|
||||||
let Some(finalize_rx) = finalize_rx else {
|
|
||||||
button.set_label("Record");
|
|
||||||
widgets.status_label.set_text(&format!(
|
|
||||||
"{} recording stop failed: recording worker state was missing.",
|
|
||||||
pane.title
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
button.set_sensitive(false);
|
|
||||||
button.set_label("Finishing...");
|
|
||||||
let button = button.clone();
|
|
||||||
let widgets = widgets.clone();
|
|
||||||
let pane_title = pane.title.clone();
|
|
||||||
glib::timeout_add_local(Duration::from_millis(100), move || match finalize_rx
|
|
||||||
.try_recv()
|
|
||||||
{
|
|
||||||
Ok(Ok(output)) => {
|
|
||||||
button.set_sensitive(true);
|
|
||||||
button.set_label("Record");
|
|
||||||
widgets.status_label.set_text(&format!(
|
|
||||||
"{} recording saved to {}.",
|
|
||||||
pane_title,
|
|
||||||
output.display()
|
|
||||||
));
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
}
|
|
||||||
Ok(Err(err)) => {
|
|
||||||
button.set_sensitive(true);
|
|
||||||
button.set_label("Record");
|
|
||||||
widgets.status_label.set_text(&format!(
|
|
||||||
"{} recording stop failed: {err}",
|
|
||||||
pane_title,
|
|
||||||
));
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
}
|
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
|
||||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
||||||
button.set_sensitive(true);
|
|
||||||
button.set_label("Record");
|
|
||||||
widgets.status_label.set_text(&format!(
|
|
||||||
"{} recording stop failed: recording worker disconnected.",
|
|
||||||
pane_title
|
|
||||||
));
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (record_fps, record_bitrate_kbit) = {
|
|
||||||
let state = state.borrow();
|
|
||||||
best_effort_recording_profile(&state, preview.as_deref(), monitor_id)
|
|
||||||
};
|
|
||||||
let root = {
|
|
||||||
let borrowed = save_state.borrow();
|
|
||||||
match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) {
|
|
||||||
Ok(path) => path,
|
|
||||||
Err(err) => {
|
|
||||||
widgets
|
|
||||||
.status_label
|
|
||||||
.set_text(&format!("Could not prepare capture folder: {err}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let recording_stem = format!("{}-record-{}", eye_slug(&pane.title), timestamp_slug());
|
|
||||||
let output_path = unique_capture_path(&root, &recording_stem, "mp4");
|
|
||||||
let frame_dir = root.join(format!("{}.frames", recording_stem));
|
|
||||||
if let Err(err) = std::fs::create_dir_all(&frame_dir) {
|
|
||||||
widgets.status_label.set_text(&format!(
|
|
||||||
"{} record failed creating frame cache {}: {err}",
|
|
||||||
pane.title,
|
|
||||||
frame_dir.display()
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (frame_tx, frame_rx) = std::sync::mpsc::channel::<RecordFrameTask>();
|
|
||||||
let (result_tx, result_rx) = std::sync::mpsc::channel::<Result<PathBuf, String>>();
|
|
||||||
let frame_dir_worker = frame_dir.clone();
|
|
||||||
let output_path_worker = output_path.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let result = run_recording_worker(
|
|
||||||
frame_rx,
|
|
||||||
frame_dir_worker,
|
|
||||||
output_path_worker,
|
|
||||||
record_fps,
|
|
||||||
record_bitrate_kbit,
|
|
||||||
);
|
|
||||||
let _ = result_tx.send(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut state = save_state.borrow_mut();
|
|
||||||
state.frame_dir = Some(frame_dir);
|
|
||||||
state.frame_writer_tx = Some(frame_tx);
|
|
||||||
state.finalize_rx = Some(result_rx);
|
|
||||||
state.next_frame_index = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pane_for_tick = pane.clone();
|
|
||||||
let widgets_for_tick = widgets.clone();
|
|
||||||
let save_state_for_tick = Rc::clone(&save_state);
|
|
||||||
let timer = glib::timeout_add_local(
|
|
||||||
Duration::from_millis(recording_interval_ms(record_fps)),
|
|
||||||
move || {
|
|
||||||
let mut state = save_state_for_tick.borrow_mut();
|
|
||||||
if state.frame_dir.is_none() {
|
|
||||||
return glib::ControlFlow::Break;
|
|
||||||
}
|
|
||||||
if let Err(err) = queue_record_frame(&mut state, &pane_for_tick.picture) {
|
|
||||||
if let Some(frame_writer_tx) = state.frame_writer_tx.take() {
|
|
||||||
let _ = frame_writer_tx.send(RecordFrameTask::Finish);
|
|
||||||
}
|
|
||||||
widgets_for_tick.status_label.set_text(&format!(
|
|
||||||
"{} recording frame skipped: {err}",
|
|
||||||
pane_for_tick.title
|
|
||||||
));
|
|
||||||
return glib::ControlFlow::Break;
|
|
||||||
}
|
|
||||||
glib::ControlFlow::Continue
|
|
||||||
},
|
|
||||||
);
|
|
||||||
save_state.borrow_mut().timer = Some(timer);
|
|
||||||
button.set_sensitive(true);
|
|
||||||
button.set_label("Stop");
|
|
||||||
widgets.status_label.set_text(&format!(
|
|
||||||
"Recording {} at {} fps (~{} kbit)... press Stop to finish.",
|
|
||||||
pane.title, record_fps, record_bitrate_kbit
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
let server_entry = server_entry.clone();
|
let server_entry = server_entry.clone();
|
||||||
@ -645,6 +171,144 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
let server_entry = server_entry.clone();
|
||||||
|
let server_addr_fallback = Rc::clone(&server_addr);
|
||||||
|
let calibration_tx = calibration_tx.clone();
|
||||||
|
let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight);
|
||||||
|
widgets.calibration_default_button.connect_clicked(move |_| {
|
||||||
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text("Calibration 1/2: restoring saved upstream A/V default...");
|
||||||
|
calibration_request_in_flight.set(true);
|
||||||
|
request_calibration_command(calibration_tx.clone(), server_addr, |server_addr| {
|
||||||
|
restore_default_calibration(server_addr)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
let server_entry = server_entry.clone();
|
||||||
|
let server_addr_fallback = Rc::clone(&server_addr);
|
||||||
|
let calibration_tx = calibration_tx.clone();
|
||||||
|
let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight);
|
||||||
|
widgets.calibration_factory_button.connect_clicked(move |_| {
|
||||||
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text("Calibration 1/2: restoring factory MJPEG upstream A/V baseline...");
|
||||||
|
calibration_request_in_flight.set(true);
|
||||||
|
request_calibration_command(calibration_tx.clone(), server_addr, |server_addr| {
|
||||||
|
restore_factory_calibration(server_addr)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
let server_entry = server_entry.clone();
|
||||||
|
let server_addr_fallback = Rc::clone(&server_addr);
|
||||||
|
let calibration_tx = calibration_tx.clone();
|
||||||
|
let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight);
|
||||||
|
widgets.calibration_minus_button.connect_clicked(move |_| {
|
||||||
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text("Calibration 1/2: nudging upstream audio 5 ms earlier...");
|
||||||
|
calibration_request_in_flight.set(true);
|
||||||
|
request_calibration_command(calibration_tx.clone(), server_addr, |server_addr| {
|
||||||
|
nudge_audio_calibration(server_addr, -5_000)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
let server_entry = server_entry.clone();
|
||||||
|
let server_addr_fallback = Rc::clone(&server_addr);
|
||||||
|
let calibration_tx = calibration_tx.clone();
|
||||||
|
let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight);
|
||||||
|
widgets.calibration_plus_button.connect_clicked(move |_| {
|
||||||
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text("Calibration 1/2: nudging upstream audio 5 ms later...");
|
||||||
|
calibration_request_in_flight.set(true);
|
||||||
|
request_calibration_command(calibration_tx.clone(), server_addr, |server_addr| {
|
||||||
|
nudge_audio_calibration(server_addr, 5_000)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
let server_entry = server_entry.clone();
|
||||||
|
let server_addr_fallback = Rc::clone(&server_addr);
|
||||||
|
let calibration_tx = calibration_tx.clone();
|
||||||
|
let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight);
|
||||||
|
widgets.calibration_blind_button.connect_clicked(move |_| {
|
||||||
|
let Some(sample) = widgets.diagnostics_log.borrow().latest().cloned() else {
|
||||||
|
widgets.status_label.set_text(
|
||||||
|
"Blind calibration needs a live upstream camera and microphone sample first.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let camera = sample.upstream_camera;
|
||||||
|
let microphone = sample.upstream_microphone;
|
||||||
|
if !camera.connected || !microphone.connected {
|
||||||
|
widgets.status_label.set_text(
|
||||||
|
"Blind calibration refused: upstream camera and microphone are not both live.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delivery_delta_ms =
|
||||||
|
microphone.latest_delivery_age_ms - camera.latest_delivery_age_ms;
|
||||||
|
let delivery_skew_ms = delivery_delta_ms.abs();
|
||||||
|
let enqueue_skew_ms =
|
||||||
|
(microphone.latest_enqueue_age_ms - camera.latest_enqueue_age_ms).abs();
|
||||||
|
if camera.queue_depth >= 28 || microphone.queue_depth >= 14 || delivery_skew_ms > 80.0
|
||||||
|
{
|
||||||
|
widgets.status_label.set_text(
|
||||||
|
"Blind calibration refused: live queues are too backed up to make a safe timing estimate. Use the test rig or fix queue churn first.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let audio_delta_us = (-(delivery_delta_ms as f64) * 500.0)
|
||||||
|
.round()
|
||||||
|
.clamp(-10_000.0, 10_000.0) as i64;
|
||||||
|
let note = format!(
|
||||||
|
"blind estimate from live telemetry: mic-camera delivery delta {delivery_delta_ms:+.1}ms, enqueue skew {enqueue_skew_ms:.1}ms; applying half-step audio delta {:+.1}ms",
|
||||||
|
audio_delta_us as f64 / 1000.0
|
||||||
|
);
|
||||||
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
|
widgets
|
||||||
|
.status_label
|
||||||
|
.set_text("Calibration 1/2: applying blind upstream A/V estimate...");
|
||||||
|
calibration_request_in_flight.set(true);
|
||||||
|
request_calibration_command(calibration_tx.clone(), server_addr, move |server_addr| {
|
||||||
|
blind_calibration_estimate(
|
||||||
|
server_addr,
|
||||||
|
audio_delta_us,
|
||||||
|
delivery_skew_ms,
|
||||||
|
enqueue_skew_ms,
|
||||||
|
¬e,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
widgets.calibration_rig_button.connect_clicked(move |_| {
|
||||||
|
widgets.status_label.set_text(
|
||||||
|
"Rig calibration wizard is queued for the 0.16.0 test-equipment phase; for now the manual Tethys sync battery remains the measured-default path.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
widgets.diagnostics_copy_button.connect_clicked(move |_| {
|
widgets.diagnostics_copy_button.connect_clicked(move |_| {
|
||||||
|
|||||||
@ -89,6 +89,12 @@ pub fn build_launcher_view(
|
|||||||
usb_recover_button,
|
usb_recover_button,
|
||||||
uac_recover_button,
|
uac_recover_button,
|
||||||
uvc_recover_button,
|
uvc_recover_button,
|
||||||
|
calibration_default_button,
|
||||||
|
calibration_factory_button,
|
||||||
|
calibration_blind_button,
|
||||||
|
calibration_minus_button,
|
||||||
|
calibration_plus_button,
|
||||||
|
calibration_rig_button,
|
||||||
power_auto_button,
|
power_auto_button,
|
||||||
power_on_button,
|
power_on_button,
|
||||||
power_off_button,
|
power_off_button,
|
||||||
|
|||||||
@ -145,6 +145,12 @@
|
|||||||
usb_recover_button: usb_recover_button.clone(),
|
usb_recover_button: usb_recover_button.clone(),
|
||||||
uac_recover_button: uac_recover_button.clone(),
|
uac_recover_button: uac_recover_button.clone(),
|
||||||
uvc_recover_button: uvc_recover_button.clone(),
|
uvc_recover_button: uvc_recover_button.clone(),
|
||||||
|
calibration_default_button: calibration_default_button.clone(),
|
||||||
|
calibration_factory_button: calibration_factory_button.clone(),
|
||||||
|
calibration_blind_button: calibration_blind_button.clone(),
|
||||||
|
calibration_minus_button: calibration_minus_button.clone(),
|
||||||
|
calibration_plus_button: calibration_plus_button.clone(),
|
||||||
|
calibration_rig_button: calibration_rig_button.clone(),
|
||||||
device_refresh_button: device_refresh_button.clone(),
|
device_refresh_button: device_refresh_button.clone(),
|
||||||
swap_key_button: swap_key_button.clone(),
|
swap_key_button: swap_key_button.clone(),
|
||||||
camera_test_button: camera_test_button.clone(),
|
camera_test_button: camera_test_button.clone(),
|
||||||
|
|||||||
@ -59,6 +59,12 @@ struct OperationsRailContext {
|
|||||||
usb_recover_button: gtk::Button,
|
usb_recover_button: gtk::Button,
|
||||||
uac_recover_button: gtk::Button,
|
uac_recover_button: gtk::Button,
|
||||||
uvc_recover_button: gtk::Button,
|
uvc_recover_button: gtk::Button,
|
||||||
|
calibration_default_button: gtk::Button,
|
||||||
|
calibration_factory_button: gtk::Button,
|
||||||
|
calibration_blind_button: gtk::Button,
|
||||||
|
calibration_minus_button: gtk::Button,
|
||||||
|
calibration_plus_button: gtk::Button,
|
||||||
|
calibration_rig_button: gtk::Button,
|
||||||
power_auto_button: gtk::Button,
|
power_auto_button: gtk::Button,
|
||||||
power_on_button: gtk::Button,
|
power_on_button: gtk::Button,
|
||||||
power_off_button: gtk::Button,
|
power_off_button: gtk::Button,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
let (connection_panel, connection_body) = build_panel("Relay Controls");
|
let (connection_panel, connection_body) = build_panel("Relay");
|
||||||
let server_entry = gtk::Entry::new();
|
let server_entry = gtk::Entry::new();
|
||||||
server_entry.add_css_class("server-entry");
|
server_entry.add_css_class("server-entry");
|
||||||
server_entry.set_hexpand(true);
|
server_entry.set_hexpand(true);
|
||||||
@ -39,20 +39,50 @@
|
|||||||
connection_body.append(&recovery_row);
|
connection_body.append(&recovery_row);
|
||||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||||
|
|
||||||
let tools_heading = gtk::Label::new(Some("Tools"));
|
let calibration_heading = gtk::Label::new(Some("AV Upstream\nCalibration"));
|
||||||
tools_heading.add_css_class("subgroup-title");
|
calibration_heading.add_css_class("subgroup-title");
|
||||||
tools_heading.set_halign(gtk::Align::Start);
|
calibration_heading.set_halign(gtk::Align::Start);
|
||||||
let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
calibration_heading.set_width_chars(12);
|
||||||
tools_row.set_hexpand(true);
|
let calibration_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
tools_heading.set_width_chars(10);
|
calibration_row.set_hexpand(true);
|
||||||
tools_row.append(&tools_heading);
|
calibration_row.append(&calibration_heading);
|
||||||
let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let calibration_buttons = gtk::Grid::new();
|
||||||
tools_buttons.set_hexpand(true);
|
calibration_buttons.set_column_homogeneous(true);
|
||||||
tools_buttons.set_homogeneous(true);
|
calibration_buttons.set_column_spacing(8);
|
||||||
let clipboard_button = rail_button("Clipboard", "Type clipboard remotely.");
|
calibration_buttons.set_row_spacing(6);
|
||||||
tools_buttons.append(&clipboard_button);
|
calibration_buttons.set_hexpand(true);
|
||||||
tools_row.append(&tools_buttons);
|
let calibration_default_button = rail_button(
|
||||||
connection_body.append(&tools_row);
|
"Default",
|
||||||
|
"Restore the saved upstream A/V calibration profile for this relay.",
|
||||||
|
);
|
||||||
|
let calibration_factory_button = rail_button(
|
||||||
|
"Factory",
|
||||||
|
"Restore the release-shipped MJPEG upstream A/V calibration.",
|
||||||
|
);
|
||||||
|
let calibration_blind_button = rail_button(
|
||||||
|
"Blind",
|
||||||
|
"Estimate upstream A/V calibration from live queue and timestamp telemetry; use only when no test rig is attached.",
|
||||||
|
);
|
||||||
|
let calibration_minus_button = rail_button(
|
||||||
|
"-5 ms",
|
||||||
|
"Move upstream audio 5 ms earlier relative to video for the active session.",
|
||||||
|
);
|
||||||
|
let calibration_plus_button = rail_button(
|
||||||
|
"+5 ms",
|
||||||
|
"Move upstream audio 5 ms later relative to video for the active session.",
|
||||||
|
);
|
||||||
|
let calibration_rig_button = rail_button(
|
||||||
|
"Rig...",
|
||||||
|
"Open the test-equipment calibration path for measuring a new saved default.",
|
||||||
|
);
|
||||||
|
calibration_buttons.attach(&calibration_default_button, 0, 0, 1, 1);
|
||||||
|
calibration_buttons.attach(&calibration_factory_button, 1, 0, 1, 1);
|
||||||
|
calibration_buttons.attach(&calibration_blind_button, 2, 0, 1, 1);
|
||||||
|
calibration_buttons.attach(&calibration_minus_button, 0, 1, 1, 1);
|
||||||
|
calibration_buttons.attach(&calibration_plus_button, 1, 1, 1, 1);
|
||||||
|
calibration_buttons.attach(&calibration_rig_button, 2, 1, 1, 1);
|
||||||
|
calibration_row.append(&calibration_buttons);
|
||||||
|
connection_body.append(&calibration_row);
|
||||||
|
|
||||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||||
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
||||||
@ -113,6 +143,22 @@
|
|||||||
routing_buttons.append(&swap_key_button);
|
routing_buttons.append(&swap_key_button);
|
||||||
routing_row.append(&routing_buttons);
|
routing_row.append(&routing_buttons);
|
||||||
connection_body.append(&routing_row);
|
connection_body.append(&routing_row);
|
||||||
|
|
||||||
|
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||||
|
let tools_heading = gtk::Label::new(Some("Tools"));
|
||||||
|
tools_heading.add_css_class("subgroup-title");
|
||||||
|
tools_heading.set_halign(gtk::Align::Start);
|
||||||
|
let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
tools_row.set_hexpand(true);
|
||||||
|
tools_heading.set_width_chars(10);
|
||||||
|
tools_row.append(&tools_heading);
|
||||||
|
let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
tools_buttons.set_hexpand(true);
|
||||||
|
tools_buttons.set_homogeneous(true);
|
||||||
|
let clipboard_button = rail_button("Clipboard", "Type clipboard remotely.");
|
||||||
|
tools_buttons.append(&clipboard_button);
|
||||||
|
tools_row.append(&tools_buttons);
|
||||||
|
connection_body.append(&tools_row);
|
||||||
operations.append(&connection_panel);
|
operations.append(&connection_panel);
|
||||||
|
|
||||||
let (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics");
|
let (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics");
|
||||||
@ -235,6 +281,12 @@
|
|||||||
usb_recover_button,
|
usb_recover_button,
|
||||||
uac_recover_button,
|
uac_recover_button,
|
||||||
uvc_recover_button,
|
uvc_recover_button,
|
||||||
|
calibration_default_button,
|
||||||
|
calibration_factory_button,
|
||||||
|
calibration_blind_button,
|
||||||
|
calibration_minus_button,
|
||||||
|
calibration_plus_button,
|
||||||
|
calibration_rig_button,
|
||||||
power_auto_button,
|
power_auto_button,
|
||||||
power_on_button,
|
power_on_button,
|
||||||
power_off_button,
|
power_off_button,
|
||||||
|
|||||||
@ -158,17 +158,17 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
|||||||
breakout_combo.set_hexpand(true);
|
breakout_combo.set_hexpand(true);
|
||||||
|
|
||||||
let clip_button = gtk::Button::with_label("Clip");
|
let clip_button = gtk::Button::with_label("Clip");
|
||||||
stabilize_button(&clip_button, 72);
|
stabilize_button(&clip_button, 66);
|
||||||
clip_button.set_tooltip_text(Some("Capture a still image for this eye."));
|
clip_button.set_tooltip_text(Some("Capture a still image for this eye."));
|
||||||
let record_button = gtk::Button::with_label("Record");
|
let record_button = gtk::Button::with_label("Record");
|
||||||
stabilize_button(&record_button, 84);
|
stabilize_button(&record_button, 78);
|
||||||
record_button.set_tooltip_text(Some("Record this eye feed until you stop."));
|
record_button.set_tooltip_text(Some("Record this eye feed until you stop."));
|
||||||
let save_button = gtk::Button::with_label("Save");
|
let save_button = gtk::Button::with_label("Save");
|
||||||
stabilize_button(&save_button, 72);
|
stabilize_button(&save_button, 66);
|
||||||
save_button.set_tooltip_text(Some("Choose where this eye saves clips and recordings."));
|
save_button.set_tooltip_text(Some("Choose where this eye saves clips and recordings."));
|
||||||
|
|
||||||
let action_button = gtk::Button::with_label("Break Out");
|
let action_button = gtk::Button::with_label("Break Out");
|
||||||
stabilize_button(&action_button, 90);
|
stabilize_button(&action_button, 82);
|
||||||
action_button.set_halign(gtk::Align::End);
|
action_button.set_halign(gtk::Align::End);
|
||||||
|
|
||||||
let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ fn build_panel(title: &str) -> (gtk::Box, gtk::Box) {
|
|||||||
build_panel_with_action(title, None)
|
build_panel_with_action(title, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a vertical panel with an optional header action.
|
||||||
fn build_panel_with_action(title: &str, action: Option<>k::Widget>) -> (gtk::Box, gtk::Box) {
|
fn build_panel_with_action(title: &str, action: Option<>k::Widget>) -> (gtk::Box, gtk::Box) {
|
||||||
let panel = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
let panel = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
panel.add_css_class("panel");
|
panel.add_css_class("panel");
|
||||||
@ -28,6 +29,7 @@ fn build_subgroup(title: &str) -> gtk::Box {
|
|||||||
build_subgroup_with_action(title, None)
|
build_subgroup_with_action(title, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a titled subgroup row matching the relay-control section style.
|
||||||
fn build_subgroup_with_action(title: &str, action: Option<>k::Widget>) -> gtk::Box {
|
fn build_subgroup_with_action(title: &str, action: Option<>k::Widget>) -> gtk::Box {
|
||||||
let group = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
let group = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
group.add_css_class("subgroup");
|
group.add_css_class("subgroup");
|
||||||
@ -45,6 +47,7 @@ fn build_subgroup_with_action(title: &str, action: Option<>k::Widget>) -> gtk:
|
|||||||
group
|
group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a fixed-width status chip so changing labels do not move the header row.
|
||||||
fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
|
fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
|
||||||
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||||
chip.add_css_class("status-chip");
|
chip.add_css_class("status-chip");
|
||||||
@ -67,6 +70,7 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
|
|||||||
(chip, value_widget)
|
(chip, value_widget)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a fixed-width status chip with a colored health light.
|
||||||
fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box, gtk::Label) {
|
fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box, gtk::Label) {
|
||||||
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||||
chip.add_css_class("status-chip");
|
chip.add_css_class("status-chip");
|
||||||
|
|||||||
@ -152,6 +152,12 @@ pub struct LauncherWidgets {
|
|||||||
pub usb_recover_button: gtk::Button,
|
pub usb_recover_button: gtk::Button,
|
||||||
pub uac_recover_button: gtk::Button,
|
pub uac_recover_button: gtk::Button,
|
||||||
pub uvc_recover_button: gtk::Button,
|
pub uvc_recover_button: gtk::Button,
|
||||||
|
pub calibration_default_button: gtk::Button,
|
||||||
|
pub calibration_factory_button: gtk::Button,
|
||||||
|
pub calibration_blind_button: gtk::Button,
|
||||||
|
pub calibration_minus_button: gtk::Button,
|
||||||
|
pub calibration_plus_button: gtk::Button,
|
||||||
|
pub calibration_rig_button: gtk::Button,
|
||||||
pub device_refresh_button: gtk::Button,
|
pub device_refresh_button: gtk::Button,
|
||||||
pub swap_key_button: gtk::Button,
|
pub swap_key_button: gtk::Button,
|
||||||
pub camera_test_button: gtk::Button,
|
pub camera_test_button: gtk::Button,
|
||||||
@ -205,11 +211,11 @@ pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
|
|||||||
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
|
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
|
||||||
const LAUNCHER_DEFAULT_WIDTH: i32 = 1540;
|
const LAUNCHER_DEFAULT_WIDTH: i32 = 1540;
|
||||||
const LAUNCHER_DEFAULT_HEIGHT: i32 = 880;
|
const LAUNCHER_DEFAULT_HEIGHT: i32 = 880;
|
||||||
const OPERATIONS_RAIL_WIDTH: i32 = 288;
|
const OPERATIONS_RAIL_WIDTH: i32 = 276;
|
||||||
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 225;
|
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 225;
|
||||||
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 400;
|
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 400;
|
||||||
const EYE_PREVIEW_MIN_HEIGHT: i32 = 315;
|
const EYE_PREVIEW_MIN_HEIGHT: i32 = 299;
|
||||||
const EYE_PREVIEW_MIN_WIDTH: i32 = 560;
|
const EYE_PREVIEW_MIN_WIDTH: i32 = 532;
|
||||||
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
|
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
|
||||||
const SIDE_LOG_RECOVERY_BUDGET_SPLIT: i32 = 63;
|
const SIDE_LOG_RECOVERY_BUDGET_SPLIT: i32 = 102;
|
||||||
const SIDE_LOG_RECOVERY_MIN_HEIGHT: i32 = SIDE_LOG_MIN_HEIGHT - SIDE_LOG_RECOVERY_BUDGET_SPLIT;
|
const SIDE_LOG_RECOVERY_MIN_HEIGHT: i32 = SIDE_LOG_MIN_HEIGHT - SIDE_LOG_RECOVERY_BUDGET_SPLIT;
|
||||||
|
|||||||
@ -101,6 +101,7 @@ fn normalize_version(version: &str) -> &str {
|
|||||||
version.trim().trim_start_matches('v')
|
version.trim().trim_start_matches('v')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show the connected server version, or an explicit unknown marker when disconnected.
|
||||||
fn server_version_label(state: &LauncherState) -> String {
|
fn server_version_label(state: &LauncherState) -> String {
|
||||||
if !state.server_available {
|
if !state.server_available {
|
||||||
return "???".to_string();
|
return "???".to_string();
|
||||||
@ -117,6 +118,7 @@ fn server_version_label(state: &LauncherState) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Summarize whether the composite USB gadget appears reachable to the host.
|
||||||
fn recovery_usb_health(state: &LauncherState) -> (StatusLightState, String) {
|
fn recovery_usb_health(state: &LauncherState) -> (StatusLightState, String) {
|
||||||
if !state.server_available {
|
if !state.server_available {
|
||||||
return (StatusLightState::Idle, "Offline".to_string());
|
return (StatusLightState::Idle, "Offline".to_string());
|
||||||
@ -136,6 +138,7 @@ fn recovery_usb_health(state: &LauncherState) -> (StatusLightState, String) {
|
|||||||
(StatusLightState::Caution, "Partial".to_string())
|
(StatusLightState::Caution, "Partial".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Summarize whether the UAC microphone/audio function is advertised by the relay.
|
||||||
fn recovery_uac_health(state: &LauncherState) -> (StatusLightState, String) {
|
fn recovery_uac_health(state: &LauncherState) -> (StatusLightState, String) {
|
||||||
if !state.server_available {
|
if !state.server_available {
|
||||||
return (StatusLightState::Idle, "Offline".to_string());
|
return (StatusLightState::Idle, "Offline".to_string());
|
||||||
@ -147,6 +150,7 @@ fn recovery_uac_health(state: &LauncherState) -> (StatusLightState, String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Summarize whether the UVC camera function is advertised with the expected codec.
|
||||||
fn recovery_uvc_health(state: &LauncherState) -> (StatusLightState, String) {
|
fn recovery_uvc_health(state: &LauncherState) -> (StatusLightState, String) {
|
||||||
if !state.server_available {
|
if !state.server_available {
|
||||||
return (StatusLightState::Idle, "Offline".to_string());
|
return (StatusLightState::Idle, "Offline".to_string());
|
||||||
|
|||||||
@ -15,6 +15,7 @@ pub mod layout;
|
|||||||
pub(crate) mod live_capture_clock;
|
pub(crate) mod live_capture_clock;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
pub mod paste;
|
pub mod paste;
|
||||||
|
pub mod relay_transport;
|
||||||
pub mod sync_probe;
|
pub mod sync_probe;
|
||||||
pub(crate) mod uplink_fresh_queue;
|
pub(crate) mod uplink_fresh_queue;
|
||||||
pub(crate) mod uplink_latency_harness;
|
pub(crate) mod uplink_latency_harness;
|
||||||
|
|||||||
257
client/src/relay_transport.rs
Normal file
257
client/src/relay_transport.rs
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
|
||||||
|
|
||||||
|
const DEFAULT_CLIENT_PKI_DIR: &str = ".config/lesavka/pki";
|
||||||
|
|
||||||
|
pub fn endpoint(server_addr: &str) -> Result<Endpoint> {
|
||||||
|
enforce_transport_policy(server_addr)?;
|
||||||
|
let mut endpoint =
|
||||||
|
Channel::from_shared(server_addr.to_string()).context("invalid relay server address")?;
|
||||||
|
if is_https(server_addr) {
|
||||||
|
endpoint = endpoint
|
||||||
|
.tls_config(client_tls_config(server_addr)?)
|
||||||
|
.context("configuring relay TLS")?;
|
||||||
|
}
|
||||||
|
Ok(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(server_addr: &str) -> Result<Channel> {
|
||||||
|
endpoint(server_addr)?
|
||||||
|
.tcp_nodelay(true)
|
||||||
|
.connect()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("connecting to relay at {server_addr}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enforce_transport_policy(server_addr: &str) -> Result<()> {
|
||||||
|
if is_https(server_addr) || is_local_http(server_addr) || allow_insecure_transport() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!(
|
||||||
|
"refusing insecure relay transport for {server_addr}; use https:// or set LESAVKA_ALLOW_INSECURE=1 for a deliberate lab override"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_tls_config(server_addr: &str) -> Result<ClientTlsConfig> {
|
||||||
|
let mut tls = ClientTlsConfig::new()
|
||||||
|
.domain_name(tls_domain(server_addr))
|
||||||
|
.with_enabled_roots();
|
||||||
|
|
||||||
|
if let Some(ca_path) = env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt"))
|
||||||
|
&& ca_path.exists()
|
||||||
|
{
|
||||||
|
let ca = std::fs::read(&ca_path)
|
||||||
|
.with_context(|| format!("reading TLS CA certificate {}", ca_path.display()))?;
|
||||||
|
tls = tls.ca_certificate(Certificate::from_pem(ca));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cert_path = env_path("LESAVKA_TLS_CLIENT_CERT").or_else(|| default_pki_path("client.crt"));
|
||||||
|
let key_path = env_path("LESAVKA_TLS_CLIENT_KEY").or_else(|| default_pki_path("client.key"));
|
||||||
|
if let (Some(cert_path), Some(key_path)) = (cert_path, key_path)
|
||||||
|
&& cert_path.exists()
|
||||||
|
&& key_path.exists()
|
||||||
|
{
|
||||||
|
let cert = std::fs::read(&cert_path)
|
||||||
|
.with_context(|| format!("reading TLS client certificate {}", cert_path.display()))?;
|
||||||
|
let key = std::fs::read(&key_path)
|
||||||
|
.with_context(|| format!("reading TLS client key {}", key_path.display()))?;
|
||||||
|
tls = tls.identity(Identity::from_pem(cert, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tls)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tls_domain(server_addr: &str) -> String {
|
||||||
|
std::env::var("LESAVKA_TLS_DOMAIN")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
host_from_uri(server_addr)
|
||||||
|
.unwrap_or("lesavka-server")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_path(name: &str) -> Option<PathBuf> {
|
||||||
|
std::env::var_os(name)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_pki_path(file_name: &str) -> Option<PathBuf> {
|
||||||
|
let home = std::env::var_os("HOME")?;
|
||||||
|
Some(
|
||||||
|
PathBuf::from(home)
|
||||||
|
.join(DEFAULT_CLIENT_PKI_DIR)
|
||||||
|
.join(file_name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow_insecure_transport() -> bool {
|
||||||
|
std::env::var("LESAVKA_ALLOW_INSECURE")
|
||||||
|
.map(|value| {
|
||||||
|
let value = value.trim().to_ascii_lowercase();
|
||||||
|
!value.is_empty() && value != "0" && value != "false" && value != "no"
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_https(server_addr: &str) -> bool {
|
||||||
|
server_addr
|
||||||
|
.get(..8)
|
||||||
|
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_local_http(server_addr: &str) -> bool {
|
||||||
|
let Some(rest) = server_addr.strip_prefix("http://") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let host = rest
|
||||||
|
.split(['/', '?', '#'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, host)| host)
|
||||||
|
.unwrap_or(rest);
|
||||||
|
let host = if host.starts_with('[') {
|
||||||
|
host.split(']')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(host)
|
||||||
|
.trim_start_matches('[')
|
||||||
|
} else {
|
||||||
|
host.split(':').next().unwrap_or(host)
|
||||||
|
};
|
||||||
|
matches!(host, "localhost" | "::1" | "0.0.0.0") || host.starts_with("127.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_from_uri(server_addr: &str) -> Option<&str> {
|
||||||
|
let rest = server_addr.split_once("://")?.1;
|
||||||
|
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||||
|
let authority = authority
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, host)| host)
|
||||||
|
.unwrap_or(authority);
|
||||||
|
if authority.starts_with('[') {
|
||||||
|
return authority
|
||||||
|
.split(']')
|
||||||
|
.next()
|
||||||
|
.map(|host| host.trim_start_matches('['))
|
||||||
|
.filter(|host| !host.is_empty());
|
||||||
|
}
|
||||||
|
authority.split(':').next().filter(|host| !host.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{endpoint, enforce_transport_policy, host_from_uri, is_local_http, tls_domain};
|
||||||
|
use temp_env::with_vars;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn localhost_http_is_allowed_for_tunnels() {
|
||||||
|
with_vars([("LESAVKA_ALLOW_INSECURE", None::<&str>)], || {
|
||||||
|
assert!(enforce_transport_policy("http://127.0.0.1:50051").is_ok());
|
||||||
|
assert!(enforce_transport_policy("http://localhost:50051").is_ok());
|
||||||
|
assert!(enforce_transport_policy("http://[::1]:50051").is_ok());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_http_requires_deliberate_override() {
|
||||||
|
with_vars([("LESAVKA_ALLOW_INSECURE", None::<&str>)], || {
|
||||||
|
let err = enforce_transport_policy("http://38.28.125.112:50051")
|
||||||
|
.expect_err("public http must be rejected");
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("refusing insecure relay transport")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
with_vars([("LESAVKA_ALLOW_INSECURE", Some("1"))], || {
|
||||||
|
assert!(enforce_transport_policy("http://38.28.125.112:50051").is_ok());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_domain_can_be_overridden_for_ip_backed_servers() {
|
||||||
|
with_vars([("LESAVKA_TLS_DOMAIN", None::<&str>)], || {
|
||||||
|
assert_eq!(tls_domain("https://38.28.125.112:50051"), "38.28.125.112");
|
||||||
|
});
|
||||||
|
with_vars([("LESAVKA_TLS_DOMAIN", Some("lesavka-server"))], || {
|
||||||
|
assert_eq!(tls_domain("https://38.28.125.112:50051"), "lesavka-server");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_reports_invalid_local_uri_after_security_policy_passes() {
|
||||||
|
let err = endpoint("http://[::1").expect_err("malformed local URI should fail");
|
||||||
|
assert!(err.to_string().contains("invalid relay server address"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_http_policy_handles_ipv4_ipv6_and_auth_shapes() {
|
||||||
|
assert!(is_local_http("http://user:pass@127.0.0.1:50051/path"));
|
||||||
|
assert!(is_local_http("http://[::1]:50051"));
|
||||||
|
assert!(is_local_http("http://0.0.0.0:50051"));
|
||||||
|
assert!(!is_local_http("https://127.0.0.1:50051"));
|
||||||
|
assert!(!is_local_http("http://38.28.125.112:50051"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_parser_covers_ipv6_auth_and_missing_scheme() {
|
||||||
|
assert_eq!(
|
||||||
|
host_from_uri("https://user@lesavka-server:50051"),
|
||||||
|
Some("lesavka-server")
|
||||||
|
);
|
||||||
|
assert_eq!(host_from_uri("https://[::1]:50051"), Some("::1"));
|
||||||
|
assert_eq!(host_from_uri("lesavka-server:50051"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn https_endpoint_reads_default_and_explicit_pki_paths_when_present() {
|
||||||
|
let dir = tempdir().expect("pki dir");
|
||||||
|
let pki = dir.path().join(".config/lesavka/pki");
|
||||||
|
std::fs::create_dir_all(&pki).expect("create pki");
|
||||||
|
std::fs::write(pki.join("ca.crt"), b"not a real ca").expect("ca");
|
||||||
|
std::fs::write(pki.join("client.crt"), b"not a real cert").expect("cert");
|
||||||
|
std::fs::write(pki.join("client.key"), b"not a real key").expect("key");
|
||||||
|
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("HOME", Some(dir.path().to_string_lossy().as_ref())),
|
||||||
|
("LESAVKA_TLS_CA", None::<&str>),
|
||||||
|
("LESAVKA_TLS_CLIENT_CERT", None::<&str>),
|
||||||
|
("LESAVKA_TLS_CLIENT_KEY", None::<&str>),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let err = endpoint("https://lesavka-server:50051")
|
||||||
|
.expect_err("fake PEM should be rejected after file discovery");
|
||||||
|
assert!(err.to_string().contains("configuring relay TLS"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("HOME", None::<&str>),
|
||||||
|
(
|
||||||
|
"LESAVKA_TLS_CA",
|
||||||
|
Some(pki.join("ca.crt").to_string_lossy().as_ref()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"LESAVKA_TLS_CLIENT_CERT",
|
||||||
|
Some(pki.join("client.crt").to_string_lossy().as_ref()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"LESAVKA_TLS_CLIENT_KEY",
|
||||||
|
Some(pki.join("client.key").to_string_lossy().as_ref()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let err = endpoint("https://lesavka-server:50051")
|
||||||
|
.expect_err("fake explicit PEM should be rejected after file discovery");
|
||||||
|
assert!(err.to_string().contains("configuring relay TLS"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -235,33 +235,31 @@ async fn runtime_probe_video_packets_change_across_a_pulse_boundary() {
|
|||||||
.expect("runtime capture");
|
.expect("runtime capture");
|
||||||
|
|
||||||
let video_queue = capture.video_queue();
|
let video_queue = capture.video_queue();
|
||||||
let mut dark_packet = None;
|
let mut darkest_packet = None;
|
||||||
let mut pulse_packet = None;
|
let mut brightest_packet = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let next = video_queue.pop_fresh().await;
|
let next = video_queue.pop_fresh().await;
|
||||||
let Some(packet) = next.packet else {
|
let Some(packet) = next.packet else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
if dark_packet.is_none() && (200_000..800_000).contains(&packet.pts) {
|
let mean = decode_mjpeg_packet_mean_luma(&packet);
|
||||||
dark_packet = Some(packet.clone());
|
if mean <= 40 && darkest_packet.is_none() {
|
||||||
|
darkest_packet = Some((packet.clone(), mean));
|
||||||
}
|
}
|
||||||
if pulse_packet.is_none() && (1_000_000..1_120_000).contains(&packet.pts) {
|
if mean >= 180 && brightest_packet.is_none() {
|
||||||
pulse_packet = Some(packet.clone());
|
brightest_packet = Some((packet.clone(), mean));
|
||||||
}
|
}
|
||||||
if dark_packet.is_some() && pulse_packet.is_some() {
|
if darkest_packet.is_some() && brightest_packet.is_some() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let dark_packet = dark_packet.expect("dark packet");
|
let (dark_packet, dark_mean) = darkest_packet.expect("dark packet");
|
||||||
let pulse_packet = pulse_packet.expect("pulse packet");
|
let (pulse_packet, pulse_mean) = brightest_packet.expect("pulse packet");
|
||||||
assert_ne!(dark_packet.data, pulse_packet.data);
|
assert_ne!(dark_packet.data, pulse_packet.data);
|
||||||
assert!(!dark_packet.data.is_empty());
|
assert!(!dark_packet.data.is_empty());
|
||||||
assert!(!pulse_packet.data.is_empty());
|
assert!(!pulse_packet.data.is_empty());
|
||||||
|
|
||||||
let dark_mean = decode_mjpeg_packet_mean_luma(&dark_packet);
|
|
||||||
let pulse_mean = decode_mjpeg_packet_mean_luma(&pulse_packet);
|
|
||||||
assert!(
|
assert!(
|
||||||
pulse_mean > dark_mean.saturating_add(100),
|
pulse_mean > dark_mean.saturating_add(100),
|
||||||
"expected decoded pulse frame to be much brighter than decoded dark frame, got dark={dark_mean} pulse={pulse_mean}"
|
"expected decoded pulse frame to be much brighter than decoded dark frame, got dark={dark_mean} pulse={pulse_mean}"
|
||||||
@ -271,6 +269,12 @@ async fn runtime_probe_video_packets_change_across_a_pulse_boundary() {
|
|||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() {
|
async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() {
|
||||||
|
let schedule = PulseSchedule::new(
|
||||||
|
Duration::from_secs(4),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_millis(120),
|
||||||
|
5,
|
||||||
|
);
|
||||||
let capture = SyncProbeCapture::new(
|
let capture = SyncProbeCapture::new(
|
||||||
CameraConfig {
|
CameraConfig {
|
||||||
codec: CameraCodec::Mjpeg,
|
codec: CameraCodec::Mjpeg,
|
||||||
@ -278,12 +282,7 @@ async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() {
|
|||||||
height: 480,
|
height: 480,
|
||||||
fps: 20,
|
fps: 20,
|
||||||
},
|
},
|
||||||
PulseSchedule::new(
|
schedule,
|
||||||
Duration::from_secs(4),
|
|
||||||
Duration::from_secs(1),
|
|
||||||
Duration::from_millis(120),
|
|
||||||
5,
|
|
||||||
),
|
|
||||||
Duration::from_secs(3),
|
Duration::from_secs(3),
|
||||||
)
|
)
|
||||||
.expect("runtime capture");
|
.expect("runtime capture");
|
||||||
@ -296,9 +295,6 @@ async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() {
|
|||||||
let Some(packet) = next.packet else {
|
let Some(packet) = next.packet else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
if packet.pts >= 1_000_000 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
dark_means.push(decode_mjpeg_packet_mean_luma(&packet));
|
dark_means.push(decode_mjpeg_packet_mean_luma(&packet));
|
||||||
if dark_means.len() >= 8 {
|
if dark_means.len() >= 8 {
|
||||||
break;
|
break;
|
||||||
@ -306,7 +302,7 @@ async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
dark_means.len() >= 4,
|
dark_means.len() >= 3,
|
||||||
"expected several dark packets before the first pulse, got {dark_means:?}"
|
"expected several dark packets before the first pulse, got {dark_means:?}"
|
||||||
);
|
);
|
||||||
let min = *dark_means.iter().min().expect("dark min");
|
let min = *dark_means.iter().min().expect("dark min");
|
||||||
|
|||||||
@ -148,8 +148,7 @@ async fn run_sync_probe(config: ProbeConfig) -> Result<()> {
|
|||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
async fn connect(server_addr: &str) -> Result<Channel> {
|
async fn connect(server_addr: &str) -> Result<Channel> {
|
||||||
Channel::from_shared(server_addr.to_string())
|
crate::relay_transport::endpoint(server_addr)?
|
||||||
.context("invalid relay server address")?
|
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.connect()
|
.connect()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.15.5"
|
version = "0.16.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
@ -9,7 +9,7 @@ name = "lesavka_common"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tonic = { version = "0.13", features = ["transport"] }
|
tonic = { version = "0.13", features = ["transport", "tls-ring", "tls-native-roots"] }
|
||||||
prost = "0.13"
|
prost = "0.13"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|||||||
@ -57,6 +57,38 @@ message SetCapturePowerRequest {
|
|||||||
CapturePowerCommand command = 2;
|
CapturePowerCommand command = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CalibrationAction {
|
||||||
|
CALIBRATION_ACTION_UNSPECIFIED = 0;
|
||||||
|
CALIBRATION_ACTION_RESTORE_DEFAULT = 1;
|
||||||
|
CALIBRATION_ACTION_RESTORE_FACTORY = 2;
|
||||||
|
CALIBRATION_ACTION_ADJUST_ACTIVE = 3;
|
||||||
|
CALIBRATION_ACTION_BLIND_ESTIMATE = 4;
|
||||||
|
CALIBRATION_ACTION_SAVE_ACTIVE_AS_DEFAULT = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CalibrationState {
|
||||||
|
string profile = 1;
|
||||||
|
int64 factory_audio_offset_us = 2;
|
||||||
|
int64 factory_video_offset_us = 3;
|
||||||
|
int64 default_audio_offset_us = 4;
|
||||||
|
int64 default_video_offset_us = 5;
|
||||||
|
int64 active_audio_offset_us = 6;
|
||||||
|
int64 active_video_offset_us = 7;
|
||||||
|
string source = 8;
|
||||||
|
string confidence = 9;
|
||||||
|
string updated_at = 10;
|
||||||
|
string detail = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CalibrationRequest {
|
||||||
|
CalibrationAction action = 1;
|
||||||
|
int64 audio_delta_us = 2;
|
||||||
|
int64 video_delta_us = 3;
|
||||||
|
float observed_delivery_skew_ms = 4;
|
||||||
|
float observed_enqueue_skew_ms = 5;
|
||||||
|
string note = 6;
|
||||||
|
}
|
||||||
|
|
||||||
message HandshakeSet {
|
message HandshakeSet {
|
||||||
bool camera = 1;
|
bool camera = 1;
|
||||||
bool microphone = 2;
|
bool microphone = 2;
|
||||||
@ -85,6 +117,8 @@ service Relay {
|
|||||||
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);
|
||||||
|
rpc GetCalibration (Empty) returns (CalibrationState);
|
||||||
|
rpc Calibrate (CalibrationRequest) returns (CalibrationState);
|
||||||
}
|
}
|
||||||
|
|
||||||
service Handshake {
|
service Handshake {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ This is the tracked inventory for `LESAVKA_*` knobs used by source, scripts, CI,
|
|||||||
Hardware-facing assumptions belong near the code that uses them; this file is the repo-wide index.
|
Hardware-facing assumptions belong near the code that uses them; this file is the repo-wide index.
|
||||||
| `LESAVKA_ALLOW_GADGET_CYCLE` | document near use before promoting to operator config |
|
| `LESAVKA_ALLOW_GADGET_CYCLE` | document near use before promoting to operator config |
|
||||||
| `LESAVKA_ALLOW_GADGET_RESET` | document near use before promoting to operator config |
|
| `LESAVKA_ALLOW_GADGET_RESET` | document near use before promoting to operator config |
|
||||||
|
| `LESAVKA_ALLOW_INSECURE` | client transport override; permits non-local `http://` relay URLs only for lab/debug use |
|
||||||
| `LESAVKA_ALSA_DEV` | server hardware/device override |
|
| `LESAVKA_ALSA_DEV` | server hardware/device override |
|
||||||
| `LESAVKA_ATTACH_WRITE_UDC` | server hardware/device override |
|
| `LESAVKA_ATTACH_WRITE_UDC` | server hardware/device override |
|
||||||
| `LESAVKA_AUDIO_AUTO_RECOVER_AFTER` | client media capture/playback override |
|
| `LESAVKA_AUDIO_AUTO_RECOVER_AFTER` | client media capture/playback override |
|
||||||
@ -42,10 +43,12 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_CAM_TEST_ENCODER` | client media capture/playback override |
|
| `LESAVKA_CAM_TEST_ENCODER` | client media capture/playback override |
|
||||||
| `LESAVKA_CAM_TEST_PATTERN` | client media capture/playback override |
|
| `LESAVKA_CAM_TEST_PATTERN` | client media capture/playback override |
|
||||||
| `LESAVKA_CAM_WIDTH` | client media capture/playback override |
|
| `LESAVKA_CAM_WIDTH` | client media capture/playback override |
|
||||||
|
| `LESAVKA_CALIBRATION_PATH` | server upstream A/V calibration storage path override |
|
||||||
| `LESAVKA_CAPTURE_POWER_GRACE_SECS` | runtime/install/session override |
|
| `LESAVKA_CAPTURE_POWER_GRACE_SECS` | runtime/install/session override |
|
||||||
| `LESAVKA_CAPTURE_POWER_UNIT` | runtime/install/session override |
|
| `LESAVKA_CAPTURE_POWER_UNIT` | runtime/install/session override |
|
||||||
| `LESAVKA_CAPTURE_REMOTE` | runtime/install/session override |
|
| `LESAVKA_CAPTURE_REMOTE` | runtime/install/session override |
|
||||||
| `LESAVKA_CLIENT_APP_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_CLIENT_APP_SRC` | test/build contract variable; not runtime operator config |
|
||||||
|
| `LESAVKA_CLIENT_BUNDLE` | server installer output path for the generated client TLS enrollment bundle |
|
||||||
| `LESAVKA_CLIENT_CAMERA_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_CLIENT_CAMERA_SRC` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_CLIENT_INPUTS_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_CLIENT_INPUTS_SRC` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_CLIENT_KEYBOARD_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_CLIENT_KEYBOARD_SRC` | test/build contract variable; not runtime operator config |
|
||||||
@ -55,6 +58,8 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_CLIENT_OUTPUT_AUDIO_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_CLIENT_OUTPUT_AUDIO_SRC` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_CLIENT_OUTPUT_DISPLAY_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_CLIENT_OUTPUT_DISPLAY_SRC` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_CLIENT_OUTPUT_VIDEO_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_CLIENT_OUTPUT_VIDEO_SRC` | test/build contract variable; not runtime operator config |
|
||||||
|
| `LESAVKA_CLIENT_PKI_BUNDLE` | client installer input path for a server-generated TLS enrollment bundle |
|
||||||
|
| `LESAVKA_CLIENT_PKI_DIR` | client installer/runtime TLS identity directory override |
|
||||||
| `LESAVKA_CLIENT_RELAYCTL_BIN_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_CLIENT_RELAYCTL_BIN_SRC` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_CLIENT_VIDEO_SUPPORT_SRC` | test/build contract variable; not runtime operator config |
|
| `LESAVKA_CLIENT_VIDEO_SUPPORT_SRC` | test/build contract variable; not runtime operator config |
|
||||||
| `LESAVKA_CLIPBOARD_CHORD` | input routing/clipboard override |
|
| `LESAVKA_CLIPBOARD_CHORD` | input routing/clipboard override |
|
||||||
@ -161,6 +166,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_PASTE_KEY_FILE` | input routing/clipboard override |
|
| `LESAVKA_PASTE_KEY_FILE` | input routing/clipboard override |
|
||||||
| `LESAVKA_PASTE_MAX` | input routing/clipboard override |
|
| `LESAVKA_PASTE_MAX` | input routing/clipboard override |
|
||||||
| `LESAVKA_PASTE_RPC` | input routing/clipboard override |
|
| `LESAVKA_PASTE_RPC` | input routing/clipboard override |
|
||||||
|
| `LESAVKA_PERFORMANCE_GATE_PUSHGATEWAY_JOB` | CI metrics destination override for latency/performance checks |
|
||||||
| `LESAVKA_PREVIEW_HEIGHT` | eye preview/video transport override |
|
| `LESAVKA_PREVIEW_HEIGHT` | eye preview/video transport override |
|
||||||
| `LESAVKA_PREVIEW_MAX_KBIT` | eye preview/video transport override |
|
| `LESAVKA_PREVIEW_MAX_KBIT` | eye preview/video transport override |
|
||||||
| `LESAVKA_PREVIEW_REQUEST_FPS` | eye preview/video transport override |
|
| `LESAVKA_PREVIEW_REQUEST_FPS` | eye preview/video transport override |
|
||||||
@ -170,6 +176,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_REF` | runtime/install/session override |
|
| `LESAVKA_REF` | runtime/install/session override |
|
||||||
| `LESAVKA_RELOAD_UVCVIDEO` | document near use before promoting to operator config |
|
| `LESAVKA_RELOAD_UVCVIDEO` | document near use before promoting to operator config |
|
||||||
| `LESAVKA_REPO_URL` | runtime/install/session override |
|
| `LESAVKA_REPO_URL` | runtime/install/session override |
|
||||||
|
| `LESAVKA_REQUIRE_TLS` | server security override; require TLS credentials before binding public relay service |
|
||||||
| `LESAVKA_RGBA` | document near use before promoting to operator config |
|
| `LESAVKA_RGBA` | document near use before promoting to operator config |
|
||||||
| `LESAVKA_SERVER_ADDR` | runtime/install/session override |
|
| `LESAVKA_SERVER_ADDR` | runtime/install/session override |
|
||||||
| `LESAVKA_SERVER_BIND_ADDR` | server bind address override; defaults to `0.0.0.0:50051` |
|
| `LESAVKA_SERVER_BIND_ADDR` | server bind address override; defaults to `0.0.0.0:50051` |
|
||||||
@ -183,6 +190,18 @@ Hardware-facing assumptions belong near the code that uses them; this file is th
|
|||||||
| `LESAVKA_SONAR_ENFORCE` | CI gate enforcement override |
|
| `LESAVKA_SONAR_ENFORCE` | CI gate enforcement override |
|
||||||
| `LESAVKA_SUPPLY_CHAIN_ENFORCE_TOOLS` | CI gate enforcement override |
|
| `LESAVKA_SUPPLY_CHAIN_ENFORCE_TOOLS` | CI gate enforcement override |
|
||||||
| `LESAVKA_TAP_AUDIO` | client media capture/playback override |
|
| `LESAVKA_TAP_AUDIO` | client media capture/playback override |
|
||||||
|
| `LESAVKA_TLS_CA` | client transport CA path override for relay TLS verification |
|
||||||
|
| `LESAVKA_TLS_CA_DAYS` | server installer certificate-authority lifetime override |
|
||||||
|
| `LESAVKA_TLS_CERT` | server TLS certificate path override |
|
||||||
|
| `LESAVKA_TLS_CERT_DAYS` | server installer leaf certificate lifetime override |
|
||||||
|
| `LESAVKA_TLS_CLIENT_AUTH_OPTIONAL` | server TLS override; allow clients without certs only for controlled migration/debug |
|
||||||
|
| `LESAVKA_TLS_CLIENT_CA` | server TLS client-CA path override for mTLS verification |
|
||||||
|
| `LESAVKA_TLS_CLIENT_CERT` | client transport certificate path override for mTLS |
|
||||||
|
| `LESAVKA_TLS_CLIENT_KEY` | client transport private-key path override for mTLS |
|
||||||
|
| `LESAVKA_TLS_DIR` | server installer/runtime TLS directory override |
|
||||||
|
| `LESAVKA_TLS_DOMAIN` | client transport SNI/domain override when dialing by IP |
|
||||||
|
| `LESAVKA_TLS_KEY` | server TLS private-key path override |
|
||||||
|
| `LESAVKA_TLS_SAN` | server installer extra certificate SAN list for additional relay hostnames/IPs |
|
||||||
| `LESAVKA_UAC_BUFFER_TIME_US` | server audio sink latency override |
|
| `LESAVKA_UAC_BUFFER_TIME_US` | server audio sink latency override |
|
||||||
| `LESAVKA_UAC_COMPENSATION_US` | server audio sink latency override |
|
| `LESAVKA_UAC_COMPENSATION_US` | server audio sink latency override |
|
||||||
| `LESAVKA_UAC_DEV` | server hardware/device override |
|
| `LESAVKA_UAC_DEV` | server hardware/device override |
|
||||||
|
|||||||
@ -8,17 +8,17 @@
|
|||||||
"client/src/app/audio_recovery_config.rs": {
|
"client/src/app/audio_recovery_config.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 82
|
"loc": 126
|
||||||
},
|
},
|
||||||
"client/src/app/downlink_media.rs": {
|
"client/src/app/downlink_media.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 208
|
"loc": 209
|
||||||
},
|
},
|
||||||
"client/src/app/input_streams.rs": {
|
"client/src/app/input_streams.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 102
|
"loc": 115
|
||||||
},
|
},
|
||||||
"client/src/app/session_lifecycle.rs": {
|
"client/src/app/session_lifecycle.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -28,7 +28,7 @@
|
|||||||
"client/src/app/uplink_media.rs": {
|
"client/src/app/uplink_media.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 224
|
"loc": 329
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -165,10 +165,15 @@
|
|||||||
"doc_debt": 14,
|
"doc_debt": 14,
|
||||||
"loc": 439
|
"loc": 439
|
||||||
},
|
},
|
||||||
|
"client/src/launcher/calibration.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 1,
|
||||||
|
"loc": 121
|
||||||
|
},
|
||||||
"client/src/launcher/clipboard.rs": {
|
"client/src/launcher/clipboard.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 178
|
"loc": 173
|
||||||
},
|
},
|
||||||
"client/src/launcher/device_test.rs": {
|
"client/src/launcher/device_test.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -198,12 +203,12 @@
|
|||||||
"client/src/launcher/diagnostics.rs": {
|
"client/src/launcher/diagnostics.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 8
|
"loc": 9
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics/diagnostics_models.rs": {
|
"client/src/launcher/diagnostics/diagnostics_models.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 170
|
"loc": 185
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics/recommendations.rs": {
|
"client/src/launcher/diagnostics/recommendations.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -212,13 +217,18 @@
|
|||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics/snapshot_report.rs": {
|
"client/src/launcher/diagnostics/snapshot_report.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 2,
|
||||||
"loc": 465
|
"loc": 286
|
||||||
|
},
|
||||||
|
"client/src/launcher/diagnostics/snapshot_report_text.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 2,
|
||||||
|
"loc": 292
|
||||||
},
|
},
|
||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 5,
|
"doc_debt": 5,
|
||||||
"loc": 244
|
"loc": 246
|
||||||
},
|
},
|
||||||
"client/src/launcher/power.rs": {
|
"client/src/launcher/power.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -252,18 +262,23 @@
|
|||||||
},
|
},
|
||||||
"client/src/launcher/preview/status_pipeline.rs": {
|
"client/src/launcher/preview/status_pipeline.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 9,
|
"doc_debt": 8,
|
||||||
"loc": 284
|
"loc": 259
|
||||||
},
|
},
|
||||||
"client/src/launcher/state.rs": {
|
"client/src/launcher/state.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 8
|
"loc": 9
|
||||||
},
|
},
|
||||||
"client/src/launcher/state/launcher_state_impl.rs": {
|
"client/src/launcher/state/launcher_state_impl.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 17,
|
"doc_debt": 17,
|
||||||
"loc": 465
|
"loc": 456
|
||||||
|
},
|
||||||
|
"client/src/launcher/state/launcher_status_line.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 1,
|
||||||
|
"loc": 48
|
||||||
},
|
},
|
||||||
"client/src/launcher/state/profile_helpers.rs": {
|
"client/src/launcher/state/profile_helpers.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -273,27 +288,27 @@
|
|||||||
"client/src/launcher/state/selection_models.rs": {
|
"client/src/launcher/state/selection_models.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 15,
|
"doc_debt": 15,
|
||||||
"loc": 380
|
"loc": 456
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 182
|
"loc": 193
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui/activation_context.rs": {
|
"client/src/launcher/ui/activation_context.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 37
|
"loc": 41
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui/activation_setup.rs": {
|
"client/src/launcher/ui/activation_setup.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 169
|
"loc": 178
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui/control_requests.rs": {
|
"client/src/launcher/ui/control_requests.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 1,
|
||||||
"loc": 165
|
"loc": 214
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui/device_refresh_binding.rs": {
|
"client/src/launcher/ui/device_refresh_binding.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -305,6 +320,11 @@
|
|||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 161
|
"loc": 161
|
||||||
},
|
},
|
||||||
|
"client/src/launcher/ui/eye_capture_bindings.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 9,
|
||||||
|
"loc": 471
|
||||||
|
},
|
||||||
"client/src/launcher/ui/eye_display_bindings.rs": {
|
"client/src/launcher/ui/eye_display_bindings.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
@ -323,7 +343,7 @@
|
|||||||
"client/src/launcher/ui/message_and_network_state.rs": {
|
"client/src/launcher/ui/message_and_network_state.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 130
|
"loc": 136
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui/power_display_key_bindings.rs": {
|
"client/src/launcher/ui/power_display_key_bindings.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -343,7 +363,7 @@
|
|||||||
"client/src/launcher/ui/runtime_poll.rs": {
|
"client/src/launcher/ui/runtime_poll.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 375
|
"loc": 449
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui/session_preview_coverage.rs": {
|
"client/src/launcher/ui/session_preview_coverage.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -353,7 +373,7 @@
|
|||||||
"client/src/launcher/ui/stage_device_bindings.rs": {
|
"client/src/launcher/ui/stage_device_bindings.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 174
|
"loc": 176
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui/startup_window_guard.rs": {
|
"client/src/launcher/ui/startup_window_guard.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -363,37 +383,37 @@
|
|||||||
"client/src/launcher/ui/utility_button_bindings.rs": {
|
"client/src/launcher/ui/utility_button_bindings.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 197
|
"loc": 387
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components.rs": {
|
"client/src/launcher/ui_components.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 110
|
"loc": 124
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/assemble_view.rs": {
|
"client/src/launcher/ui_components/assemble_view.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 189
|
"loc": 204
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/build_contexts.rs": {
|
"client/src/launcher/ui_components/build_contexts.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 73
|
"loc": 87
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/build_device_controls.rs": {
|
"client/src/launcher/ui_components/build_device_controls.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 394
|
"loc": 407
|
||||||
},
|
},
|
||||||
"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": 228
|
"loc": 310
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/build_shell.rs": {
|
"client/src/launcher/ui_components/build_shell.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 111
|
"loc": 132
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/combo_helpers.rs": {
|
"client/src/launcher/ui_components/combo_helpers.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -408,12 +428,12 @@
|
|||||||
"client/src/launcher/ui_components/display_pane.rs": {
|
"client/src/launcher/ui_components/display_pane.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 209
|
"loc": 235
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/panel_chips.rs": {
|
"client/src/launcher/ui_components/panel_chips.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 0,
|
||||||
"loc": 79
|
"loc": 102
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/scale_reset.rs": {
|
"client/src/launcher/ui_components/scale_reset.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -428,7 +448,7 @@
|
|||||||
"client/src/launcher/ui_components/types.rs": {
|
"client/src/launcher/ui_components/types.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 201
|
"loc": 221
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime.rs": {
|
"client/src/launcher/ui_runtime.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -443,7 +463,7 @@
|
|||||||
"client/src/launcher/ui_runtime/display_popouts.rs": {
|
"client/src/launcher/ui_runtime/display_popouts.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 5,
|
"doc_debt": 5,
|
||||||
"loc": 270
|
"loc": 273
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime/log_filtering.rs": {
|
"client/src/launcher/ui_runtime/log_filtering.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -462,13 +482,13 @@
|
|||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime/status_details.rs": {
|
"client/src/launcher/ui_runtime/status_details.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 13,
|
"doc_debt": 12,
|
||||||
"loc": 284
|
"loc": 345
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime/status_refresh.rs": {
|
"client/src/launcher/ui_runtime/status_refresh.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 285
|
"loc": 316
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -478,7 +498,7 @@
|
|||||||
"client/src/lib.rs": {
|
"client/src/lib.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 24
|
"loc": 25
|
||||||
},
|
},
|
||||||
"client/src/live_capture_clock.rs": {
|
"client/src/live_capture_clock.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -530,6 +550,11 @@
|
|||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 82
|
"loc": 82
|
||||||
},
|
},
|
||||||
|
"client/src/relay_transport.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 7,
|
||||||
|
"loc": 257
|
||||||
|
},
|
||||||
"client/src/sync_probe/analyze.rs": {
|
"client/src/sync_probe/analyze.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
@ -588,7 +613,7 @@
|
|||||||
"client/src/sync_probe/capture/tests.rs": {
|
"client/src/sync_probe/capture/tests.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 5,
|
"doc_debt": 5,
|
||||||
"loc": 208
|
"loc": 209
|
||||||
},
|
},
|
||||||
"client/src/sync_probe/config.rs": {
|
"client/src/sync_probe/config.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -603,7 +628,7 @@
|
|||||||
"client/src/sync_probe/runner.rs": {
|
"client/src/sync_probe/runner.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 222
|
"loc": 221
|
||||||
},
|
},
|
||||||
"client/src/sync_probe/schedule.rs": {
|
"client/src/sync_probe/schedule.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -618,7 +643,7 @@
|
|||||||
"client/src/uplink_latency_harness.rs": {
|
"client/src/uplink_latency_harness.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 5,
|
"doc_debt": 5,
|
||||||
"loc": 270
|
"loc": 284
|
||||||
},
|
},
|
||||||
"client/src/uplink_telemetry.rs": {
|
"client/src/uplink_telemetry.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -703,13 +728,18 @@
|
|||||||
"server/src/bin/lesavka_uvc/coverage_startup.rs": {
|
"server/src/bin/lesavka_uvc/coverage_startup.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 5,
|
"doc_debt": 5,
|
||||||
"loc": 110
|
"loc": 129
|
||||||
},
|
},
|
||||||
"server/src/bin/lesavka_uvc/payload_limits.rs": {
|
"server/src/bin/lesavka_uvc/payload_limits.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 74
|
"loc": 74
|
||||||
},
|
},
|
||||||
|
"server/src/calibration.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 12,
|
||||||
|
"loc": 467
|
||||||
|
},
|
||||||
"server/src/camera.rs": {
|
"server/src/camera.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
@ -773,17 +803,17 @@
|
|||||||
"server/src/lib.rs": {
|
"server/src/lib.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 20
|
"loc": 22
|
||||||
},
|
},
|
||||||
"server/src/main.rs": {
|
"server/src/main.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 96
|
"loc": 99
|
||||||
},
|
},
|
||||||
"server/src/main/entrypoint.rs": {
|
"server/src/main/entrypoint.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 45
|
"loc": 49
|
||||||
},
|
},
|
||||||
"server/src/main/eye_hub.rs": {
|
"server/src/main/eye_hub.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -798,22 +828,27 @@
|
|||||||
"server/src/main/handler_startup.rs": {
|
"server/src/main/handler_startup.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 136
|
"loc": 140
|
||||||
},
|
},
|
||||||
"server/src/main/relay_service.rs": {
|
"server/src/main/relay_service.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 6,
|
"doc_debt": 5,
|
||||||
"loc": 490
|
"loc": 485
|
||||||
},
|
},
|
||||||
"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": 281
|
"loc": 301
|
||||||
|
},
|
||||||
|
"server/src/main/relay_service_tests.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 1,
|
||||||
|
"loc": 30
|
||||||
},
|
},
|
||||||
"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": 105
|
"loc": 118
|
||||||
},
|
},
|
||||||
"server/src/main/usb_recovery_helpers.rs": {
|
"server/src/main/usb_recovery_helpers.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -850,15 +885,25 @@
|
|||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 90
|
"loc": 90
|
||||||
},
|
},
|
||||||
|
"server/src/security.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 6,
|
||||||
|
"loc": 211
|
||||||
|
},
|
||||||
"server/src/upstream_media_runtime.rs": {
|
"server/src/upstream_media_runtime.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 4,
|
"doc_debt": 2,
|
||||||
"loc": 495
|
"loc": 392
|
||||||
},
|
},
|
||||||
"server/src/upstream_media_runtime/config.rs": {
|
"server/src/upstream_media_runtime/config.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 4,
|
"doc_debt": 4,
|
||||||
"loc": 79
|
"loc": 88
|
||||||
|
},
|
||||||
|
"server/src/upstream_media_runtime/lease_lifecycle.rs": {
|
||||||
|
"clippy_warnings": 0,
|
||||||
|
"doc_debt": 3,
|
||||||
|
"loc": 142
|
||||||
},
|
},
|
||||||
"server/src/upstream_media_runtime/state.rs": {
|
"server/src/upstream_media_runtime/state.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -868,7 +913,7 @@
|
|||||||
"server/src/upstream_media_runtime/tests.rs": {
|
"server/src/upstream_media_runtime/tests.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 13
|
"loc": 19
|
||||||
},
|
},
|
||||||
"server/src/upstream_media_runtime/types.rs": {
|
"server/src/upstream_media_runtime/types.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -888,7 +933,7 @@
|
|||||||
"server/src/uvc_runtime.rs": {
|
"server/src/uvc_runtime.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 4,
|
"doc_debt": 4,
|
||||||
"loc": 251
|
"loc": 255
|
||||||
},
|
},
|
||||||
"server/src/video.rs": {
|
"server/src/video.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
|
|||||||
74
scripts/ci/performance_gate.sh
Executable file
74
scripts/ci/performance_gate.sh
Executable file
@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run deterministic latency/performance contracts and publish gate metrics.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
REPORT_DIR="${ROOT_DIR}/target/performance-gate"
|
||||||
|
TEST_LOG="${REPORT_DIR}/cargo-test.log"
|
||||||
|
METRICS_FILE="${REPORT_DIR}/metrics.prom"
|
||||||
|
PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-}
|
||||||
|
PUSHGATEWAY_JOB=${LESAVKA_PERFORMANCE_GATE_PUSHGATEWAY_JOB:-lesavka-performance-gate}
|
||||||
|
|
||||||
|
mkdir -p "${REPORT_DIR}"
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
branch=${BRANCH_NAME:-${GIT_BRANCH:-}}
|
||||||
|
if [[ -z "${branch}" ]]; then
|
||||||
|
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
|
||||||
|
fi
|
||||||
|
commit=${GIT_COMMIT:-}
|
||||||
|
if [[ -z "${commit}" ]]; then
|
||||||
|
commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
|
fi
|
||||||
|
|
||||||
|
PERFORMANCE_TESTS=(
|
||||||
|
--test client_uplink_performance_contract
|
||||||
|
--test client_uplink_freshness_contract
|
||||||
|
--test client_log_noise_contract
|
||||||
|
--test video_downstream_feed_contract
|
||||||
|
--test client_app_include_contract
|
||||||
|
)
|
||||||
|
|
||||||
|
start_seconds=$(date +%s)
|
||||||
|
status=0
|
||||||
|
set +e
|
||||||
|
cargo test -p lesavka_testing "${PERFORMANCE_TESTS[@]}" --color never 2>&1 | tee "${TEST_LOG}"
|
||||||
|
status=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
duration_seconds=$(($(date +%s) - start_seconds))
|
||||||
|
|
||||||
|
python3 - "${METRICS_FILE}" "${status}" "${duration_seconds}" "${branch}" "${commit}" <<'PY'
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
metrics_path = pathlib.Path(sys.argv[1])
|
||||||
|
status = int(sys.argv[2])
|
||||||
|
duration_seconds = int(sys.argv[3])
|
||||||
|
branch = sys.argv[4]
|
||||||
|
commit = sys.argv[5]
|
||||||
|
|
||||||
|
def esc(value: str) -> str:
|
||||||
|
return value.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')
|
||||||
|
|
||||||
|
labels = f'suite="lesavka",branch="{esc(branch)}",commit="{esc(commit)}"'
|
||||||
|
ok = 1 if status == 0 else 0
|
||||||
|
failed = 0 if status == 0 else 1
|
||||||
|
metrics = [
|
||||||
|
'# HELP platform_quality_gate_checks_total Check outcomes from the latest lesavka gate run.',
|
||||||
|
'# TYPE platform_quality_gate_checks_total gauge',
|
||||||
|
f'platform_quality_gate_checks_total{{{labels},check="performance",status="ok"}} {ok}',
|
||||||
|
f'platform_quality_gate_checks_total{{{labels},check="performance",status="failed"}} {failed}',
|
||||||
|
'# HELP lesavka_performance_gate_duration_seconds Runtime of the deterministic performance gate.',
|
||||||
|
'# TYPE lesavka_performance_gate_duration_seconds gauge',
|
||||||
|
f'lesavka_performance_gate_duration_seconds{{{labels}}} {duration_seconds}',
|
||||||
|
]
|
||||||
|
metrics_path.write_text('\n'.join(metrics) + '\n', encoding='utf-8')
|
||||||
|
PY
|
||||||
|
|
||||||
|
if [[ -n "${PUSHGATEWAY_URL}" ]]; then
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--data-binary @"${METRICS_FILE}" \
|
||||||
|
"${PUSHGATEWAY_URL%/}/metrics/job/${PUSHGATEWAY_JOB}/suite/lesavka" || status=$?
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "${status}"
|
||||||
@ -8,6 +8,7 @@ cd "${ROOT_DIR}"
|
|||||||
scripts/ci/hygiene_gate.sh
|
scripts/ci/hygiene_gate.sh
|
||||||
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/quality_gate.sh
|
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/quality_gate.sh
|
||||||
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/test_gate.sh
|
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/test_gate.sh
|
||||||
|
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/performance_gate.sh
|
||||||
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/media_reliability_gate.sh
|
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/media_reliability_gate.sh
|
||||||
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/gate_glue_gate.sh
|
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/gate_glue_gate.sh
|
||||||
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/sonarqube_gate.sh
|
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/sonarqube_gate.sh
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"files": {
|
"files": {
|
||||||
"client/src/app/audio_recovery_config.rs": {
|
"client/src/app/audio_recovery_config.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 82
|
"loc": 126
|
||||||
},
|
},
|
||||||
"client/src/app/session_lifecycle.rs": {
|
"client/src/app/session_lifecycle.rs": {
|
||||||
"line_percent": 97.56,
|
"line_percent": 97.56,
|
||||||
@ -102,7 +102,7 @@
|
|||||||
},
|
},
|
||||||
"client/src/launcher/clipboard.rs": {
|
"client/src/launcher/clipboard.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 178
|
"loc": 173
|
||||||
},
|
},
|
||||||
"client/src/launcher/devices.rs": {
|
"client/src/launcher/devices.rs": {
|
||||||
"line_percent": 96.74,
|
"line_percent": 96.74,
|
||||||
@ -110,35 +110,43 @@
|
|||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics/diagnostics_models.rs": {
|
"client/src/launcher/diagnostics/diagnostics_models.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 170
|
"loc": 185
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics/recommendations.rs": {
|
"client/src/launcher/diagnostics/recommendations.rs": {
|
||||||
"line_percent": 97.62,
|
"line_percent": 97.62,
|
||||||
"loc": 277
|
"loc": 277
|
||||||
},
|
},
|
||||||
"client/src/launcher/diagnostics/snapshot_report.rs": {
|
"client/src/launcher/diagnostics/snapshot_report.rs": {
|
||||||
"line_percent": 98.22,
|
"line_percent": 98.31,
|
||||||
"loc": 465
|
"loc": 286
|
||||||
|
},
|
||||||
|
"client/src/launcher/diagnostics/snapshot_report_text.rs": {
|
||||||
|
"line_percent": 96.69,
|
||||||
|
"loc": 292
|
||||||
},
|
},
|
||||||
"client/src/launcher/mod.rs": {
|
"client/src/launcher/mod.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 244
|
"loc": 246
|
||||||
},
|
},
|
||||||
"client/src/launcher/state/launcher_state_impl.rs": {
|
"client/src/launcher/state/launcher_state_impl.rs": {
|
||||||
"line_percent": 95.91,
|
"line_percent": 100.0,
|
||||||
"loc": 465
|
"loc": 456
|
||||||
|
},
|
||||||
|
"client/src/launcher/state/launcher_status_line.rs": {
|
||||||
|
"line_percent": 96.3,
|
||||||
|
"loc": 48
|
||||||
},
|
},
|
||||||
"client/src/launcher/state/profile_helpers.rs": {
|
"client/src/launcher/state/profile_helpers.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 244
|
"loc": 244
|
||||||
},
|
},
|
||||||
"client/src/launcher/state/selection_models.rs": {
|
"client/src/launcher/state/selection_models.rs": {
|
||||||
"line_percent": 99.42,
|
"line_percent": 99.53,
|
||||||
"loc": 380
|
"loc": 456
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui.rs": {
|
"client/src/launcher/ui.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 182
|
"loc": 193
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui/session_preview_coverage.rs": {
|
"client/src/launcher/ui/session_preview_coverage.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
@ -180,6 +188,10 @@
|
|||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 82
|
"loc": 82
|
||||||
},
|
},
|
||||||
|
"client/src/relay_transport.rs": {
|
||||||
|
"line_percent": 95.54,
|
||||||
|
"loc": 257
|
||||||
|
},
|
||||||
"client/src/sync_probe/analyze.rs": {
|
"client/src/sync_probe/analyze.rs": {
|
||||||
"line_percent": 97.92,
|
"line_percent": 97.92,
|
||||||
"loc": 87
|
"loc": 87
|
||||||
@ -222,7 +234,7 @@
|
|||||||
},
|
},
|
||||||
"client/src/sync_probe/runner.rs": {
|
"client/src/sync_probe/runner.rs": {
|
||||||
"line_percent": 95.65,
|
"line_percent": 95.65,
|
||||||
"loc": 222
|
"loc": 221
|
||||||
},
|
},
|
||||||
"client/src/sync_probe/schedule.rs": {
|
"client/src/sync_probe/schedule.rs": {
|
||||||
"line_percent": 98.74,
|
"line_percent": 98.74,
|
||||||
@ -233,8 +245,8 @@
|
|||||||
"loc": 288
|
"loc": 288
|
||||||
},
|
},
|
||||||
"client/src/uplink_latency_harness.rs": {
|
"client/src/uplink_latency_harness.rs": {
|
||||||
"line_percent": 98.65,
|
"line_percent": 98.73,
|
||||||
"loc": 270
|
"loc": 284
|
||||||
},
|
},
|
||||||
"client/src/uplink_telemetry.rs": {
|
"client/src/uplink_telemetry.rs": {
|
||||||
"line_percent": 95.76,
|
"line_percent": 95.76,
|
||||||
@ -294,12 +306,16 @@
|
|||||||
},
|
},
|
||||||
"server/src/bin/lesavka_uvc/coverage_startup.rs": {
|
"server/src/bin/lesavka_uvc/coverage_startup.rs": {
|
||||||
"line_percent": 98.99,
|
"line_percent": 98.99,
|
||||||
"loc": 128
|
"loc": 129
|
||||||
},
|
},
|
||||||
"server/src/bin/lesavka_uvc/payload_limits.rs": {
|
"server/src/bin/lesavka_uvc/payload_limits.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 74
|
"loc": 74
|
||||||
},
|
},
|
||||||
|
"server/src/calibration.rs": {
|
||||||
|
"line_percent": 99.72,
|
||||||
|
"loc": 467
|
||||||
|
},
|
||||||
"server/src/camera.rs": {
|
"server/src/camera.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 132
|
"loc": 132
|
||||||
@ -342,11 +358,11 @@
|
|||||||
},
|
},
|
||||||
"server/src/main.rs": {
|
"server/src/main.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 96
|
"loc": 99
|
||||||
},
|
},
|
||||||
"server/src/main/entrypoint.rs": {
|
"server/src/main/entrypoint.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 45
|
"loc": 49
|
||||||
},
|
},
|
||||||
"server/src/main/eye_hub.rs": {
|
"server/src/main/eye_hub.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
@ -358,19 +374,19 @@
|
|||||||
},
|
},
|
||||||
"server/src/main/handler_startup.rs": {
|
"server/src/main/handler_startup.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 136
|
"loc": 140
|
||||||
},
|
},
|
||||||
"server/src/main/relay_service.rs": {
|
"server/src/main/relay_service.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 499
|
"loc": 485
|
||||||
},
|
},
|
||||||
"server/src/main/relay_service_coverage.rs": {
|
"server/src/main/relay_service_coverage.rs": {
|
||||||
"line_percent": 95.86,
|
"line_percent": 96.53,
|
||||||
"loc": 287
|
"loc": 301
|
||||||
},
|
},
|
||||||
"server/src/main/rpc_helpers.rs": {
|
"server/src/main/rpc_helpers.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 105
|
"loc": 118
|
||||||
},
|
},
|
||||||
"server/src/main/usb_recovery_helpers.rs": {
|
"server/src/main/usb_recovery_helpers.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
@ -396,13 +412,21 @@
|
|||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 90
|
"loc": 90
|
||||||
},
|
},
|
||||||
|
"server/src/security.rs": {
|
||||||
|
"line_percent": 97.44,
|
||||||
|
"loc": 211
|
||||||
|
},
|
||||||
"server/src/upstream_media_runtime.rs": {
|
"server/src/upstream_media_runtime.rs": {
|
||||||
"line_percent": 98.04,
|
"line_percent": 97.36,
|
||||||
"loc": 495
|
"loc": 392
|
||||||
},
|
},
|
||||||
"server/src/upstream_media_runtime/config.rs": {
|
"server/src/upstream_media_runtime/config.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
"loc": 79
|
"loc": 88
|
||||||
|
},
|
||||||
|
"server/src/upstream_media_runtime/lease_lifecycle.rs": {
|
||||||
|
"line_percent": 100.0,
|
||||||
|
"loc": 142
|
||||||
},
|
},
|
||||||
"server/src/uvc_runtime.rs": {
|
"server/src/uvc_runtime.rs": {
|
||||||
"line_percent": 97.53,
|
"line_percent": 97.53,
|
||||||
|
|||||||
@ -30,7 +30,7 @@ set +e
|
|||||||
cargo build --workspace --bins --color never 2>&1 | tee "${TEST_LOG}"
|
cargo build --workspace --bins --color never 2>&1 | tee "${TEST_LOG}"
|
||||||
build_status=${PIPESTATUS[0]}
|
build_status=${PIPESTATUS[0]}
|
||||||
if [[ "${build_status}" -eq 0 ]]; then
|
if [[ "${build_status}" -eq 0 ]]; then
|
||||||
cargo test --workspace --all-targets --color never 2>&1 | tee -a "${TEST_LOG}"
|
RUST_TEST_THREADS="${RUST_TEST_THREADS:-1}" cargo test --workspace --all-targets --color never 2>&1 | tee -a "${TEST_LOG}"
|
||||||
status=${PIPESTATUS[0]}
|
status=${PIPESTATUS[0]}
|
||||||
else
|
else
|
||||||
status=${build_status}
|
status=${build_status}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ REPO_URL=${LESAVKA_REPO_URL:-}
|
|||||||
SRC=/var/src/lesavka
|
SRC=/var/src/lesavka
|
||||||
export TMPDIR=${TMPDIR:-/var/tmp}
|
export TMPDIR=${TMPDIR:-/var/tmp}
|
||||||
USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
|
USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
|
||||||
|
CLIENT_PKI_DIR=${LESAVKA_CLIENT_PKI_DIR:-$USER_HOME/.config/lesavka/pki}
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '==> %s\n' "$*"
|
printf '==> %s\n' "$*"
|
||||||
@ -102,6 +103,36 @@ run_as_user() {
|
|||||||
sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@"
|
sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_client_pki_bundle() {
|
||||||
|
local bundle=${LESAVKA_CLIENT_PKI_BUNDLE:-}
|
||||||
|
if [[ -z $bundle ]]; then
|
||||||
|
if [[ -s "$CLIENT_PKI_DIR/ca.crt" && -s "$CLIENT_PKI_DIR/client.crt" && -s "$CLIENT_PKI_DIR/client.key" ]]; then
|
||||||
|
echo " ↪ TLS client identity already present: $CLIENT_PKI_DIR"
|
||||||
|
else
|
||||||
|
echo "⚠️ no LESAVKA_CLIENT_PKI_BUNDLE supplied; HTTPS relay connections will need a trusted public cert or a bundle install later."
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "5b. Installing TLS client identity"
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
sudo tar -xzf "$bundle" -C "$tmp"
|
||||||
|
for item in ca.crt client.crt client.key; do
|
||||||
|
if [[ ! -s "$tmp/$item" ]]; then
|
||||||
|
echo "❌ TLS client bundle $bundle is missing $item" >&2
|
||||||
|
sudo rm -rf "$tmp"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
sudo install -d -m 0700 -o "$ORIG_USER" -g "$ORIG_USER" "$CLIENT_PKI_DIR"
|
||||||
|
sudo install -m 0644 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/ca.crt" "$CLIENT_PKI_DIR/ca.crt"
|
||||||
|
sudo install -m 0644 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/client.crt" "$CLIENT_PKI_DIR/client.crt"
|
||||||
|
sudo install -m 0600 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/client.key" "$CLIENT_PKI_DIR/client.key"
|
||||||
|
sudo rm -rf "$tmp"
|
||||||
|
echo " ↪ installed TLS client identity: $CLIENT_PKI_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
mkdir -p "$TMPDIR"
|
mkdir -p "$TMPDIR"
|
||||||
|
|
||||||
if [[ -z $REPO_URL ]] && [[ -d $SCRIPT_REPO_ROOT/.git ]]; then
|
if [[ -z $REPO_URL ]] && [[ -d $SCRIPT_REPO_ROOT/.git ]]; then
|
||||||
@ -114,7 +145,7 @@ sudo pacman -Sq --needed --noconfirm \
|
|||||||
git rustup protobuf abseil-cpp gcc clang llvm-libs compiler-rt evtest base-devel libpulse \
|
git rustup protobuf abseil-cpp gcc clang llvm-libs compiler-rt evtest base-devel libpulse \
|
||||||
pipewire pipewire-pulse wireplumber alsa-utils gst-plugin-pipewire \
|
pipewire pipewire-pulse wireplumber alsa-utils gst-plugin-pipewire \
|
||||||
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \
|
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \
|
||||||
wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils
|
wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils openssl
|
||||||
|
|
||||||
ensure_yay() {
|
ensure_yay() {
|
||||||
if command -v yay >/dev/null 2>&1; then
|
if command -v yay >/dev/null 2>&1; then
|
||||||
@ -201,6 +232,7 @@ run_as_user env TMPDIR="$TMPDIR" bash -c "cd '$SRC/client' && cargo clean && car
|
|||||||
log "5. Installing launchable client binaries"
|
log "5. Installing launchable client binaries"
|
||||||
sudo install -Dm755 "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client
|
sudo install -Dm755 "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client
|
||||||
sudo ln -sf /usr/local/bin/lesavka-client /usr/local/bin/lesavka
|
sudo ln -sf /usr/local/bin/lesavka-client /usr/local/bin/lesavka
|
||||||
|
install_client_pki_bundle
|
||||||
|
|
||||||
log "6. Registering desktop application"
|
log "6. Registering desktop application"
|
||||||
sudo install -Dm644 "$SRC/client/assets/icons/hicolor/1024x1024/apps/lesavka.png" \
|
sudo install -Dm644 "$SRC/client/assets/icons/hicolor/1024x1024/apps/lesavka.png" \
|
||||||
@ -232,6 +264,7 @@ echo " Binary: /usr/local/bin/lesavka-client"
|
|||||||
echo " Launch alias: /usr/local/bin/lesavka"
|
echo " Launch alias: /usr/local/bin/lesavka"
|
||||||
echo " Desktop entry: /usr/share/applications/lesavka.desktop"
|
echo " Desktop entry: /usr/share/applications/lesavka.desktop"
|
||||||
echo " Build source: $SRC/target/release/lesavka-client"
|
echo " Build source: $SRC/target/release/lesavka-client"
|
||||||
|
echo " TLS identity: $CLIENT_PKI_DIR"
|
||||||
echo "✅ Installed version: lesavka-client ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}"
|
echo "✅ Installed version: lesavka-client ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}"
|
||||||
echo
|
echo
|
||||||
echo "Quick start:"
|
echo "Quick start:"
|
||||||
|
|||||||
@ -12,6 +12,8 @@ REPO_URL=${LESAVKA_REPO_URL:-}
|
|||||||
USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
|
USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
|
||||||
INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}
|
INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}
|
||||||
INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}
|
INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}
|
||||||
|
LESAVKA_TLS_DIR=${LESAVKA_TLS_DIR:-/etc/lesavka/pki}
|
||||||
|
LESAVKA_CLIENT_BUNDLE=${LESAVKA_CLIENT_BUNDLE:-/etc/lesavka/lesavka-client-pki.tar.gz}
|
||||||
|
|
||||||
manifest_package_version() {
|
manifest_package_version() {
|
||||||
local manifest=$1
|
local manifest=$1
|
||||||
@ -41,6 +43,135 @@ LESAVKA_UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-0}
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
append_san_entry() {
|
||||||
|
local value=$1
|
||||||
|
[[ -n $value ]] || return 0
|
||||||
|
case "$value" in
|
||||||
|
IP:*)
|
||||||
|
TLS_SAN_IPS+=("${value#IP:}")
|
||||||
|
;;
|
||||||
|
DNS:*)
|
||||||
|
TLS_SAN_DNS+=("${value#DNS:}")
|
||||||
|
;;
|
||||||
|
*[!0-9.]*)
|
||||||
|
TLS_SAN_DNS+=("$value")
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
TLS_SAN_IPS+=("$value")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
render_server_cert_ext() {
|
||||||
|
local ext_file=$1
|
||||||
|
local dns_index=1
|
||||||
|
local ip_index=1
|
||||||
|
{
|
||||||
|
echo "basicConstraints = CA:FALSE"
|
||||||
|
echo "keyUsage = digitalSignature,keyEncipherment"
|
||||||
|
echo "extendedKeyUsage = serverAuth"
|
||||||
|
echo "subjectAltName = @alt_names"
|
||||||
|
echo "[alt_names]"
|
||||||
|
local value
|
||||||
|
for value in "${TLS_SAN_DNS[@]}"; do
|
||||||
|
[[ -n $value ]] || continue
|
||||||
|
printf 'DNS.%d = %s\n' "$dns_index" "$value"
|
||||||
|
dns_index=$((dns_index + 1))
|
||||||
|
done
|
||||||
|
for value in "${TLS_SAN_IPS[@]}"; do
|
||||||
|
[[ -n $value ]] || continue
|
||||||
|
printf 'IP.%d = %s\n' "$ip_index" "$value"
|
||||||
|
ip_index=$((ip_index + 1))
|
||||||
|
done
|
||||||
|
} >"$ext_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_server_tls_pki() {
|
||||||
|
echo "==> 5c. TLS/mTLS identity"
|
||||||
|
sudo install -d -m 0750 "$LESAVKA_TLS_DIR"
|
||||||
|
|
||||||
|
if ! sudo test -s "$LESAVKA_TLS_DIR/ca.key" || ! sudo test -s "$LESAVKA_TLS_DIR/ca.crt"; then
|
||||||
|
echo " ↪ generating Lesavka local CA"
|
||||||
|
sudo openssl genrsa -out "$LESAVKA_TLS_DIR/ca.key" 4096 >/dev/null 2>&1
|
||||||
|
sudo openssl req -x509 -new -nodes \
|
||||||
|
-key "$LESAVKA_TLS_DIR/ca.key" \
|
||||||
|
-sha256 -days "${LESAVKA_TLS_CA_DAYS:-3650}" \
|
||||||
|
-subj "/CN=Lesavka Local Relay CA" \
|
||||||
|
-out "$LESAVKA_TLS_DIR/ca.crt" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TLS_SAN_DNS=(lesavka-server "$(hostname -s 2>/dev/null || true)" "$(hostname -f 2>/dev/null || true)")
|
||||||
|
TLS_SAN_IPS=(127.0.0.1 38.28.125.112)
|
||||||
|
local extra_san
|
||||||
|
IFS=',' read -r -a extra_san <<<"${LESAVKA_TLS_SAN:-}"
|
||||||
|
local san
|
||||||
|
for san in "${extra_san[@]}"; do
|
||||||
|
append_san_entry "${san//[[:space:]]/}"
|
||||||
|
done
|
||||||
|
|
||||||
|
local ext_file client_ext_file
|
||||||
|
ext_file=$(mktemp)
|
||||||
|
client_ext_file=$(mktemp)
|
||||||
|
render_server_cert_ext "$ext_file"
|
||||||
|
{
|
||||||
|
echo "basicConstraints = CA:FALSE"
|
||||||
|
echo "keyUsage = digitalSignature,keyEncipherment"
|
||||||
|
echo "extendedKeyUsage = clientAuth"
|
||||||
|
} >"$client_ext_file"
|
||||||
|
|
||||||
|
if ! sudo test -s "$LESAVKA_TLS_DIR/server.key" || ! sudo test -s "$LESAVKA_TLS_DIR/server.crt"; then
|
||||||
|
echo " ↪ generating server certificate"
|
||||||
|
sudo openssl genrsa -out "$LESAVKA_TLS_DIR/server.key" 2048 >/dev/null 2>&1
|
||||||
|
sudo openssl req -new \
|
||||||
|
-key "$LESAVKA_TLS_DIR/server.key" \
|
||||||
|
-subj "/CN=lesavka-server" \
|
||||||
|
-out "$LESAVKA_TLS_DIR/server.csr" >/dev/null 2>&1
|
||||||
|
sudo openssl x509 -req \
|
||||||
|
-in "$LESAVKA_TLS_DIR/server.csr" \
|
||||||
|
-CA "$LESAVKA_TLS_DIR/ca.crt" \
|
||||||
|
-CAkey "$LESAVKA_TLS_DIR/ca.key" \
|
||||||
|
-CAcreateserial \
|
||||||
|
-out "$LESAVKA_TLS_DIR/server.crt" \
|
||||||
|
-days "${LESAVKA_TLS_CERT_DAYS:-825}" \
|
||||||
|
-sha256 \
|
||||||
|
-extfile "$ext_file" >/dev/null 2>&1
|
||||||
|
sudo rm -f "$LESAVKA_TLS_DIR/server.csr"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sudo test -s "$LESAVKA_TLS_DIR/client.key" || ! sudo test -s "$LESAVKA_TLS_DIR/client.crt"; then
|
||||||
|
echo " ↪ generating default client certificate"
|
||||||
|
sudo openssl genrsa -out "$LESAVKA_TLS_DIR/client.key" 2048 >/dev/null 2>&1
|
||||||
|
sudo openssl req -new \
|
||||||
|
-key "$LESAVKA_TLS_DIR/client.key" \
|
||||||
|
-subj "/CN=lesavka-client" \
|
||||||
|
-out "$LESAVKA_TLS_DIR/client.csr" >/dev/null 2>&1
|
||||||
|
sudo openssl x509 -req \
|
||||||
|
-in "$LESAVKA_TLS_DIR/client.csr" \
|
||||||
|
-CA "$LESAVKA_TLS_DIR/ca.crt" \
|
||||||
|
-CAkey "$LESAVKA_TLS_DIR/ca.key" \
|
||||||
|
-CAcreateserial \
|
||||||
|
-out "$LESAVKA_TLS_DIR/client.crt" \
|
||||||
|
-days "${LESAVKA_TLS_CERT_DAYS:-825}" \
|
||||||
|
-sha256 \
|
||||||
|
-extfile "$client_ext_file" >/dev/null 2>&1
|
||||||
|
sudo rm -f "$LESAVKA_TLS_DIR/client.csr"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo chmod 0600 "$LESAVKA_TLS_DIR/"*.key
|
||||||
|
sudo chmod 0644 "$LESAVKA_TLS_DIR/"*.crt
|
||||||
|
rm -f "$ext_file" "$client_ext_file"
|
||||||
|
|
||||||
|
local bundle_tmp
|
||||||
|
bundle_tmp=$(mktemp -d)
|
||||||
|
sudo cp "$LESAVKA_TLS_DIR/ca.crt" "$bundle_tmp/ca.crt"
|
||||||
|
sudo cp "$LESAVKA_TLS_DIR/client.crt" "$bundle_tmp/client.crt"
|
||||||
|
sudo cp "$LESAVKA_TLS_DIR/client.key" "$bundle_tmp/client.key"
|
||||||
|
sudo tar -C "$bundle_tmp" -czf "$LESAVKA_CLIENT_BUNDLE" ca.crt client.crt client.key
|
||||||
|
sudo chmod 0640 "$LESAVKA_CLIENT_BUNDLE"
|
||||||
|
sudo rm -rf "$bundle_tmp"
|
||||||
|
echo " ↪ client enrollment bundle: $LESAVKA_CLIENT_BUNDLE"
|
||||||
|
}
|
||||||
|
|
||||||
find_uvc_output_node() {
|
find_uvc_output_node() {
|
||||||
local by_path_root=/dev/v4l/by-path
|
local by_path_root=/dev/v4l/by-path
|
||||||
local ctrl=""
|
local ctrl=""
|
||||||
@ -641,7 +772,8 @@ sudo pacman -Sq --needed --noconfirm git \
|
|||||||
gst-plugins-ugly \
|
gst-plugins-ugly \
|
||||||
gst-libav \
|
gst-libav \
|
||||||
tcpdump \
|
tcpdump \
|
||||||
lsof
|
lsof \
|
||||||
|
openssl
|
||||||
if ! command -v yay >/dev/null 2>&1; then
|
if ! command -v yay >/dev/null 2>&1; then
|
||||||
echo "==> 1b. installing yay from AUR ..."
|
echo "==> 1b. installing yay from AUR ..."
|
||||||
run_as_user env TMPDIR="$TMPDIR" bash -c '
|
run_as_user env TMPDIR="$TMPDIR" bash -c '
|
||||||
@ -772,6 +904,7 @@ sudo install -Dm755 "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/loca
|
|||||||
|
|
||||||
echo "==> 5b. Runtime environment defaults"
|
echo "==> 5b. Runtime environment defaults"
|
||||||
sudo install -d -m 0755 /etc/lesavka
|
sudo install -d -m 0755 /etc/lesavka
|
||||||
|
ensure_server_tls_pki
|
||||||
HDMI_CONNECTOR=$(resolve_hdmi_connector)
|
HDMI_CONNECTOR=$(resolve_hdmi_connector)
|
||||||
if [[ -n $HDMI_CONNECTOR ]]; then
|
if [[ -n $HDMI_CONNECTOR ]]; then
|
||||||
echo " ↪ HDMI connector: $HDMI_CONNECTOR"
|
echo " ↪ HDMI connector: $HDMI_CONNECTOR"
|
||||||
@ -806,6 +939,10 @@ fi
|
|||||||
printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}"
|
printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}"
|
||||||
printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}"
|
printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}"
|
||||||
printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}"
|
printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}"
|
||||||
|
printf 'LESAVKA_REQUIRE_TLS=%s\n' "${LESAVKA_REQUIRE_TLS:-1}"
|
||||||
|
printf 'LESAVKA_TLS_CERT=%s\n' "${LESAVKA_TLS_CERT:-$LESAVKA_TLS_DIR/server.crt}"
|
||||||
|
printf 'LESAVKA_TLS_KEY=%s\n' "${LESAVKA_TLS_KEY:-$LESAVKA_TLS_DIR/server.key}"
|
||||||
|
printf 'LESAVKA_TLS_CLIENT_CA=%s\n' "${LESAVKA_TLS_CLIENT_CA:-$LESAVKA_TLS_DIR/ca.crt}"
|
||||||
} | sudo tee /etc/lesavka/server.env >/dev/null
|
} | sudo tee /etc/lesavka/server.env >/dev/null
|
||||||
|
|
||||||
UVC_ENV_TMP=$(mktemp)
|
UVC_ENV_TMP=$(mktemp)
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
# - Optional: if THEIA_HOST is set, ssh to show core/server status + hidg/uvc presence
|
# - Optional: if THEIA_HOST is set, ssh to show core/server status + hidg/uvc presence
|
||||||
#
|
#
|
||||||
# Env:
|
# Env:
|
||||||
# LESAVKA_SERVER_ADDR (default http://38.28.125.112:50051)
|
# LESAVKA_SERVER_ADDR (default https://38.28.125.112:50051)
|
||||||
# ITER=0 (loop forever) or number of iterations
|
# ITER=0 (loop forever) or number of iterations
|
||||||
# SLEEP=10 (seconds between iterations)
|
# SLEEP=10 (seconds between iterations)
|
||||||
# TETHYS_HOST=host (ssh target for target machine; requires key auth)
|
# TETHYS_HOST=host (ssh target for target machine; requires key auth)
|
||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVER=${LESAVKA_SERVER_ADDR:-http://38.28.125.112:50051}
|
SERVER=${LESAVKA_SERVER_ADDR:-https://38.28.125.112:50051}
|
||||||
# default to a few iterations instead of infinite to avoid unintentional long runs
|
# default to a few iterations instead of infinite to avoid unintentional long runs
|
||||||
ITER=${ITER:-5}
|
ITER=${ITER:-5}
|
||||||
SLEEP=${SLEEP:-10}
|
SLEEP=${SLEEP:-10}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
|||||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)"
|
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)"
|
||||||
|
|
||||||
TETHYS_HOST=${TETHYS_HOST:-tethys}
|
TETHYS_HOST=${TETHYS_HOST:-tethys}
|
||||||
LESAVKA_SERVER_ADDR=${LESAVKA_SERVER_ADDR:-http://38.28.125.112:50051}
|
LESAVKA_SERVER_ADDR=${LESAVKA_SERVER_ADDR:-https://38.28.125.112:50051}
|
||||||
PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-15}
|
PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-15}
|
||||||
BROWSER_PORT=${BROWSER_PORT:-18443}
|
BROWSER_PORT=${BROWSER_PORT:-18443}
|
||||||
REMOTE_SCRIPT=${REMOTE_SCRIPT:-/tmp/lesavka-browser-consumer-probe.py}
|
REMOTE_SCRIPT=${REMOTE_SCRIPT:-/tmp/lesavka-browser-consumer-probe.py}
|
||||||
|
|||||||
@ -10,14 +10,14 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.15.5"
|
version = "0.16.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.45", features = ["full", "fs"] }
|
tokio = { version = "1.45", features = ["full", "fs"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
tonic = { version = "0.13", features = ["transport"] }
|
tonic = { version = "0.13", features = ["transport", "tls-ring", "tls-native-roots"] }
|
||||||
tonic-reflection = "0.13"
|
tonic-reflection = "0.13"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
lesavka_common = { path = "../common" }
|
lesavka_common = { path = "../common" }
|
||||||
|
|||||||
@ -114,6 +114,7 @@ fn open_with_retry(path: &str) -> Result<std::fs::File> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
|
/// Keep coverage-mode UVC control opens read-only unless a test opts into writes.
|
||||||
fn uvc_control_read_only() -> bool {
|
fn uvc_control_read_only() -> bool {
|
||||||
env::var("LESAVKA_UVC_CONTROL_READ_ONLY")
|
env::var("LESAVKA_UVC_CONTROL_READ_ONLY")
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
467
server/src/calibration.rs
Normal file
467
server/src/calibration.rs
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::Utc;
|
||||||
|
use lesavka_common::lesavka::{
|
||||||
|
CalibrationAction, CalibrationRequest, CalibrationState as ProtoCalibrationState,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::upstream_media_runtime::UpstreamMediaRuntime;
|
||||||
|
|
||||||
|
pub const FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = -45_000;
|
||||||
|
pub const FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 0;
|
||||||
|
const PROFILE: &str = "mjpeg";
|
||||||
|
const FACTORY_CONFIDENCE: &str = "factory";
|
||||||
|
const OFFSET_LIMIT_US: i64 = 500_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct CalibrationSnapshot {
|
||||||
|
profile: String,
|
||||||
|
factory_audio_offset_us: i64,
|
||||||
|
factory_video_offset_us: i64,
|
||||||
|
default_audio_offset_us: i64,
|
||||||
|
default_video_offset_us: i64,
|
||||||
|
active_audio_offset_us: i64,
|
||||||
|
active_video_offset_us: i64,
|
||||||
|
source: String,
|
||||||
|
confidence: String,
|
||||||
|
updated_at: String,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CalibrationStore {
|
||||||
|
path: PathBuf,
|
||||||
|
runtime: Arc<UpstreamMediaRuntime>,
|
||||||
|
state: Mutex<CalibrationSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalibrationStore {
|
||||||
|
pub fn load(runtime: Arc<UpstreamMediaRuntime>) -> Self {
|
||||||
|
let path = calibration_path();
|
||||||
|
let state = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|raw| parse_snapshot(&raw))
|
||||||
|
.unwrap_or_else(snapshot_from_env);
|
||||||
|
runtime.set_playout_offsets(state.active_video_offset_us, state.active_audio_offset_us);
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
runtime,
|
||||||
|
state: Mutex::new(state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current(&self) -> ProtoCalibrationState {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.expect("calibration mutex poisoned")
|
||||||
|
.to_proto()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&self, request: CalibrationRequest) -> Result<ProtoCalibrationState> {
|
||||||
|
let mut state = self.state.lock().expect("calibration mutex poisoned");
|
||||||
|
let action =
|
||||||
|
CalibrationAction::try_from(request.action).unwrap_or(CalibrationAction::Unspecified);
|
||||||
|
match action {
|
||||||
|
CalibrationAction::Unspecified => {}
|
||||||
|
CalibrationAction::RestoreDefault => {
|
||||||
|
state.active_audio_offset_us = state.default_audio_offset_us;
|
||||||
|
state.active_video_offset_us = state.default_video_offset_us;
|
||||||
|
state.source = "default".to_string();
|
||||||
|
state.confidence = "saved-default".to_string();
|
||||||
|
state.detail = "restored saved upstream A/V calibration".to_string();
|
||||||
|
touch(&mut state);
|
||||||
|
}
|
||||||
|
CalibrationAction::RestoreFactory => {
|
||||||
|
state.active_audio_offset_us = state.factory_audio_offset_us;
|
||||||
|
state.active_video_offset_us = state.factory_video_offset_us;
|
||||||
|
state.source = "factory".to_string();
|
||||||
|
state.confidence = FACTORY_CONFIDENCE.to_string();
|
||||||
|
state.detail = "restored release-shipped MJPEG upstream calibration".to_string();
|
||||||
|
touch(&mut state);
|
||||||
|
}
|
||||||
|
CalibrationAction::AdjustActive => {
|
||||||
|
state.active_audio_offset_us = clamp_offset(
|
||||||
|
state
|
||||||
|
.active_audio_offset_us
|
||||||
|
.saturating_add(request.audio_delta_us),
|
||||||
|
);
|
||||||
|
state.active_video_offset_us = clamp_offset(
|
||||||
|
state
|
||||||
|
.active_video_offset_us
|
||||||
|
.saturating_add(request.video_delta_us),
|
||||||
|
);
|
||||||
|
state.source = "manual".to_string();
|
||||||
|
state.confidence = "manual".to_string();
|
||||||
|
state.detail = format!(
|
||||||
|
"manual upstream A/V calibration nudge: audio {:+.1}ms, video {:+.1}ms",
|
||||||
|
request.audio_delta_us as f64 / 1000.0,
|
||||||
|
request.video_delta_us as f64 / 1000.0
|
||||||
|
);
|
||||||
|
touch(&mut state);
|
||||||
|
}
|
||||||
|
CalibrationAction::BlindEstimate => {
|
||||||
|
state.active_audio_offset_us = clamp_offset(
|
||||||
|
state
|
||||||
|
.active_audio_offset_us
|
||||||
|
.saturating_add(request.audio_delta_us),
|
||||||
|
);
|
||||||
|
state.active_video_offset_us = clamp_offset(
|
||||||
|
state
|
||||||
|
.active_video_offset_us
|
||||||
|
.saturating_add(request.video_delta_us),
|
||||||
|
);
|
||||||
|
state.source = "blind".to_string();
|
||||||
|
state.confidence = "estimated".to_string();
|
||||||
|
state.detail = if request.note.trim().is_empty() {
|
||||||
|
format!(
|
||||||
|
"blind estimate applied from relay telemetry: delivery skew {:.1}ms, enqueue skew {:.1}ms",
|
||||||
|
request.observed_delivery_skew_ms, request.observed_enqueue_skew_ms
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
request.note
|
||||||
|
};
|
||||||
|
touch(&mut state);
|
||||||
|
}
|
||||||
|
CalibrationAction::SaveActiveAsDefault => {
|
||||||
|
state.default_audio_offset_us = state.active_audio_offset_us;
|
||||||
|
state.default_video_offset_us = state.active_video_offset_us;
|
||||||
|
state.source = "default".to_string();
|
||||||
|
state.confidence = "measured".to_string();
|
||||||
|
state.detail = "saved current upstream A/V calibration as site default".to_string();
|
||||||
|
touch(&mut state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.runtime
|
||||||
|
.set_playout_offsets(state.active_video_offset_us, state.active_audio_offset_us);
|
||||||
|
persist_snapshot(&self.path, &state)?;
|
||||||
|
Ok(state.to_proto())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalibrationSnapshot {
|
||||||
|
fn to_proto(&self) -> ProtoCalibrationState {
|
||||||
|
ProtoCalibrationState {
|
||||||
|
profile: self.profile.clone(),
|
||||||
|
factory_audio_offset_us: self.factory_audio_offset_us,
|
||||||
|
factory_video_offset_us: self.factory_video_offset_us,
|
||||||
|
default_audio_offset_us: self.default_audio_offset_us,
|
||||||
|
default_video_offset_us: self.default_video_offset_us,
|
||||||
|
active_audio_offset_us: self.active_audio_offset_us,
|
||||||
|
active_video_offset_us: self.active_video_offset_us,
|
||||||
|
source: self.source.clone(),
|
||||||
|
confidence: self.confidence.clone(),
|
||||||
|
updated_at: self.updated_at.clone(),
|
||||||
|
detail: self.detail.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calibration_path() -> PathBuf {
|
||||||
|
std::env::var("LESAVKA_CALIBRATION_PATH")
|
||||||
|
.ok()
|
||||||
|
.filter(|path| !path.trim().is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/var/lib/lesavka/calibration.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_from_env() -> CalibrationSnapshot {
|
||||||
|
let env_audio = env_i64("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US");
|
||||||
|
let env_video = env_i64("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US");
|
||||||
|
let default_audio_offset_us = env_audio.unwrap_or(FACTORY_MJPEG_AUDIO_OFFSET_US);
|
||||||
|
let default_video_offset_us = env_video.unwrap_or(FACTORY_MJPEG_VIDEO_OFFSET_US);
|
||||||
|
let source = if env_audio.is_some() || env_video.is_some() {
|
||||||
|
"env".to_string()
|
||||||
|
} else {
|
||||||
|
"factory".to_string()
|
||||||
|
};
|
||||||
|
let confidence = if source == "factory" {
|
||||||
|
FACTORY_CONFIDENCE.to_string()
|
||||||
|
} else {
|
||||||
|
"configured".to_string()
|
||||||
|
};
|
||||||
|
CalibrationSnapshot {
|
||||||
|
profile: PROFILE.to_string(),
|
||||||
|
factory_audio_offset_us: FACTORY_MJPEG_AUDIO_OFFSET_US,
|
||||||
|
factory_video_offset_us: FACTORY_MJPEG_VIDEO_OFFSET_US,
|
||||||
|
default_audio_offset_us,
|
||||||
|
default_video_offset_us,
|
||||||
|
active_audio_offset_us: default_audio_offset_us,
|
||||||
|
active_video_offset_us: default_video_offset_us,
|
||||||
|
source,
|
||||||
|
confidence,
|
||||||
|
updated_at: Utc::now().to_rfc3339(),
|
||||||
|
detail: "loaded upstream A/V calibration defaults".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_snapshot(raw: &str) -> CalibrationSnapshot {
|
||||||
|
let fallback = snapshot_from_env();
|
||||||
|
let value = |key: &str| -> Option<String> {
|
||||||
|
raw.lines().find_map(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
let (left, right) = trimmed.split_once('=')?;
|
||||||
|
(left.trim() == key).then(|| right.trim().trim_matches('"').to_string())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let number = |key: &str, default: i64| -> i64 {
|
||||||
|
value(key)
|
||||||
|
.and_then(|raw| raw.parse::<i64>().ok())
|
||||||
|
.map(clamp_offset)
|
||||||
|
.unwrap_or(default)
|
||||||
|
};
|
||||||
|
CalibrationSnapshot {
|
||||||
|
profile: value("profile").unwrap_or(fallback.profile),
|
||||||
|
factory_audio_offset_us: FACTORY_MJPEG_AUDIO_OFFSET_US,
|
||||||
|
factory_video_offset_us: FACTORY_MJPEG_VIDEO_OFFSET_US,
|
||||||
|
default_audio_offset_us: number(
|
||||||
|
"default_audio_offset_us",
|
||||||
|
fallback.default_audio_offset_us,
|
||||||
|
),
|
||||||
|
default_video_offset_us: number(
|
||||||
|
"default_video_offset_us",
|
||||||
|
fallback.default_video_offset_us,
|
||||||
|
),
|
||||||
|
active_audio_offset_us: number("active_audio_offset_us", fallback.active_audio_offset_us),
|
||||||
|
active_video_offset_us: number("active_video_offset_us", fallback.active_video_offset_us),
|
||||||
|
source: value("source").unwrap_or(fallback.source),
|
||||||
|
confidence: value("confidence").unwrap_or(fallback.confidence),
|
||||||
|
updated_at: value("updated_at").unwrap_or(fallback.updated_at),
|
||||||
|
detail: value("detail").unwrap_or(fallback.detail),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_snapshot(path: &PathBuf, state: &CalibrationSnapshot) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("creating calibration directory {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, serialize_snapshot(state))
|
||||||
|
.with_context(|| format!("writing calibration state {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_snapshot(state: &CalibrationSnapshot) -> String {
|
||||||
|
format!(
|
||||||
|
"profile=\"{}\"\ndefault_audio_offset_us={}\ndefault_video_offset_us={}\nactive_audio_offset_us={}\nactive_video_offset_us={}\nsource=\"{}\"\nconfidence=\"{}\"\nupdated_at=\"{}\"\ndetail=\"{}\"\n",
|
||||||
|
escape_value(&state.profile),
|
||||||
|
state.default_audio_offset_us,
|
||||||
|
state.default_video_offset_us,
|
||||||
|
state.active_audio_offset_us,
|
||||||
|
state.active_video_offset_us,
|
||||||
|
escape_value(&state.source),
|
||||||
|
escape_value(&state.confidence),
|
||||||
|
escape_value(&state.updated_at),
|
||||||
|
escape_value(&state.detail),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn touch(state: &mut CalibrationSnapshot) {
|
||||||
|
state.updated_at = Utc::now().to_rfc3339();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_i64(name: &str) -> Option<i64> {
|
||||||
|
std::env::var(name)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.trim().parse::<i64>().ok())
|
||||||
|
.map(clamp_offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_offset(value: i64) -> i64 {
|
||||||
|
value.clamp(-OFFSET_LIMIT_US, OFFSET_LIMIT_US)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_value(value: &str) -> String {
|
||||||
|
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_snapshot_uses_factory_mjpeg_calibration() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", None::<&str>),
|
||||||
|
("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", None::<&str>),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let state = snapshot_from_env();
|
||||||
|
assert_eq!(state.default_audio_offset_us, -45_000);
|
||||||
|
assert_eq!(state.active_video_offset_us, 0);
|
||||||
|
assert_eq!(state.source, "factory");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_persists_manual_adjustments_and_updates_runtime() {
|
||||||
|
let file = NamedTempFile::new().expect("temp calibration file");
|
||||||
|
let path = file.path().to_string_lossy().to_string();
|
||||||
|
temp_env::with_var("LESAVKA_CALIBRATION_PATH", Some(path.as_str()), || {
|
||||||
|
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
||||||
|
let store = CalibrationStore::load(runtime.clone());
|
||||||
|
let state = store
|
||||||
|
.apply(CalibrationRequest {
|
||||||
|
action: CalibrationAction::AdjustActive as i32,
|
||||||
|
audio_delta_us: -5_000,
|
||||||
|
video_delta_us: 0,
|
||||||
|
observed_delivery_skew_ms: 0.0,
|
||||||
|
observed_enqueue_skew_ms: 0.0,
|
||||||
|
note: String::new(),
|
||||||
|
})
|
||||||
|
.expect("manual adjust applies");
|
||||||
|
assert_eq!(state.active_audio_offset_us, -50_000);
|
||||||
|
assert_eq!(runtime.playout_offsets(), (0, -50_000));
|
||||||
|
let raw = std::fs::read_to_string(file.path()).expect("persisted calibration");
|
||||||
|
assert!(raw.contains("active_audio_offset_us=-50000"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calibration_path_uses_default_for_blank_override() {
|
||||||
|
temp_env::with_var("LESAVKA_CALIBRATION_PATH", Some(""), || {
|
||||||
|
assert_eq!(
|
||||||
|
calibration_path(),
|
||||||
|
PathBuf::from("/var/lib/lesavka/calibration.toml")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_from_env_uses_configured_offsets_and_clamps_extremes() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", Some("-999999")),
|
||||||
|
("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", Some("12345")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let state = snapshot_from_env();
|
||||||
|
assert_eq!(state.default_audio_offset_us, -500_000);
|
||||||
|
assert_eq!(state.default_video_offset_us, 12_345);
|
||||||
|
assert_eq!(state.source, "env");
|
||||||
|
assert_eq!(state.confidence, "configured");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_snapshot_falls_back_for_missing_and_malformed_fields() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", None::<&str>),
|
||||||
|
("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", None::<&str>),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let state = parse_snapshot(
|
||||||
|
r#"
|
||||||
|
profile="mjpeg"
|
||||||
|
default_audio_offset_us=bad
|
||||||
|
default_video_offset_us=2500
|
||||||
|
active_audio_offset_us=-600000
|
||||||
|
source="saved"
|
||||||
|
detail="loaded \"quoted\" value"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(state.default_audio_offset_us, FACTORY_MJPEG_AUDIO_OFFSET_US);
|
||||||
|
assert_eq!(state.default_video_offset_us, 2_500);
|
||||||
|
assert_eq!(state.active_audio_offset_us, -500_000);
|
||||||
|
assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US);
|
||||||
|
assert_eq!(state.source, "saved");
|
||||||
|
assert_eq!(state.confidence, FACTORY_CONFIDENCE);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_applies_all_calibration_actions_and_persists_defaults() {
|
||||||
|
let dir = tempfile::tempdir().expect("calibration dir");
|
||||||
|
let path = dir.path().join("calibration.toml");
|
||||||
|
let path_string = path.to_string_lossy().to_string();
|
||||||
|
temp_env::with_var(
|
||||||
|
"LESAVKA_CALIBRATION_PATH",
|
||||||
|
Some(path_string.as_str()),
|
||||||
|
|| {
|
||||||
|
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
||||||
|
let store = CalibrationStore::load(runtime.clone());
|
||||||
|
|
||||||
|
let blind = store
|
||||||
|
.apply(CalibrationRequest {
|
||||||
|
action: CalibrationAction::BlindEstimate as i32,
|
||||||
|
audio_delta_us: 5_000,
|
||||||
|
video_delta_us: -2_000,
|
||||||
|
observed_delivery_skew_ms: 44.0,
|
||||||
|
observed_enqueue_skew_ms: 3.5,
|
||||||
|
note: String::new(),
|
||||||
|
})
|
||||||
|
.expect("blind estimate");
|
||||||
|
assert_eq!(blind.source, "blind");
|
||||||
|
assert!(blind.detail.contains("delivery skew 44.0ms"));
|
||||||
|
assert_eq!(runtime.playout_offsets(), (-2_000, -40_000));
|
||||||
|
|
||||||
|
let manual = store
|
||||||
|
.apply(CalibrationRequest {
|
||||||
|
action: CalibrationAction::AdjustActive as i32,
|
||||||
|
audio_delta_us: 999_999,
|
||||||
|
video_delta_us: 0,
|
||||||
|
observed_delivery_skew_ms: 0.0,
|
||||||
|
observed_enqueue_skew_ms: 0.0,
|
||||||
|
note: String::new(),
|
||||||
|
})
|
||||||
|
.expect("manual clamp");
|
||||||
|
assert_eq!(manual.active_audio_offset_us, 500_000);
|
||||||
|
|
||||||
|
let saved = store
|
||||||
|
.apply(CalibrationRequest {
|
||||||
|
action: CalibrationAction::SaveActiveAsDefault as i32,
|
||||||
|
..CalibrationRequest::default()
|
||||||
|
})
|
||||||
|
.expect("save default");
|
||||||
|
assert_eq!(saved.default_audio_offset_us, saved.active_audio_offset_us);
|
||||||
|
assert_eq!(saved.confidence, "measured");
|
||||||
|
|
||||||
|
let factory = store
|
||||||
|
.apply(CalibrationRequest {
|
||||||
|
action: CalibrationAction::RestoreFactory as i32,
|
||||||
|
..CalibrationRequest::default()
|
||||||
|
})
|
||||||
|
.expect("factory restore");
|
||||||
|
assert_eq!(
|
||||||
|
factory.active_audio_offset_us,
|
||||||
|
FACTORY_MJPEG_AUDIO_OFFSET_US
|
||||||
|
);
|
||||||
|
assert_eq!(factory.source, "factory");
|
||||||
|
|
||||||
|
let restored = store
|
||||||
|
.apply(CalibrationRequest {
|
||||||
|
action: CalibrationAction::RestoreDefault as i32,
|
||||||
|
..CalibrationRequest::default()
|
||||||
|
})
|
||||||
|
.expect("default restore");
|
||||||
|
assert_eq!(
|
||||||
|
restored.active_audio_offset_us,
|
||||||
|
restored.default_audio_offset_us
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.current().active_audio_offset_us,
|
||||||
|
restored.active_audio_offset_us
|
||||||
|
);
|
||||||
|
|
||||||
|
let no_op = store
|
||||||
|
.apply(CalibrationRequest::default())
|
||||||
|
.expect("unspecified action is ok");
|
||||||
|
assert_eq!(
|
||||||
|
no_op.active_audio_offset_us,
|
||||||
|
restored.active_audio_offset_us
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = std::fs::read_to_string(&path).expect("persisted actions");
|
||||||
|
assert!(raw.contains("confidence="));
|
||||||
|
assert!(raw.contains("detail="));
|
||||||
|
assert_eq!(escape_value("a\\b\"c"), "a\\\\b\\\"c");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ pub const BUILD_ID: &str = env!("LESAVKA_GIT_SHA");
|
|||||||
pub const FULL_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "+", env!("LESAVKA_GIT_SHA"));
|
pub const FULL_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "+", env!("LESAVKA_GIT_SHA"));
|
||||||
|
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
|
pub mod calibration;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
pub mod camera_runtime;
|
pub mod camera_runtime;
|
||||||
pub mod capture_power;
|
pub mod capture_power;
|
||||||
@ -13,6 +14,7 @@ pub mod handshake;
|
|||||||
pub(crate) mod media_timing;
|
pub(crate) mod media_timing;
|
||||||
pub mod paste;
|
pub mod paste;
|
||||||
pub mod runtime_support;
|
pub mod runtime_support;
|
||||||
|
pub mod security;
|
||||||
pub mod upstream_media_runtime;
|
pub mod upstream_media_runtime;
|
||||||
pub mod uvc_runtime;
|
pub mod uvc_runtime;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
|||||||
@ -18,14 +18,16 @@ use tonic_reflection::server::Builder as ReflBuilder;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use lesavka_common::lesavka::{
|
use lesavka_common::lesavka::{
|
||||||
AudioPacket, CapturePowerCommand, CapturePowerState, Empty, KeyboardReport, MonitorRequest,
|
AudioPacket, CalibrationRequest, CalibrationState, CapturePowerCommand, CapturePowerState,
|
||||||
MouseReport, PasteReply, PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket,
|
Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, PasteRequest, ResetUsbReply,
|
||||||
|
SetCapturePowerRequest, VideoPacket,
|
||||||
relay_server::{Relay, RelayServer},
|
relay_server::{Relay, RelayServer},
|
||||||
};
|
};
|
||||||
|
|
||||||
use lesavka_server::{
|
use lesavka_server::{
|
||||||
camera, camera_runtime::CameraRuntime, capture_power::CapturePowerManager, gadget::UsbGadget,
|
calibration::CalibrationStore, camera, camera_runtime::CameraRuntime,
|
||||||
handshake::HandshakeSvc, paste, runtime_support, runtime_support::init_tracing,
|
capture_power::CapturePowerManager, gadget::UsbGadget, handshake::HandshakeSvc, paste,
|
||||||
|
runtime_support, runtime_support::init_tracing, security,
|
||||||
upstream_media_runtime::UpstreamMediaRuntime, uvc_runtime, video,
|
upstream_media_runtime::UpstreamMediaRuntime, uvc_runtime, video,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,6 +68,7 @@ struct Handler {
|
|||||||
did_cycle: Arc<AtomicBool>,
|
did_cycle: Arc<AtomicBool>,
|
||||||
camera_rt: Arc<CameraRuntime>,
|
camera_rt: Arc<CameraRuntime>,
|
||||||
upstream_media_rt: Arc<UpstreamMediaRuntime>,
|
upstream_media_rt: Arc<UpstreamMediaRuntime>,
|
||||||
|
calibration: Arc<CalibrationStore>,
|
||||||
capture_power: CapturePowerManager,
|
capture_power: CapturePowerManager,
|
||||||
eye_hubs: Arc<Mutex<HashMap<EyeHubKey, Arc<EyeHub>>>>,
|
eye_hubs: Arc<Mutex<HashMap<EyeHubKey, Arc<EyeHub>>>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,9 +25,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let bind_addr = server_bind_addr()?;
|
let bind_addr = server_bind_addr()?;
|
||||||
info!("🌐 lesavka-server listening on {bind_addr}");
|
info!("🌐 lesavka-server listening on {bind_addr}");
|
||||||
Server::builder()
|
let mut server = Server::builder()
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.max_frame_size(Some(2 * 1024 * 1024))
|
.max_frame_size(Some(2 * 1024 * 1024));
|
||||||
|
if let Some(tls) = security::server_tls_config()? {
|
||||||
|
server = server.tls_config(tls)?;
|
||||||
|
}
|
||||||
|
server
|
||||||
.add_service(RelayServer::new(handler))
|
.add_service(RelayServer::new(handler))
|
||||||
.add_service(HandshakeSvc::server())
|
.add_service(HandshakeSvc::server())
|
||||||
.add_service(ReflBuilder::configure().build_v1().unwrap())
|
.add_service(ReflBuilder::configure().build_v1().unwrap())
|
||||||
|
|||||||
@ -45,13 +45,17 @@ impl Handler {
|
|||||||
warn!("⌛ HID endpoints are not ready; relay will keep running and open them lazily");
|
warn!("⌛ HID endpoints are not ready; relay will keep running and open them lazily");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let upstream_media_rt = Arc::new(UpstreamMediaRuntime::new());
|
||||||
|
let calibration = Arc::new(CalibrationStore::load(upstream_media_rt.clone()));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
kb: Arc::new(Mutex::new(kb)),
|
kb: Arc::new(Mutex::new(kb)),
|
||||||
ms: Arc::new(Mutex::new(ms)),
|
ms: Arc::new(Mutex::new(ms)),
|
||||||
gadget,
|
gadget,
|
||||||
did_cycle: Arc::new(AtomicBool::new(false)),
|
did_cycle: Arc::new(AtomicBool::new(false)),
|
||||||
camera_rt: Arc::new(CameraRuntime::new()),
|
camera_rt: Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt,
|
||||||
|
calibration,
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: Arc::new(Mutex::new(HashMap::new())),
|
eye_hubs: Arc::new(Mutex::new(HashMap::new())),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -457,6 +457,20 @@ impl Relay for Handler {
|
|||||||
) -> Result<Response<CapturePowerState>, Status> {
|
) -> Result<Response<CapturePowerState>, Status> {
|
||||||
self.set_capture_power_reply(req).await
|
self.set_capture_power_reply(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_calibration(
|
||||||
|
&self,
|
||||||
|
_req: Request<Empty>,
|
||||||
|
) -> Result<Response<CalibrationState>, Status> {
|
||||||
|
self.get_calibration_reply().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn calibrate(
|
||||||
|
&self,
|
||||||
|
req: Request<CalibrationRequest>,
|
||||||
|
) -> Result<Response<CalibrationState>, Status> {
|
||||||
|
self.calibrate_reply(req).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remote_audio_status(message: String) -> Status {
|
fn remote_audio_status(message: String) -> Status {
|
||||||
@ -468,32 +482,4 @@ fn remote_audio_status(message: String) -> Status {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::items_after_test_module)]
|
include!("relay_service_tests.rs");
|
||||||
mod tests {
|
|
||||||
use super::retain_freshest_video_packet;
|
|
||||||
use lesavka_common::lesavka::VideoPacket;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retain_freshest_video_packet_keeps_only_the_latest_frame() {
|
|
||||||
let mut pending = std::collections::VecDeque::from(vec![
|
|
||||||
VideoPacket {
|
|
||||||
pts: 100,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
VideoPacket {
|
|
||||||
pts: 200,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
VideoPacket {
|
|
||||||
pts: 300,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
let dropped = retain_freshest_video_packet(&mut pending);
|
|
||||||
|
|
||||||
assert_eq!(dropped, 2);
|
|
||||||
assert_eq!(pending.len(), 1);
|
|
||||||
assert_eq!(pending.front().map(|pkt| pkt.pts), Some(300));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -284,4 +284,18 @@ impl Relay for Handler {
|
|||||||
) -> Result<Response<CapturePowerState>, Status> {
|
) -> Result<Response<CapturePowerState>, Status> {
|
||||||
self.set_capture_power_reply(req).await
|
self.set_capture_power_reply(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_calibration(
|
||||||
|
&self,
|
||||||
|
_req: Request<Empty>,
|
||||||
|
) -> Result<Response<CalibrationState>, Status> {
|
||||||
|
self.get_calibration_reply().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn calibrate(
|
||||||
|
&self,
|
||||||
|
req: Request<CalibrationRequest>,
|
||||||
|
) -> Result<Response<CalibrationState>, Status> {
|
||||||
|
self.calibrate_reply(req).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
server/src/main/relay_service_tests.rs
Normal file
30
server/src/main/relay_service_tests.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::items_after_test_module)]
|
||||||
|
mod tests {
|
||||||
|
use super::retain_freshest_video_packet;
|
||||||
|
use lesavka_common::lesavka::VideoPacket;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retain_freshest_video_packet_keeps_only_the_latest_frame() {
|
||||||
|
let mut pending = std::collections::VecDeque::from(vec![
|
||||||
|
VideoPacket {
|
||||||
|
pts: 100,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
VideoPacket {
|
||||||
|
pts: 200,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
VideoPacket {
|
||||||
|
pts: 300,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let dropped = retain_freshest_video_packet(&mut pending);
|
||||||
|
|
||||||
|
assert_eq!(dropped, 2);
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending.front().map(|pkt| pkt.pts), Some(300));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -102,4 +102,17 @@ impl Handler {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_calibration_reply(&self) -> Result<Response<CalibrationState>, Status> {
|
||||||
|
Ok(Response::new(self.calibration.current()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn calibrate_reply(
|
||||||
|
&self,
|
||||||
|
req: Request<CalibrationRequest>,
|
||||||
|
) -> Result<Response<CalibrationState>, Status> {
|
||||||
|
self.calibration
|
||||||
|
.apply(req.into_inner())
|
||||||
|
.map(Response::new)
|
||||||
|
.map_err(|e| Status::internal(format!("{e:#}")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
211
server/src/security.rs
Normal file
211
server/src/security.rs
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tonic::transport::{Certificate, Identity, ServerTlsConfig};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
const DEFAULT_PKI_DIR: &str = "/etc/lesavka/pki";
|
||||||
|
|
||||||
|
pub fn server_tls_config() -> Result<Option<ServerTlsConfig>> {
|
||||||
|
let cert_path = env_path("LESAVKA_TLS_CERT").unwrap_or_else(|| default_path("server.crt"));
|
||||||
|
let key_path = env_path("LESAVKA_TLS_KEY").unwrap_or_else(|| default_path("server.key"));
|
||||||
|
let require_tls = bool_env("LESAVKA_REQUIRE_TLS");
|
||||||
|
|
||||||
|
if !cert_path.exists() || !key_path.exists() {
|
||||||
|
if require_tls {
|
||||||
|
bail!(
|
||||||
|
"LESAVKA_REQUIRE_TLS=1 but TLS identity files are missing: cert={} key={}",
|
||||||
|
cert_path.display(),
|
||||||
|
key_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
warn!(
|
||||||
|
cert = %cert_path.display(),
|
||||||
|
key = %key_path.display(),
|
||||||
|
"🔓 TLS identity not present; serving plaintext gRPC for local/dev use"
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cert = std::fs::read(&cert_path)
|
||||||
|
.with_context(|| format!("reading TLS server certificate {}", cert_path.display()))?;
|
||||||
|
let key = std::fs::read(&key_path)
|
||||||
|
.with_context(|| format!("reading TLS server key {}", key_path.display()))?;
|
||||||
|
let mut tls = ServerTlsConfig::new().identity(Identity::from_pem(cert, key));
|
||||||
|
|
||||||
|
let client_ca_path =
|
||||||
|
env_path("LESAVKA_TLS_CLIENT_CA").unwrap_or_else(|| default_path("ca.crt"));
|
||||||
|
if client_ca_path.exists() {
|
||||||
|
let client_ca = std::fs::read(&client_ca_path)
|
||||||
|
.with_context(|| format!("reading TLS client CA {}", client_ca_path.display()))?;
|
||||||
|
tls = tls.client_ca_root(Certificate::from_pem(client_ca));
|
||||||
|
if bool_env("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL") {
|
||||||
|
tls = tls.client_auth_optional(true);
|
||||||
|
warn!("🔐 TLS enabled with optional client certificates");
|
||||||
|
} else {
|
||||||
|
info!("🔐 TLS enabled with required client certificate authentication");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
ca = %client_ca_path.display(),
|
||||||
|
"🔐 TLS enabled without client certificate authentication; install a client CA for production"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(tls))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_path(name: &str) -> Option<PathBuf> {
|
||||||
|
std::env::var_os(name)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_path(file_name: &str) -> PathBuf {
|
||||||
|
PathBuf::from(DEFAULT_PKI_DIR).join(file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bool_env(name: &str) -> bool {
|
||||||
|
std::env::var(name)
|
||||||
|
.map(|value| {
|
||||||
|
let value = value.trim().to_ascii_lowercase();
|
||||||
|
!value.is_empty() && value != "0" && value != "false" && value != "no"
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{bool_env, default_path, env_path, server_tls_config};
|
||||||
|
use temp_env::with_vars;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_identity_is_allowed_unless_tls_is_required() {
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_REQUIRE_TLS", None::<&str>),
|
||||||
|
("LESAVKA_TLS_CERT", Some("/tmp/lesavka-missing-cert")),
|
||||||
|
("LESAVKA_TLS_KEY", Some("/tmp/lesavka-missing-key")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert!(
|
||||||
|
server_tls_config()
|
||||||
|
.expect("optional tls should not error")
|
||||||
|
.is_none()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn require_tls_errors_when_identity_is_missing() {
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_REQUIRE_TLS", Some("1")),
|
||||||
|
("LESAVKA_TLS_CERT", Some("/tmp/lesavka-missing-cert")),
|
||||||
|
("LESAVKA_TLS_KEY", Some("/tmp/lesavka-missing-key")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let err = server_tls_config().expect_err("required tls should fail");
|
||||||
|
assert!(err.to_string().contains("LESAVKA_REQUIRE_TLS=1"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn helpers_parse_paths_and_boolean_overrides() {
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_TLS_CERT", Some("/tmp/server.crt")),
|
||||||
|
("LESAVKA_REQUIRE_TLS", Some("yes")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert_eq!(
|
||||||
|
env_path("LESAVKA_TLS_CERT").unwrap(),
|
||||||
|
std::path::PathBuf::from("/tmp/server.crt")
|
||||||
|
);
|
||||||
|
assert!(bool_env("LESAVKA_REQUIRE_TLS"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_TLS_CERT", Some("")),
|
||||||
|
("LESAVKA_REQUIRE_TLS", Some("false")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert!(env_path("LESAVKA_TLS_CERT").is_none());
|
||||||
|
assert!(!bool_env("LESAVKA_REQUIRE_TLS"));
|
||||||
|
assert_eq!(
|
||||||
|
default_path("server.crt"),
|
||||||
|
std::path::PathBuf::from("/etc/lesavka/pki/server.crt")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_identity_can_load_with_and_without_client_ca() {
|
||||||
|
let dir = tempdir().expect("tls dir");
|
||||||
|
let cert = dir.path().join("server.crt");
|
||||||
|
let key = dir.path().join("server.key");
|
||||||
|
let ca = dir.path().join("ca.crt");
|
||||||
|
std::fs::write(&cert, b"not a real cert").expect("cert");
|
||||||
|
std::fs::write(&key, b"not a real key").expect("key");
|
||||||
|
std::fs::write(&ca, b"not a real ca").expect("ca");
|
||||||
|
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_REQUIRE_TLS", Some("1")),
|
||||||
|
("LESAVKA_TLS_CERT", Some(cert.to_string_lossy().as_ref())),
|
||||||
|
("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())),
|
||||||
|
("LESAVKA_TLS_CLIENT_CA", Some(ca.to_string_lossy().as_ref())),
|
||||||
|
("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL", Some("1")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert!(server_tls_config().expect("tls config").is_some());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_REQUIRE_TLS", Some("1")),
|
||||||
|
("LESAVKA_TLS_CERT", Some(cert.to_string_lossy().as_ref())),
|
||||||
|
("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())),
|
||||||
|
(
|
||||||
|
"LESAVKA_TLS_CLIENT_CA",
|
||||||
|
Some(dir.path().join("missing-ca.crt").to_string_lossy().as_ref()),
|
||||||
|
),
|
||||||
|
("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL", Some("0")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert!(
|
||||||
|
server_tls_config()
|
||||||
|
.expect("tls config without ca")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_but_unreadable_identity_reports_context() {
|
||||||
|
let dir = tempdir().expect("tls dir");
|
||||||
|
let key = dir.path().join("server.key");
|
||||||
|
std::fs::write(&key, b"not a real key").expect("key");
|
||||||
|
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_REQUIRE_TLS", Some("1")),
|
||||||
|
(
|
||||||
|
"LESAVKA_TLS_CERT",
|
||||||
|
Some(dir.path().to_string_lossy().as_ref()),
|
||||||
|
),
|
||||||
|
("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let err = server_tls_config().expect_err("directory cert should fail");
|
||||||
|
assert!(err.to_string().contains("reading TLS server certificate"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore};
|
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore};
|
||||||
@ -36,6 +36,8 @@ pub struct UpstreamMediaRuntime {
|
|||||||
microphone_sink_gate: Arc<Semaphore>,
|
microphone_sink_gate: Arc<Semaphore>,
|
||||||
pairing_state_notify: Arc<Notify>,
|
pairing_state_notify: Arc<Notify>,
|
||||||
audio_progress_notify: Arc<Notify>,
|
audio_progress_notify: Arc<Notify>,
|
||||||
|
camera_playout_offset_us: AtomicI64,
|
||||||
|
microphone_playout_offset_us: AtomicI64,
|
||||||
state: Mutex<UpstreamClockState>,
|
state: Mutex<UpstreamClockState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,151 +52,46 @@ impl UpstreamMediaRuntime {
|
|||||||
microphone_sink_gate: Arc::new(Semaphore::new(1)),
|
microphone_sink_gate: Arc::new(Semaphore::new(1)),
|
||||||
pairing_state_notify: Arc::new(Notify::new()),
|
pairing_state_notify: Arc::new(Notify::new()),
|
||||||
audio_progress_notify: Arc::new(Notify::new()),
|
audio_progress_notify: Arc::new(Notify::new()),
|
||||||
|
camera_playout_offset_us: AtomicI64::new(upstream_playout_offset_us(
|
||||||
|
UpstreamMediaKind::Camera,
|
||||||
|
)),
|
||||||
|
microphone_playout_offset_us: AtomicI64::new(upstream_playout_offset_us(
|
||||||
|
UpstreamMediaKind::Microphone,
|
||||||
|
)),
|
||||||
state: Mutex::new(UpstreamClockState::default()),
|
state: Mutex::new(UpstreamClockState::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Activate a camera stream as the current owner for the session.
|
/// Apply live upstream playout offsets without restarting the relay.
|
||||||
|
pub fn set_playout_offsets(&self, camera_offset_us: i64, microphone_offset_us: i64) {
|
||||||
|
self.camera_playout_offset_us
|
||||||
|
.store(camera_offset_us, Ordering::Relaxed);
|
||||||
|
self.microphone_playout_offset_us
|
||||||
|
.store(microphone_offset_us, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `(camera_offset_us, microphone_offset_us)` currently used for live playout.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn activate_camera(&self) -> UpstreamStreamLease {
|
pub fn playout_offsets(&self) -> (i64, i64) {
|
||||||
self.activate(UpstreamMediaKind::Camera)
|
(
|
||||||
|
self.camera_playout_offset_us.load(Ordering::Relaxed),
|
||||||
|
self.microphone_playout_offset_us.load(Ordering::Relaxed),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Activate a microphone stream as the current owner for the session.
|
fn playout_offset_us(&self, kind: UpstreamMediaKind) -> i64 {
|
||||||
#[must_use]
|
match kind {
|
||||||
pub fn activate_microphone(&self) -> UpstreamStreamLease {
|
UpstreamMediaKind::Camera => self.camera_playout_offset_us.load(Ordering::Relaxed),
|
||||||
self.activate(UpstreamMediaKind::Microphone)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reserve the single live microphone sink slot for one generation.
|
|
||||||
///
|
|
||||||
/// Inputs: the microphone lease generation that wants to own the UAC sink.
|
|
||||||
/// Outputs: an owned semaphore permit while that generation still owns the
|
|
||||||
/// microphone slot, or `None` if a newer stream superseded it before the
|
|
||||||
/// previous sink fully stood down.
|
|
||||||
/// Why: ALSA only allows one live owner of the UAC playback device, so a
|
|
||||||
/// replacement stream must wait for the old owner to release the sink
|
|
||||||
/// before opening a new playback pipeline.
|
|
||||||
pub async fn reserve_microphone_sink(&self, generation: u64) -> Option<OwnedSemaphorePermit> {
|
|
||||||
let permit = self
|
|
||||||
.microphone_sink_gate
|
|
||||||
.clone()
|
|
||||||
.acquire_owned()
|
|
||||||
.await
|
|
||||||
.ok()?;
|
|
||||||
self.is_microphone_active(generation).then_some(permit)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn activate(&self, kind: UpstreamMediaKind) -> UpstreamStreamLease {
|
|
||||||
let generation = match kind {
|
|
||||||
UpstreamMediaKind::Camera => {
|
|
||||||
self.next_camera_generation.fetch_add(1, Ordering::SeqCst) + 1
|
|
||||||
}
|
|
||||||
UpstreamMediaKind::Microphone => {
|
UpstreamMediaKind::Microphone => {
|
||||||
self.next_microphone_generation
|
self.microphone_playout_offset_us.load(Ordering::Relaxed)
|
||||||
.fetch_add(1, Ordering::SeqCst)
|
|
||||||
+ 1
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.expect("upstream media state mutex poisoned");
|
|
||||||
if state.active_camera_generation.is_none() && state.active_microphone_generation.is_none()
|
|
||||||
{
|
|
||||||
state.session_id = self.next_session_id.fetch_add(1, Ordering::SeqCst) + 1;
|
|
||||||
state.first_camera_remote_pts_us = None;
|
|
||||||
state.first_microphone_remote_pts_us = None;
|
|
||||||
state.camera_startup_ready = false;
|
|
||||||
state.session_base_remote_pts_us = None;
|
|
||||||
state.last_video_local_pts_us = None;
|
|
||||||
state.last_audio_local_pts_us = None;
|
|
||||||
state.camera_packet_count = 0;
|
|
||||||
state.microphone_packet_count = 0;
|
|
||||||
state.startup_anchor_logged = false;
|
|
||||||
state.playout_epoch = None;
|
|
||||||
state.pairing_anchor_deadline = None;
|
|
||||||
state.catastrophic_reanchor_done = false;
|
|
||||||
}
|
|
||||||
match kind {
|
|
||||||
UpstreamMediaKind::Camera => state.active_camera_generation = Some(generation),
|
|
||||||
UpstreamMediaKind::Microphone => state.active_microphone_generation = Some(generation),
|
|
||||||
}
|
|
||||||
UpstreamStreamLease {
|
|
||||||
session_id: state.session_id,
|
|
||||||
generation,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return whether the supplied camera lease is still the active owner.
|
include!("upstream_media_runtime/lease_lifecycle.rs");
|
||||||
#[must_use]
|
|
||||||
pub fn is_camera_active(&self, generation: u64) -> bool {
|
|
||||||
self.is_active(UpstreamMediaKind::Camera, generation)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return whether the supplied microphone lease is still the active owner.
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_microphone_active(&self, generation: u64) -> bool {
|
|
||||||
self.is_active(UpstreamMediaKind::Microphone, generation)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_active(&self, kind: UpstreamMediaKind, generation: u64) -> bool {
|
|
||||||
let state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.expect("upstream media state mutex poisoned");
|
|
||||||
match kind {
|
|
||||||
UpstreamMediaKind::Camera => state.active_camera_generation == Some(generation),
|
|
||||||
UpstreamMediaKind::Microphone => state.active_microphone_generation == Some(generation),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark a camera stream as closed if it still owns the camera slot.
|
|
||||||
pub fn close_camera(&self, generation: u64) {
|
|
||||||
self.close(UpstreamMediaKind::Camera, generation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark a microphone stream as closed if it still owns the microphone slot.
|
|
||||||
pub fn close_microphone(&self, generation: u64) {
|
|
||||||
self.close(UpstreamMediaKind::Microphone, generation);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&self, kind: UpstreamMediaKind, generation: u64) {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.expect("upstream media state mutex poisoned");
|
|
||||||
match kind {
|
|
||||||
UpstreamMediaKind::Camera if state.active_camera_generation == Some(generation) => {
|
|
||||||
state.active_camera_generation = None;
|
|
||||||
}
|
|
||||||
UpstreamMediaKind::Microphone
|
|
||||||
if state.active_microphone_generation == Some(generation) =>
|
|
||||||
{
|
|
||||||
state.active_microphone_generation = None;
|
|
||||||
}
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
if state.active_camera_generation.is_none() && state.active_microphone_generation.is_none()
|
|
||||||
{
|
|
||||||
state.first_camera_remote_pts_us = None;
|
|
||||||
state.first_microphone_remote_pts_us = None;
|
|
||||||
state.camera_startup_ready = false;
|
|
||||||
state.session_base_remote_pts_us = None;
|
|
||||||
state.last_video_local_pts_us = None;
|
|
||||||
state.last_audio_local_pts_us = None;
|
|
||||||
state.camera_packet_count = 0;
|
|
||||||
state.microphone_packet_count = 0;
|
|
||||||
state.startup_anchor_logged = false;
|
|
||||||
state.playout_epoch = None;
|
|
||||||
state.pairing_anchor_deadline = None;
|
|
||||||
state.catastrophic_reanchor_done = false;
|
|
||||||
}
|
|
||||||
self.pairing_state_notify.notify_waiters();
|
|
||||||
self.audio_progress_notify.notify_waiters();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
impl UpstreamMediaRuntime {
|
||||||
/// Rebase one upstream video packet timestamp onto the shared session clock.
|
/// Rebase one upstream video packet timestamp onto the shared session clock.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn map_video_pts(&self, remote_pts_us: u64, frame_step_us: u64) -> Option<u64> {
|
pub fn map_video_pts(&self, remote_pts_us: u64, frame_step_us: u64) -> Option<u64> {
|
||||||
@ -417,7 +314,7 @@ impl UpstreamMediaRuntime {
|
|||||||
}
|
}
|
||||||
*last_slot = Some(local_pts_us);
|
*last_slot = Some(local_pts_us);
|
||||||
let epoch = *state.playout_epoch.get_or_insert(pairing_deadline);
|
let epoch = *state.playout_epoch.get_or_insert(pairing_deadline);
|
||||||
let sink_offset_us = upstream_playout_offset_us(kind);
|
let sink_offset_us = self.playout_offset_us(kind);
|
||||||
let playout_delay = upstream_playout_delay();
|
let playout_delay = upstream_playout_delay();
|
||||||
let mut due_at =
|
let mut due_at =
|
||||||
apply_playout_offset(epoch + Duration::from_micros(local_pts_us), sink_offset_us);
|
apply_playout_offset(epoch + Duration::from_micros(local_pts_us), sink_offset_us);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ use std::time::Duration;
|
|||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
|
|
||||||
use super::UpstreamMediaKind;
|
use super::UpstreamMediaKind;
|
||||||
|
use crate::calibration::{FACTORY_MJPEG_AUDIO_OFFSET_US, FACTORY_MJPEG_VIDEO_OFFSET_US};
|
||||||
|
|
||||||
pub(super) fn upstream_timing_trace_enabled() -> bool {
|
pub(super) fn upstream_timing_trace_enabled() -> bool {
|
||||||
std::env::var("LESAVKA_UPSTREAM_TIMING_TRACE")
|
std::env::var("LESAVKA_UPSTREAM_TIMING_TRACE")
|
||||||
@ -30,12 +31,12 @@ pub(super) fn upstream_playout_offset_us(kind: UpstreamMediaKind) -> i64 {
|
|||||||
UpstreamMediaKind::Microphone => "LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US",
|
UpstreamMediaKind::Microphone => "LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US",
|
||||||
};
|
};
|
||||||
let default_offset_us = match kind {
|
let default_offset_us = match kind {
|
||||||
UpstreamMediaKind::Camera => 0,
|
UpstreamMediaKind::Camera => FACTORY_MJPEG_VIDEO_OFFSET_US,
|
||||||
// Hardware sync probes on the MJPEG UVC path show the UAC leg arriving
|
// Hardware sync probes on the MJPEG UVC path show the UAC leg arriving
|
||||||
// about 80ms after video when using the older +35ms default. Bias the
|
// about 80ms after video when using the older +35ms default. Bias the
|
||||||
// server playout earlier so the shipped default lands in the preferred
|
// server playout earlier so the shipped default lands in the preferred
|
||||||
// lip-sync band instead of hovering at the guardrail.
|
// lip-sync band instead of hovering at the guardrail.
|
||||||
UpstreamMediaKind::Microphone => -45_000,
|
UpstreamMediaKind::Microphone => FACTORY_MJPEG_AUDIO_OFFSET_US,
|
||||||
};
|
};
|
||||||
std::env::var(name)
|
std::env::var(name)
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
142
server/src/upstream_media_runtime/lease_lifecycle.rs
Normal file
142
server/src/upstream_media_runtime/lease_lifecycle.rs
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
impl UpstreamMediaRuntime {
|
||||||
|
/// Activate a camera stream as the current owner for the session.
|
||||||
|
#[must_use]
|
||||||
|
pub fn activate_camera(&self) -> UpstreamStreamLease {
|
||||||
|
self.activate(UpstreamMediaKind::Camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate a microphone stream as the current owner for the session.
|
||||||
|
#[must_use]
|
||||||
|
pub fn activate_microphone(&self) -> UpstreamStreamLease {
|
||||||
|
self.activate(UpstreamMediaKind::Microphone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserve the single live microphone sink slot for one generation.
|
||||||
|
///
|
||||||
|
/// Inputs: the microphone lease generation that wants to own the UAC sink.
|
||||||
|
/// Outputs: an owned semaphore permit while that generation still owns the
|
||||||
|
/// microphone slot, or `None` if a newer stream superseded it before the
|
||||||
|
/// previous sink fully stood down.
|
||||||
|
/// Why: ALSA only allows one live owner of the UAC playback device, so a
|
||||||
|
/// replacement stream must wait for the old owner to release the sink
|
||||||
|
/// before opening a new playback pipeline.
|
||||||
|
pub async fn reserve_microphone_sink(&self, generation: u64) -> Option<OwnedSemaphorePermit> {
|
||||||
|
let permit = self
|
||||||
|
.microphone_sink_gate
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
self.is_microphone_active(generation).then_some(permit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&self, kind: UpstreamMediaKind) -> UpstreamStreamLease {
|
||||||
|
let generation = match kind {
|
||||||
|
UpstreamMediaKind::Camera => {
|
||||||
|
self.next_camera_generation.fetch_add(1, Ordering::SeqCst) + 1
|
||||||
|
}
|
||||||
|
UpstreamMediaKind::Microphone => {
|
||||||
|
self.next_microphone_generation
|
||||||
|
.fetch_add(1, Ordering::SeqCst)
|
||||||
|
+ 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("upstream media state mutex poisoned");
|
||||||
|
if state.active_camera_generation.is_none() && state.active_microphone_generation.is_none()
|
||||||
|
{
|
||||||
|
state.session_id = self.next_session_id.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
state.first_camera_remote_pts_us = None;
|
||||||
|
state.first_microphone_remote_pts_us = None;
|
||||||
|
state.camera_startup_ready = false;
|
||||||
|
state.session_base_remote_pts_us = None;
|
||||||
|
state.last_video_local_pts_us = None;
|
||||||
|
state.last_audio_local_pts_us = None;
|
||||||
|
state.camera_packet_count = 0;
|
||||||
|
state.microphone_packet_count = 0;
|
||||||
|
state.startup_anchor_logged = false;
|
||||||
|
state.playout_epoch = None;
|
||||||
|
state.pairing_anchor_deadline = None;
|
||||||
|
state.catastrophic_reanchor_done = false;
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
UpstreamMediaKind::Camera => state.active_camera_generation = Some(generation),
|
||||||
|
UpstreamMediaKind::Microphone => state.active_microphone_generation = Some(generation),
|
||||||
|
}
|
||||||
|
UpstreamStreamLease {
|
||||||
|
session_id: state.session_id,
|
||||||
|
generation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether the supplied camera lease is still the active owner.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_camera_active(&self, generation: u64) -> bool {
|
||||||
|
self.is_active(UpstreamMediaKind::Camera, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether the supplied microphone lease is still the active owner.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_microphone_active(&self, generation: u64) -> bool {
|
||||||
|
self.is_active(UpstreamMediaKind::Microphone, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self, kind: UpstreamMediaKind, generation: u64) -> bool {
|
||||||
|
let state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("upstream media state mutex poisoned");
|
||||||
|
match kind {
|
||||||
|
UpstreamMediaKind::Camera => state.active_camera_generation == Some(generation),
|
||||||
|
UpstreamMediaKind::Microphone => state.active_microphone_generation == Some(generation),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a camera stream as closed if it still owns the camera slot.
|
||||||
|
pub fn close_camera(&self, generation: u64) {
|
||||||
|
self.close(UpstreamMediaKind::Camera, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a microphone stream as closed if it still owns the microphone slot.
|
||||||
|
pub fn close_microphone(&self, generation: u64) {
|
||||||
|
self.close(UpstreamMediaKind::Microphone, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&self, kind: UpstreamMediaKind, generation: u64) {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("upstream media state mutex poisoned");
|
||||||
|
match kind {
|
||||||
|
UpstreamMediaKind::Camera if state.active_camera_generation == Some(generation) => {
|
||||||
|
state.active_camera_generation = None;
|
||||||
|
}
|
||||||
|
UpstreamMediaKind::Microphone
|
||||||
|
if state.active_microphone_generation == Some(generation) =>
|
||||||
|
{
|
||||||
|
state.active_microphone_generation = None;
|
||||||
|
}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
if state.active_camera_generation.is_none() && state.active_microphone_generation.is_none()
|
||||||
|
{
|
||||||
|
state.first_camera_remote_pts_us = None;
|
||||||
|
state.first_microphone_remote_pts_us = None;
|
||||||
|
state.camera_startup_ready = false;
|
||||||
|
state.session_base_remote_pts_us = None;
|
||||||
|
state.last_video_local_pts_us = None;
|
||||||
|
state.last_audio_local_pts_us = None;
|
||||||
|
state.camera_packet_count = 0;
|
||||||
|
state.microphone_packet_count = 0;
|
||||||
|
state.startup_anchor_logged = false;
|
||||||
|
state.playout_epoch = None;
|
||||||
|
state.pairing_anchor_deadline = None;
|
||||||
|
state.catastrophic_reanchor_done = false;
|
||||||
|
}
|
||||||
|
self.pairing_state_notify.notify_waiters();
|
||||||
|
self.audio_progress_notify.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,12 @@ fn play(decision: UpstreamPlanDecision) -> PlannedUpstreamPacket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_without_offsets() -> UpstreamMediaRuntime {
|
||||||
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
|
runtime.set_playout_offsets(0, 0);
|
||||||
|
runtime
|
||||||
|
}
|
||||||
|
|
||||||
mod async_wait;
|
mod async_wait;
|
||||||
mod config;
|
mod config;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
use super::{UpstreamMediaRuntime, play};
|
use super::{UpstreamMediaRuntime, play};
|
||||||
|
use serial_test::serial;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
async fn wait_for_audio_master_releases_video_once_audio_catches_up() {
|
async fn wait_for_audio_master_releases_video_once_audio_catches_up() {
|
||||||
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
@ -31,6 +33,7 @@ async fn wait_for_audio_master_releases_video_once_audio_catches_up() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
async fn wait_for_audio_master_times_out_when_audio_never_catches_up() {
|
async fn wait_for_audio_master_times_out_when_audio_never_catches_up() {
|
||||||
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
@ -52,6 +55,7 @@ async fn wait_for_audio_master_times_out_when_audio_never_catches_up() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
async fn wait_for_audio_master_returns_true_when_no_microphone_stream_is_active() {
|
async fn wait_for_audio_master_returns_true_when_no_microphone_stream_is_active() {
|
||||||
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
||||||
let camera = runtime.activate_camera();
|
let camera = runtime.activate_camera();
|
||||||
@ -70,6 +74,7 @@ async fn wait_for_audio_master_returns_true_when_no_microphone_stream_is_active(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
async fn new_microphone_owner_waits_for_the_previous_sink_to_release() {
|
async fn new_microphone_owner_waits_for_the_previous_sink_to_release() {
|
||||||
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
||||||
let first = runtime.activate_microphone();
|
let first = runtime.activate_microphone();
|
||||||
@ -97,6 +102,7 @@ async fn new_microphone_owner_waits_for_the_previous_sink_to_release() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
async fn superseded_microphone_waiter_stands_down_before_opening_a_sink() {
|
async fn superseded_microphone_waiter_stands_down_before_opening_a_sink() {
|
||||||
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
let runtime = Arc::new(UpstreamMediaRuntime::new());
|
||||||
let first = runtime.activate_microphone();
|
let first = runtime.activate_microphone();
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
use super::UpstreamMediaKind;
|
use super::UpstreamMediaKind;
|
||||||
|
use serial_test::serial;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn upstream_playout_delay_defaults_to_one_second_and_accepts_overrides() {
|
fn upstream_playout_delay_defaults_to_one_second_and_accepts_overrides() {
|
||||||
temp_env::with_var_unset("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", || {
|
temp_env::with_var_unset("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", || {
|
||||||
assert_eq!(super::upstream_playout_delay(), Duration::from_secs(1));
|
assert_eq!(super::upstream_playout_delay(), Duration::from_secs(1));
|
||||||
@ -13,6 +15,7 @@ fn upstream_playout_delay_defaults_to_one_second_and_accepts_overrides() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn upstream_playout_offsets_default_to_mjpeg_calibration_and_accept_overrides() {
|
fn upstream_playout_offsets_default_to_mjpeg_calibration_and_accept_overrides() {
|
||||||
temp_env::with_var_unset("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", || {
|
temp_env::with_var_unset("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", || {
|
||||||
temp_env::with_var_unset("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", || {
|
temp_env::with_var_unset("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", || {
|
||||||
@ -50,6 +53,7 @@ fn upstream_playout_offsets_default_to_mjpeg_calibration_and_accept_overrides()
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn upstream_pairing_master_slack_defaults_to_twenty_ms_and_accepts_overrides() {
|
fn upstream_pairing_master_slack_defaults_to_twenty_ms_and_accepts_overrides() {
|
||||||
temp_env::with_var_unset("LESAVKA_UPSTREAM_PAIR_SLACK_US", || {
|
temp_env::with_var_unset("LESAVKA_UPSTREAM_PAIR_SLACK_US", || {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -67,6 +71,7 @@ fn upstream_pairing_master_slack_defaults_to_twenty_ms_and_accepts_overrides() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn upstream_reanchor_late_threshold_defaults_to_half_the_buffer_and_accepts_overrides() {
|
fn upstream_reanchor_late_threshold_defaults_to_half_the_buffer_and_accepts_overrides() {
|
||||||
temp_env::with_var_unset("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", || {
|
temp_env::with_var_unset("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", || {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -88,6 +93,7 @@ fn upstream_reanchor_late_threshold_defaults_to_half_the_buffer_and_accepts_over
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn upstream_timing_trace_flag_accepts_false_values() {
|
fn upstream_timing_trace_flag_accepts_false_values() {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("off"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("off"), || {
|
||||||
assert!(!super::upstream_timing_trace_enabled());
|
assert!(!super::upstream_timing_trace_enabled());
|
||||||
@ -101,6 +107,7 @@ fn upstream_timing_trace_flag_accepts_false_values() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn apply_playout_offset_supports_negative_offsets() {
|
fn apply_playout_offset_supports_negative_offsets() {
|
||||||
let base = tokio::time::Instant::now() + Duration::from_millis(50);
|
let base = tokio::time::Instant::now() + Duration::from_millis(50);
|
||||||
let shifted = super::apply_playout_offset(base, -20_000);
|
let shifted = super::apply_playout_offset(base, -20_000);
|
||||||
@ -109,6 +116,7 @@ fn apply_playout_offset_supports_negative_offsets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn apply_playout_offset_supports_positive_offsets() {
|
fn apply_playout_offset_supports_positive_offsets() {
|
||||||
let base = tokio::time::Instant::now();
|
let base = tokio::time::Instant::now();
|
||||||
let shifted = super::apply_playout_offset(base, 30_000);
|
let shifted = super::apply_playout_offset(base, 30_000);
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
use super::{UpstreamMediaRuntime, play};
|
use super::{UpstreamMediaRuntime, play, runtime_without_offsets};
|
||||||
|
use serial_test::serial;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn first_stream_starts_a_new_shared_session() {
|
fn first_stream_starts_a_new_shared_session() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
let camera = runtime.activate_camera();
|
let camera = runtime.activate_camera();
|
||||||
@ -14,6 +16,7 @@ fn first_stream_starts_a_new_shared_session() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn replacing_one_kind_keeps_the_session_but_preempts_the_old_owner() {
|
fn replacing_one_kind_keeps_the_session_but_preempts_the_old_owner() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
let first = runtime.activate_microphone();
|
let first = runtime.activate_microphone();
|
||||||
@ -25,6 +28,7 @@ fn replacing_one_kind_keeps_the_session_but_preempts_the_old_owner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn closing_the_last_stream_resets_the_next_session_anchor() {
|
fn closing_the_last_stream_resets_the_next_session_anchor() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
let camera = runtime.activate_camera();
|
let camera = runtime.activate_camera();
|
||||||
@ -37,8 +41,9 @@ fn closing_the_last_stream_resets_the_next_session_anchor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn first_packets_wait_for_the_counterpart_before_pairing() {
|
fn first_packets_wait_for_the_counterpart_before_pairing() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = runtime_without_offsets();
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
let _microphone = runtime.activate_microphone();
|
let _microphone = runtime.activate_microphone();
|
||||||
|
|
||||||
@ -56,6 +61,7 @@ fn first_packets_wait_for_the_counterpart_before_pairing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn overlap_waits_for_camera_startup_grace_before_establishing_the_shared_base() {
|
fn overlap_waits_for_camera_startup_grace_before_establishing_the_shared_base() {
|
||||||
temp_env::with_var(
|
temp_env::with_var(
|
||||||
"LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_MS",
|
"LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_MS",
|
||||||
@ -88,6 +94,7 @@ fn overlap_waits_for_camera_startup_grace_before_establishing_the_shared_base()
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn pairing_window_does_not_expire_into_one_sided_playout_while_camera_warms_up() {
|
fn pairing_window_does_not_expire_into_one_sided_playout_while_camera_warms_up() {
|
||||||
temp_env::with_var(
|
temp_env::with_var(
|
||||||
"LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_MS",
|
"LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_MS",
|
||||||
@ -125,6 +132,7 @@ fn pairing_window_does_not_expire_into_one_sided_playout_while_camera_warms_up()
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn overlap_pairing_drops_leading_packets_before_the_shared_base() {
|
fn overlap_pairing_drops_leading_packets_before_the_shared_base() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
@ -150,6 +158,7 @@ fn overlap_pairing_drops_leading_packets_before_the_shared_base() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn shared_clock_keeps_each_kind_monotonic_when_remote_pts_repeat() {
|
fn shared_clock_keeps_each_kind_monotonic_when_remote_pts_repeat() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
@ -168,6 +177,7 @@ fn shared_clock_keeps_each_kind_monotonic_when_remote_pts_repeat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn close_ignores_superseded_generation_values() {
|
fn close_ignores_superseded_generation_values() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
let first = runtime.activate_camera();
|
let first = runtime.activate_camera();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use super::{UpstreamMediaRuntime, play};
|
use super::{UpstreamMediaRuntime, play, runtime_without_offsets};
|
||||||
|
use serial_test::serial;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
fn with_info_tracing<T>(f: impl FnOnce() -> T) -> T {
|
fn with_info_tracing<T>(f: impl FnOnce() -> T) -> T {
|
||||||
@ -10,8 +11,9 @@ fn with_info_tracing<T>(f: impl FnOnce() -> T) -> T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn shared_playout_epoch_is_reused_across_audio_and_video() {
|
fn shared_playout_epoch_is_reused_across_audio_and_video() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = runtime_without_offsets();
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
let _microphone = runtime.activate_microphone();
|
let _microphone = runtime.activate_microphone();
|
||||||
|
|
||||||
@ -35,6 +37,7 @@ fn shared_playout_epoch_is_reused_across_audio_and_video() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn pairing_window_can_expire_into_one_sided_playout() {
|
fn pairing_window_can_expire_into_one_sided_playout() {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
@ -49,6 +52,7 @@ fn pairing_window_can_expire_into_one_sided_playout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn map_wrappers_hide_unpaired_and_pre_overlap_packets() {
|
fn map_wrappers_hide_unpaired_and_pre_overlap_packets() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
@ -60,6 +64,7 @@ fn map_wrappers_hide_unpaired_and_pre_overlap_packets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn shared_playout_trace_path_keeps_planned_pts_stable() {
|
fn shared_playout_trace_path_keeps_planned_pts_stable() {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("1"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("1"), || {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
@ -79,10 +84,12 @@ fn shared_playout_trace_path_keeps_planned_pts_stable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn catastrophic_lateness_reanchors_the_shared_playout_epoch() {
|
fn catastrophic_lateness_reanchors_the_shared_playout_epoch() {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", Some("5"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", Some("5"), || {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
|
runtime.set_playout_offsets(0, 0);
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
let _microphone = runtime.activate_microphone();
|
let _microphone = runtime.activate_microphone();
|
||||||
|
|
||||||
@ -115,9 +122,11 @@ fn catastrophic_lateness_reanchors_the_shared_playout_epoch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn overlap_anchor_gets_a_fresh_playout_budget_when_pairing_finishes_late() {
|
fn overlap_anchor_gets_a_fresh_playout_budget_when_pairing_finishes_late() {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
|
runtime.set_playout_offsets(0, 0);
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
let _microphone = runtime.activate_microphone();
|
let _microphone = runtime.activate_microphone();
|
||||||
|
|
||||||
@ -143,10 +152,12 @@ fn overlap_anchor_gets_a_fresh_playout_budget_when_pairing_finishes_late() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn catastrophic_lateness_reanchors_only_once_per_session() {
|
fn catastrophic_lateness_reanchors_only_once_per_session() {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", Some("5"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", Some("5"), || {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
|
runtime.set_playout_offsets(0, 0);
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
let _microphone = runtime.activate_microphone();
|
let _microphone = runtime.activate_microphone();
|
||||||
|
|
||||||
@ -172,10 +183,12 @@ fn catastrophic_lateness_reanchors_only_once_per_session() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn catastrophic_lateness_does_not_reanchor_once_the_session_is_well_past_startup() {
|
fn catastrophic_lateness_does_not_reanchor_once_the_session_is_well_past_startup() {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", Some("5"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", Some("5"), || {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
|
runtime.set_playout_offsets(0, 0);
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
let _microphone = runtime.activate_microphone();
|
let _microphone = runtime.activate_microphone();
|
||||||
|
|
||||||
@ -202,6 +215,7 @@ fn catastrophic_lateness_does_not_reanchor_once_the_session_is_well_past_startup
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn default_runtime_covers_video_map_play_path() {
|
fn default_runtime_covers_video_map_play_path() {
|
||||||
let runtime = UpstreamMediaRuntime::default();
|
let runtime = UpstreamMediaRuntime::default();
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
@ -216,22 +230,26 @@ fn default_runtime_covers_video_map_play_path() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
async fn wait_for_audio_master_returns_false_immediately_once_due_time_has_already_passed() {
|
async fn wait_for_audio_master_returns_false_immediately_once_due_time_has_already_passed() {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
let _camera = runtime.activate_camera();
|
let _camera = runtime.activate_camera();
|
||||||
let _microphone = runtime.activate_microphone();
|
let _microphone = runtime.activate_microphone();
|
||||||
|
|
||||||
assert!(!runtime
|
assert!(
|
||||||
.wait_for_audio_master(
|
!runtime
|
||||||
123_456,
|
.wait_for_audio_master(
|
||||||
tokio::time::Instant::now()
|
123_456,
|
||||||
.checked_sub(Duration::from_millis(1))
|
tokio::time::Instant::now()
|
||||||
.unwrap_or_else(tokio::time::Instant::now),
|
.checked_sub(Duration::from_millis(1))
|
||||||
)
|
.unwrap_or_else(tokio::time::Instant::now),
|
||||||
.await);
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial(upstream_media_runtime)]
|
||||||
fn timing_trace_paths_emit_overlap_and_dropbeforeoverlap_details() {
|
fn timing_trace_paths_emit_overlap_and_dropbeforeoverlap_details() {
|
||||||
temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("1"), || {
|
temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("1"), || {
|
||||||
with_info_tracing(|| {
|
with_info_tracing(|| {
|
||||||
|
|||||||
@ -79,6 +79,14 @@ mod app_support {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod relay_transport {
|
||||||
|
pub fn endpoint(server_addr: &str) -> anyhow::Result<tonic::transport::Endpoint> {
|
||||||
|
Ok(tonic::transport::Channel::from_shared(
|
||||||
|
server_addr.to_string(),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mod input {
|
mod input {
|
||||||
pub mod camera {
|
pub mod camera {
|
||||||
use crate::app_support::CameraConfig;
|
use crate::app_support::CameraConfig;
|
||||||
@ -242,6 +250,7 @@ mod tests {
|
|||||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||||
|
|
||||||
const DOWNLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/downlink_media.rs");
|
const DOWNLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/downlink_media.rs");
|
||||||
|
const INPUT_STREAMS_SRC: &str = include_str!("../../client/src/app/input_streams.rs");
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
@ -395,4 +404,12 @@ mod tests {
|
|||||||
assert!(DOWNLINK_MEDIA_SRC.contains("delay = app_support::next_delay(delay);"));
|
assert!(DOWNLINK_MEDIA_SRC.contains("delay = app_support::next_delay(delay);"));
|
||||||
assert!(DOWNLINK_MEDIA_SRC.contains("consecutive_source_failures = 0;"));
|
assert!(DOWNLINK_MEDIA_SRC.contains("consecutive_source_failures = 0;"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_streams_reconnect_quickly_then_back_off_under_outage() {
|
||||||
|
assert!(INPUT_STREAMS_SRC.contains("INPUT_RECONNECT_BASE_DELAY"));
|
||||||
|
assert!(INPUT_STREAMS_SRC.contains("Duration::from_millis(250)"));
|
||||||
|
assert!(INPUT_STREAMS_SRC.contains("delay = app_support::next_delay(delay);"));
|
||||||
|
assert!(INPUT_STREAMS_SRC.contains("tokio::time::sleep(delay).await;"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
testing/tests/client_install_script_contract.rs
Normal file
35
testing/tests/client_install_script_contract.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
//! Contract tests for client install-time security defaults.
|
||||||
|
//!
|
||||||
|
//! Scope: inspect the client installer shell contract without running it.
|
||||||
|
//! Targets: `scripts/install/client.sh`.
|
||||||
|
//! Why: secure-by-default relay transport depends on installing the server-issued
|
||||||
|
//! client cert bundle exactly where the desktop app auto-discovers it.
|
||||||
|
|
||||||
|
const CLIENT_INSTALL: &str = include_str!("../../scripts/install/client.sh");
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_install_accepts_server_generated_tls_bundle() {
|
||||||
|
for expected in [
|
||||||
|
"LESAVKA_CLIENT_PKI_BUNDLE",
|
||||||
|
"CLIENT_PKI_DIR",
|
||||||
|
"ca.crt",
|
||||||
|
"client.crt",
|
||||||
|
"client.key",
|
||||||
|
"install_client_pki_bundle",
|
||||||
|
"HTTPS relay connections will need a trusted public cert or a bundle install later",
|
||||||
|
"TLS identity:",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
CLIENT_INSTALL.contains(expected),
|
||||||
|
"client installer should include TLS bundle contract fragment {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
CLIENT_INSTALL.contains(".config/lesavka/pki"),
|
||||||
|
"client cert bundle should land in the same path the desktop app auto-loads"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
CLIENT_INSTALL.contains("0600"),
|
||||||
|
"client private key should be installed with private permissions"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -68,8 +68,8 @@ fn launcher_default_size_stays_inside_1080p() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width() {
|
fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width() {
|
||||||
assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 560);
|
assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 532);
|
||||||
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 315);
|
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 299);
|
||||||
assert!(UI_LAYOUT_SRC.contains("display_row.set_vexpand(false);"));
|
assert!(UI_LAYOUT_SRC.contains("display_row.set_vexpand(false);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("display_row.set_valign(gtk::Align::Start);"));
|
assert!(UI_LAYOUT_SRC.contains("display_row.set_valign(gtk::Align::Start);"));
|
||||||
assert!(
|
assert!(
|
||||||
@ -211,7 +211,7 @@ fn device_testing_keeps_webcam_and_mic_playback_compact() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn operations_column_fills_height_and_splits_extra_space_between_logs() {
|
fn operations_column_fills_height_and_splits_extra_space_between_logs() {
|
||||||
assert_eq!(const_i32("SIDE_LOG_MIN_HEIGHT"), 124);
|
assert_eq!(const_i32("SIDE_LOG_MIN_HEIGHT"), 124);
|
||||||
assert_eq!(const_i32("SIDE_LOG_RECOVERY_BUDGET_SPLIT"), 63);
|
assert_eq!(const_i32("SIDE_LOG_RECOVERY_BUDGET_SPLIT"), 102);
|
||||||
assert!(UI_LAYOUT_SRC.contains("operations.set_vexpand(true);"));
|
assert!(UI_LAYOUT_SRC.contains("operations.set_vexpand(true);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("operations.set_valign(gtk::Align::Fill);"));
|
assert!(UI_LAYOUT_SRC.contains("operations.set_valign(gtk::Align::Fill);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("diagnostics_panel.set_vexpand(true);"));
|
assert!(UI_LAYOUT_SRC.contains("diagnostics_panel.set_vexpand(true);"));
|
||||||
@ -271,8 +271,8 @@ fn status_chip_text_is_centered_inside_each_pill() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn relay_controls_keep_connect_inline_with_server_entry() {
|
fn relay_controls_keep_connect_inline_with_server_entry() {
|
||||||
assert!(UI_LAYOUT_SRC.contains("build_panel(\"Relay Controls\")"));
|
assert!(UI_LAYOUT_SRC.contains("build_panel(\"Relay\")"));
|
||||||
assert_eq!(const_i32("OPERATIONS_RAIL_WIDTH"), 288);
|
assert_eq!(const_i32("OPERATIONS_RAIL_WIDTH"), 276);
|
||||||
assert_eq!(const_i32("RAIL_BUTTON_WIDTH"), 86);
|
assert_eq!(const_i32("RAIL_BUTTON_WIDTH"), 86);
|
||||||
assert_eq!(const_i32("RAIL_BUTTON_LABEL_CHARS"), 11);
|
assert_eq!(const_i32("RAIL_BUTTON_LABEL_CHARS"), 11);
|
||||||
assert!(UI_LAYOUT_SRC.contains("let relay_grid = gtk::Grid::new();"));
|
assert!(UI_LAYOUT_SRC.contains("let relay_grid = gtk::Grid::new();"));
|
||||||
@ -282,13 +282,36 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
|
|||||||
assert!(UI_LAYOUT_SRC.contains("pub(crate) fn set_rail_button_label("));
|
assert!(UI_LAYOUT_SRC.contains("pub(crate) fn set_rail_button_label("));
|
||||||
assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);"));
|
assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recover\"));"));
|
assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recover\"));"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
assert!(
|
||||||
assert!(UI_LAYOUT_SRC.contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
UI_LAYOUT_SRC
|
||||||
|
.contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
UI_LAYOUT_SRC
|
||||||
|
.contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);")
|
||||||
|
);
|
||||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.set_homogeneous(true);"));
|
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.set_homogeneous(true);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("recovery_heading.set_width_chars(10);"));
|
assert!(UI_LAYOUT_SRC.contains("recovery_heading.set_width_chars(10);"));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains(
|
||||||
|
"let calibration_heading = gtk::Label::new(Some(\"AV Upstream\\nCalibration\"));"
|
||||||
|
));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("calibration_heading.set_width_chars(12);"));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("let calibration_buttons = gtk::Grid::new();"));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("calibration_buttons.set_column_homogeneous(true);"));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("let calibration_default_button = rail_button("));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("let calibration_factory_button = rail_button("));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("let calibration_blind_button = rail_button("));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("let calibration_minus_button = rail_button("));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("let calibration_plus_button = rail_button("));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("let calibration_rig_button = rail_button("));
|
||||||
assert!(UI_LAYOUT_SRC.contains("let tools_heading = gtk::Label::new(Some(\"Tools\"));"));
|
assert!(UI_LAYOUT_SRC.contains("let tools_heading = gtk::Label::new(Some(\"Tools\"));"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
assert!(
|
||||||
assert!(UI_LAYOUT_SRC.contains("let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
UI_LAYOUT_SRC.contains("let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
UI_LAYOUT_SRC
|
||||||
|
.contains("let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);")
|
||||||
|
);
|
||||||
assert!(UI_LAYOUT_SRC.contains("tools_buttons.set_homogeneous(true);"));
|
assert!(UI_LAYOUT_SRC.contains("tools_buttons.set_homogeneous(true);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(10);"));
|
assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(10);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\""));
|
assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\""));
|
||||||
@ -298,6 +321,17 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
|
|||||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&usb_recover_button);"));
|
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&usb_recover_button);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uac_recover_button);"));
|
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uac_recover_button);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uvc_recover_button);"));
|
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uvc_recover_button);"));
|
||||||
|
assert!(
|
||||||
|
source_index("let recovery_heading = gtk::Label::new(Some(\"Recover\"));")
|
||||||
|
< source_index(
|
||||||
|
"let calibration_heading = gtk::Label::new(Some(\"AV Upstream\\nCalibration\"));"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
source_index(
|
||||||
|
"let calibration_heading = gtk::Label::new(Some(\"AV Upstream\\nCalibration\"));"
|
||||||
|
) < source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")
|
||||||
|
);
|
||||||
assert!(UI_LAYOUT_SRC.contains("tools_buttons.append(&clipboard_button);"));
|
assert!(UI_LAYOUT_SRC.contains("tools_buttons.append(&clipboard_button);"));
|
||||||
assert!(!UI_LAYOUT_SRC.contains("Gate Probe"));
|
assert!(!UI_LAYOUT_SRC.contains("Gate Probe"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("text.set_ellipsize(pango::EllipsizeMode::End);"));
|
assert!(UI_LAYOUT_SRC.contains("text.set_ellipsize(pango::EllipsizeMode::End);"));
|
||||||
@ -373,8 +407,8 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() {
|
|||||||
UI_LAYOUT_SRC
|
UI_LAYOUT_SRC
|
||||||
.matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));")
|
.matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));")
|
||||||
.count(),
|
.count(),
|
||||||
3,
|
4,
|
||||||
"recover/tools/gpio/inputs sections should remain visually separated"
|
"recover/calibration/gpio/inputs/tools sections should remain visually separated"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")
|
source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")
|
||||||
@ -384,5 +418,9 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() {
|
|||||||
source_index("power_shell.append(&power_row);")
|
source_index("power_shell.append(&power_row);")
|
||||||
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
|
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
|
||||||
|
< source_index("let tools_heading = gtk::Label::new(Some(\"Tools\"));")
|
||||||
|
);
|
||||||
assert!(UI_LAYOUT_SRC.contains("routing_buttons.set_homogeneous(true);"));
|
assert!(UI_LAYOUT_SRC.contains("routing_buttons.set_homogeneous(true);"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ const UI_SRC: &str = concat!(
|
|||||||
include_str!("../../client/src/launcher/ui/relay_input_bindings.rs"),
|
include_str!("../../client/src/launcher/ui/relay_input_bindings.rs"),
|
||||||
include_str!("../../client/src/launcher/ui/runtime_poll.rs"),
|
include_str!("../../client/src/launcher/ui/runtime_poll.rs"),
|
||||||
include_str!("../../client/src/launcher/ui/stage_device_bindings.rs"),
|
include_str!("../../client/src/launcher/ui/stage_device_bindings.rs"),
|
||||||
|
include_str!("../../client/src/launcher/ui/eye_capture_bindings.rs"),
|
||||||
include_str!("../../client/src/launcher/ui/utility_button_bindings.rs"),
|
include_str!("../../client/src/launcher/ui/utility_button_bindings.rs"),
|
||||||
);
|
);
|
||||||
const DEVICE_TEST_SRC: &str = concat!(
|
const DEVICE_TEST_SRC: &str = concat!(
|
||||||
|
|||||||
54
testing/tests/client_log_noise_contract.rs
Normal file
54
testing/tests/client_log_noise_contract.rs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
//! Contracts for keeping steady-state live-media recovery logs actionable.
|
||||||
|
//!
|
||||||
|
//! Scope: inspect client live-media logging and recovery source text.
|
||||||
|
//! Targets: `client/src/app/uplink_media.rs`, `client/src/app/downlink_media.rs`.
|
||||||
|
//! Why: freshness-first media handling should not turn normal queue churn into
|
||||||
|
//! high-volume warnings that hide real failures or steal runtime budget.
|
||||||
|
|
||||||
|
const UPLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/uplink_media.rs");
|
||||||
|
const DOWNLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/downlink_media.rs");
|
||||||
|
const AUDIO_RECOVERY_SRC: &str = include_str!("../../client/src/app/audio_recovery_config.rs");
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upstream_queue_drops_are_rate_limited_instead_of_warn_spammed() {
|
||||||
|
assert!(
|
||||||
|
UPLINK_MEDIA_SRC.contains("UplinkDropLogLimiter"),
|
||||||
|
"uplink queue drops should flow through a rate-limited logger"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
UPLINK_MEDIA_SRC.contains("UPLINK_DROP_WARN_INTERVAL"),
|
||||||
|
"uplink drop warnings should have an explicit aggregation interval"
|
||||||
|
);
|
||||||
|
for noisy in [
|
||||||
|
"upstream camera queue dropped stale packets",
|
||||||
|
"upstream microphone queue dropped stale packets",
|
||||||
|
"upstream camera queue dropped the oldest frame because it was full",
|
||||||
|
"upstream microphone queue dropped the oldest packet because it was full",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!UPLINK_MEDIA_SRC.contains(noisy),
|
||||||
|
"normal freshness-first drop churn should not WARN every packet: {noisy}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_remote_audio_failures_are_rate_limited() {
|
||||||
|
assert!(
|
||||||
|
AUDIO_RECOVERY_SRC.contains("AudioFailureLogLimiter"),
|
||||||
|
"remote audio failures should flow through a rate-limited logger"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
AUDIO_RECOVERY_SRC.contains("AUDIO_FAILURE_WARN_INTERVAL"),
|
||||||
|
"audio failure warnings should have an explicit aggregation interval"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!DOWNLINK_MEDIA_SRC.contains("audio stream recv error"),
|
||||||
|
"repeated recv errors should use the audio failure limiter instead of direct WARN spam"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
DOWNLINK_MEDIA_SRC.contains("tracing::debug!(")
|
||||||
|
&& DOWNLINK_MEDIA_SRC.contains("waiting before USB recovery"),
|
||||||
|
"below-threshold auto-recovery bookkeeping should be DEBUG, not WARN"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -30,7 +30,7 @@ mod relayctl_binary {
|
|||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn connect_rejects_invalid_endpoint_without_network_retry() {
|
async fn connect_rejects_invalid_endpoint_without_network_retry() {
|
||||||
let err = connect("not a uri").await.expect_err("invalid endpoint");
|
let err = connect("http://[::1").await.expect_err("invalid endpoint");
|
||||||
assert!(err.to_string().contains("invalid relay server address"));
|
assert!(err.to_string().contains("invalid relay server address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
testing/tests/performance_gate_script_contract.rs
Normal file
42
testing/tests/performance_gate_script_contract.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
//! Contract tests for the deterministic performance gate.
|
||||||
|
//!
|
||||||
|
//! Scope: inspect CI gate scripts for latency-sensitive Lesavka checks.
|
||||||
|
//! Targets: `scripts/ci/performance_gate.sh`, `scripts/ci/platform_quality_gate.sh`.
|
||||||
|
//! Why: A/V sync and remote-control tightness are product behavior, so the main
|
||||||
|
//! quality gate must fail before release when their contracts regress.
|
||||||
|
|
||||||
|
const PERFORMANCE_GATE: &str = include_str!("../../scripts/ci/performance_gate.sh");
|
||||||
|
const PLATFORM_GATE: &str = include_str!("../../scripts/ci/platform_quality_gate.sh");
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn performance_gate_tracks_av_and_interaction_latency_contracts() {
|
||||||
|
for expected in [
|
||||||
|
"client_uplink_performance_contract",
|
||||||
|
"client_uplink_freshness_contract",
|
||||||
|
"client_log_noise_contract",
|
||||||
|
"video_downstream_feed_contract",
|
||||||
|
"client_app_include_contract",
|
||||||
|
"platform_quality_gate_checks_total",
|
||||||
|
"check=\"performance\"",
|
||||||
|
"lesavka-performance-gate",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
PERFORMANCE_GATE.contains(expected),
|
||||||
|
"performance gate should include {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_gate_runs_performance_before_media_reliability() {
|
||||||
|
let performance = PLATFORM_GATE
|
||||||
|
.find("scripts/ci/performance_gate.sh")
|
||||||
|
.expect("platform gate should run performance gate");
|
||||||
|
let media = PLATFORM_GATE
|
||||||
|
.find("scripts/ci/media_reliability_gate.sh")
|
||||||
|
.expect("platform gate should run media reliability gate");
|
||||||
|
assert!(
|
||||||
|
performance < media,
|
||||||
|
"performance regressions should fail before broader media packaging checks"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -31,6 +31,10 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
|
|||||||
"LESAVKA_UVC_HEIGHT=",
|
"LESAVKA_UVC_HEIGHT=",
|
||||||
"LESAVKA_UVC_CODEC=",
|
"LESAVKA_UVC_CODEC=",
|
||||||
"LESAVKA_UVC_CONTROL_READ_ONLY=",
|
"LESAVKA_UVC_CONTROL_READ_ONLY=",
|
||||||
|
"LESAVKA_REQUIRE_TLS=%s",
|
||||||
|
"LESAVKA_TLS_CERT=%s",
|
||||||
|
"LESAVKA_TLS_KEY=%s",
|
||||||
|
"LESAVKA_TLS_CLIENT_CA=%s",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
SERVER_INSTALL.contains(expected),
|
SERVER_INSTALL.contains(expected),
|
||||||
@ -208,6 +212,33 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_install_generates_mtls_identity_and_client_bundle() {
|
||||||
|
for expected in [
|
||||||
|
"ensure_server_tls_pki",
|
||||||
|
"openssl genrsa",
|
||||||
|
"extendedKeyUsage = serverAuth",
|
||||||
|
"extendedKeyUsage = clientAuth",
|
||||||
|
"LESAVKA_CLIENT_BUNDLE",
|
||||||
|
"lesavka-client-pki.tar.gz",
|
||||||
|
"LESAVKA_REQUIRE_TLS=%s",
|
||||||
|
"LESAVKA_TLS_CLIENT_CA=%s",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
SERVER_INSTALL.contains(expected),
|
||||||
|
"server install script should include TLS/mTLS contract fragment {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
SERVER_INSTALL.contains("LESAVKA_REQUIRE_TLS:-1"),
|
||||||
|
"installed servers should require TLS by default"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
SERVER_INSTALL.contains("38.28.125.112"),
|
||||||
|
"server cert SAN defaults should include the current public relay endpoint"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_install_reports_installed_version_and_revision() {
|
fn server_install_reports_installed_version_and_revision() {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@ -50,6 +50,9 @@ mod server_main_binary {
|
|||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
|
|||||||
@ -119,6 +119,9 @@ mod server_main_binary_extra {
|
|||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
|
|||||||
@ -89,6 +89,9 @@ mod server_main_media_extra {
|
|||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
@ -188,10 +191,17 @@ mod server_main_media_extra {
|
|||||||
drop(tx);
|
drop(tx);
|
||||||
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
let mut resp = cli
|
let mut resp = match cli.stream_camera(tonic::Request::new(outbound)).await {
|
||||||
.stream_camera(tonic::Request::new(outbound))
|
Ok(resp) => resp,
|
||||||
.await
|
Err(err)
|
||||||
.expect("stream camera should terminate cleanly");
|
if err.code() == tonic::Code::Internal
|
||||||
|
&& err.message().contains("no Lesavka video_output") =>
|
||||||
|
{
|
||||||
|
server.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(err) => panic!("stream camera should terminate cleanly: {err:?}"),
|
||||||
|
};
|
||||||
let _ = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(2),
|
std::time::Duration::from_secs(2),
|
||||||
resp.get_mut().message(),
|
resp.get_mut().message(),
|
||||||
|
|||||||
@ -12,7 +12,6 @@ mod server_main_rpc {
|
|||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use temp_env::with_var;
|
use temp_env::with_var;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn build_handler_for_tests_with_modes(
|
fn build_handler_for_tests_with_modes(
|
||||||
kb_writable: bool,
|
kb_writable: bool,
|
||||||
ms_writable: bool,
|
ms_writable: bool,
|
||||||
@ -22,7 +21,6 @@ mod server_main_rpc {
|
|||||||
let ms_path = dir.path().join("hidg1.bin");
|
let ms_path = dir.path().join("hidg1.bin");
|
||||||
std::fs::write(&kb_path, []).expect("create kb file");
|
std::fs::write(&kb_path, []).expect("create kb file");
|
||||||
std::fs::write(&ms_path, []).expect("create ms file");
|
std::fs::write(&ms_path, []).expect("create ms file");
|
||||||
|
|
||||||
let kb = tokio::fs::File::from_std(
|
let kb = tokio::fs::File::from_std(
|
||||||
std::fs::OpenOptions::new()
|
std::fs::OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
@ -41,7 +39,6 @@ mod server_main_rpc {
|
|||||||
.open(&ms_path)
|
.open(&ms_path)
|
||||||
.expect("open ms"),
|
.expect("open ms"),
|
||||||
);
|
);
|
||||||
|
|
||||||
let handler = with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || Handler {
|
let handler = with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || Handler {
|
||||||
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||||
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||||
@ -49,6 +46,9 @@ mod server_main_rpc {
|
|||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(
|
eye_hubs: std::sync::Arc::new(
|
||||||
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
@ -57,11 +57,9 @@ mod server_main_rpc {
|
|||||||
|
|
||||||
(dir, handler)
|
(dir, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {
|
fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {
|
||||||
build_handler_for_tests_with_modes(true, true)
|
build_handler_for_tests_with_modes(true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn reopen_hid_tolerates_missing_hid_endpoints() {
|
fn reopen_hid_tolerates_missing_hid_endpoints() {
|
||||||
@ -448,4 +446,54 @@ mod server_main_rpc {
|
|||||||
assert!(legacy_fallback.enabled);
|
assert!(legacy_fallback.enabled);
|
||||||
assert_eq!(legacy_fallback.mode, "forced-on");
|
assert_eq!(legacy_fallback.mode, "forced-on");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[serial]
|
||||||
|
fn calibration_rpcs_surface_current_state_and_apply_updates() {
|
||||||
|
let dir = tempdir().expect("calibration dir");
|
||||||
|
let calibration_path = dir.path().join("calibration.toml");
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_CALIBRATION_PATH",
|
||||||
|
Some(calibration_path.to_string_lossy().to_string()),
|
||||||
|
|| {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
|
||||||
|
let initial = rt
|
||||||
|
.block_on(async {
|
||||||
|
handler.get_calibration(tonic::Request::new(Empty {})).await
|
||||||
|
})
|
||||||
|
.expect("initial calibration")
|
||||||
|
.into_inner();
|
||||||
|
assert_eq!(initial.profile, "mjpeg");
|
||||||
|
assert_eq!(initial.active_audio_offset_us, -45_000);
|
||||||
|
|
||||||
|
let adjusted = rt
|
||||||
|
.block_on(async {
|
||||||
|
handler
|
||||||
|
.calibrate(tonic::Request::new(CalibrationRequest {
|
||||||
|
action: lesavka_common::lesavka::CalibrationAction::BlindEstimate
|
||||||
|
as i32,
|
||||||
|
audio_delta_us: 10_000,
|
||||||
|
video_delta_us: 2_000,
|
||||||
|
observed_delivery_skew_ms: 42.0,
|
||||||
|
observed_enqueue_skew_ms: 2.5,
|
||||||
|
note: "coverage estimate".to_string(),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.expect("calibrate")
|
||||||
|
.into_inner();
|
||||||
|
assert_eq!(adjusted.source, "blind");
|
||||||
|
assert_eq!(adjusted.active_audio_offset_us, -35_000);
|
||||||
|
assert_eq!(adjusted.active_video_offset_us, 2_000);
|
||||||
|
assert!(
|
||||||
|
std::fs::read_to_string(calibration_path)
|
||||||
|
.expect("persisted")
|
||||||
|
.contains("active_audio_offset_us=-35000")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,9 @@ mod server_main_rpc_reset {
|
|||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(
|
eye_hubs: std::sync::Arc::new(
|
||||||
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
@ -104,6 +107,9 @@ mod server_main_rpc_reset {
|
|||||||
)),
|
)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(
|
||||||
|
std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
)),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
|
|||||||
@ -119,6 +119,9 @@ mod server_main_binary_extra {
|
|||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
@ -153,6 +156,9 @@ mod server_main_binary_extra {
|
|||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(
|
eye_hubs: std::sync::Arc::new(
|
||||||
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
@ -217,6 +223,9 @@ echo noop core helper >&2
|
|||||||
)),
|
)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(
|
||||||
|
std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
)),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
@ -288,6 +297,9 @@ printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/stat
|
|||||||
)),
|
)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(
|
||||||
|
std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
)),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
|
|||||||
@ -61,6 +61,9 @@ mod server_upstream_media {
|
|||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
|
|||||||
@ -60,6 +60,9 @@ mod server_upstream_media_pairing {
|
|||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
capture_power: CapturePowerManager::new(),
|
capture_power: CapturePowerManager::new(),
|
||||||
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
@ -138,12 +141,14 @@ mod server_upstream_media_pairing {
|
|||||||
.await
|
.await
|
||||||
.expect("mouse stream should open")
|
.expect("mouse stream should open")
|
||||||
.into_inner();
|
.into_inner();
|
||||||
let echoed_mouse =
|
let echoed_mouse = tokio::time::timeout(
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(1), mouse_stream.message())
|
std::time::Duration::from_secs(1),
|
||||||
.await
|
mouse_stream.message(),
|
||||||
.expect("mouse response timeout")
|
)
|
||||||
.expect("mouse grpc")
|
.await
|
||||||
.expect("mouse echo");
|
.expect("mouse response timeout")
|
||||||
|
.expect("mouse grpc")
|
||||||
|
.expect("mouse echo");
|
||||||
assert_eq!(echoed_mouse.data, vec![8, 7, 6, 5, 4, 3, 2, 1]);
|
assert_eq!(echoed_mouse.data, vec![8, 7, 6, 5, 4, 3, 2, 1]);
|
||||||
|
|
||||||
server.abort();
|
server.abort();
|
||||||
@ -236,39 +241,43 @@ mod server_upstream_media_pairing {
|
|||||||
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
|
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
|
||||||
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || {
|
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || {
|
||||||
with_var("LESAVKA_UPSTREAM_STALE_DROP_MS", Some("0"), || {
|
with_var("LESAVKA_UPSTREAM_STALE_DROP_MS", Some("0"), || {
|
||||||
with_var("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", Some("-500000"), || {
|
with_var(
|
||||||
rt.block_on(async {
|
"LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US",
|
||||||
let (_dir, handler) = build_handler_for_tests();
|
Some("-500000"),
|
||||||
let (server, mut cli) = serve_handler(handler).await;
|
|| {
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
let (server, mut cli) = serve_handler(handler).await;
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
|
||||||
tx.send(AudioPacket {
|
tx.send(AudioPacket {
|
||||||
id: 0,
|
id: 0,
|
||||||
pts: 12_345,
|
pts: 12_345,
|
||||||
data: vec![1, 2, 3, 4, 5, 6],
|
data: vec![1, 2, 3, 4, 5, 6],
|
||||||
})
|
})
|
||||||
.await
|
|
||||||
.expect("send stale synthetic upstream audio");
|
|
||||||
drop(tx);
|
|
||||||
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
||||||
let mut response = cli
|
|
||||||
.stream_microphone(tonic::Request::new(outbound))
|
|
||||||
.await
|
.await
|
||||||
.expect("microphone stream should open");
|
.expect("send stale synthetic upstream audio");
|
||||||
let ack = tokio::time::timeout(
|
drop(tx);
|
||||||
std::time::Duration::from_secs(1),
|
|
||||||
response.get_mut().message(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("microphone ack timeout")
|
|
||||||
.expect("microphone ack grpc")
|
|
||||||
.expect("microphone ack item");
|
|
||||||
assert_eq!(ack, Empty {});
|
|
||||||
|
|
||||||
server.abort();
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
});
|
let mut response = cli
|
||||||
});
|
.stream_microphone(tonic::Request::new(outbound))
|
||||||
|
.await
|
||||||
|
.expect("microphone stream should open");
|
||||||
|
let ack = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
response.get_mut().message(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("microphone ack timeout")
|
||||||
|
.expect("microphone ack grpc")
|
||||||
|
.expect("microphone ack item");
|
||||||
|
assert_eq!(ack, Empty {});
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -440,121 +449,4 @@ mod server_upstream_media_pairing {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn stream_microphone_drops_stale_packets_when_freshness_budget_is_zero() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
|
|
||||||
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || {
|
|
||||||
with_var("LESAVKA_UPSTREAM_STALE_DROP_MS", Some("0"), || {
|
|
||||||
rt.block_on(async {
|
|
||||||
let (_dir, handler) = build_handler_for_tests();
|
|
||||||
let (server, mut cli) = serve_handler(handler).await;
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
||||||
|
|
||||||
tx.send(AudioPacket {
|
|
||||||
id: 0,
|
|
||||||
pts: 12_345,
|
|
||||||
data: vec![1, 2, 3, 4, 5, 6],
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("send stale synthetic upstream audio");
|
|
||||||
drop(tx);
|
|
||||||
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
||||||
let mut response = cli
|
|
||||||
.stream_microphone(tonic::Request::new(outbound))
|
|
||||||
.await
|
|
||||||
.expect("microphone stream should open");
|
|
||||||
let ack = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(1),
|
|
||||||
response.get_mut().message(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("microphone ack timeout")
|
|
||||||
.expect("microphone ack grpc")
|
|
||||||
.expect("microphone ack item");
|
|
||||||
assert_eq!(ack, Empty {});
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn stream_camera_drops_frames_that_never_reach_the_audio_master() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
|
|
||||||
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
|
|
||||||
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || {
|
|
||||||
rt.block_on(async {
|
|
||||||
let (_dir, handler) = build_handler_for_tests();
|
|
||||||
let (server, mut cli) = serve_handler(handler).await;
|
|
||||||
let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4);
|
|
||||||
let (video_tx, video_rx) = tokio::sync::mpsc::channel(4);
|
|
||||||
|
|
||||||
let mut audio_response = cli
|
|
||||||
.stream_microphone(tonic::Request::new(
|
|
||||||
tokio_stream::wrappers::ReceiverStream::new(audio_rx),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("microphone stream should open")
|
|
||||||
.into_inner();
|
|
||||||
let mut video_response = cli
|
|
||||||
.stream_camera(tonic::Request::new(
|
|
||||||
tokio_stream::wrappers::ReceiverStream::new(video_rx),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("camera stream should open")
|
|
||||||
.into_inner();
|
|
||||||
|
|
||||||
audio_tx
|
|
||||||
.send(AudioPacket {
|
|
||||||
id: 0,
|
|
||||||
pts: 1_000_000,
|
|
||||||
data: vec![1, 2, 3, 4],
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("send first audio packet");
|
|
||||||
video_tx
|
|
||||||
.send(VideoPacket {
|
|
||||||
id: 2,
|
|
||||||
pts: 1_050_000,
|
|
||||||
data: vec![0, 0, 0, 1, 0x65, 0x55],
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("send unmatched video packet");
|
|
||||||
drop(audio_tx);
|
|
||||||
drop(video_tx);
|
|
||||||
|
|
||||||
let audio_ack = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(1),
|
|
||||||
audio_response.message(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("microphone ack timeout")
|
|
||||||
.expect("microphone ack grpc")
|
|
||||||
.expect("microphone ack item");
|
|
||||||
let video_ack = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(1),
|
|
||||||
video_response.message(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("camera ack timeout")
|
|
||||||
.expect("camera ack grpc")
|
|
||||||
.expect("camera ack item");
|
|
||||||
assert_eq!(audio_ack, Empty {});
|
|
||||||
assert_eq!(video_ack, Empty {});
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,209 @@
|
|||||||
|
//! End-to-end server coverage for upstream media pairing and freshness.
|
||||||
|
//!
|
||||||
|
//! Scope: run a local gRPC server and verify webcam/mic packet pairing behavior.
|
||||||
|
//! Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`.
|
||||||
|
//! Why: MJPEG lip sync depends on keeping late/early packet decisions stable
|
||||||
|
//! while streams start, stop, or temporarily lose their pair.
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[allow(warnings)]
|
||||||
|
mod server_upstream_media_pairing {
|
||||||
|
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
|
||||||
|
|
||||||
|
use lesavka_common::lesavka::relay_client::RelayClient;
|
||||||
|
use serial_test::serial;
|
||||||
|
use temp_env::with_var;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use tonic::transport::Channel;
|
||||||
|
|
||||||
|
async fn connect_with_retry(addr: std::net::SocketAddr) -> Channel {
|
||||||
|
let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
|
||||||
|
.expect("endpoint")
|
||||||
|
.tcp_nodelay(true);
|
||||||
|
for _ in 0..40 {
|
||||||
|
if let Ok(channel) = endpoint.clone().connect().await {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
panic!("failed to connect to local tonic server");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let kb_path = dir.path().join("hidg0.bin");
|
||||||
|
let ms_path = dir.path().join("hidg1.bin");
|
||||||
|
std::fs::write(&kb_path, []).expect("create kb file");
|
||||||
|
std::fs::write(&ms_path, []).expect("create ms file");
|
||||||
|
|
||||||
|
let kb = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&kb_path)
|
||||||
|
.expect("open kb"),
|
||||||
|
);
|
||||||
|
let ms = tokio::fs::File::from_std(
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&ms_path)
|
||||||
|
.expect("open ms"),
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
dir,
|
||||||
|
Handler {
|
||||||
|
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
|
||||||
|
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
|
||||||
|
gadget: UsbGadget::new("lesavka"),
|
||||||
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
|
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
|
||||||
|
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
|
||||||
|
UpstreamMediaRuntime::new(),
|
||||||
|
))),
|
||||||
|
capture_power: CapturePowerManager::new(),
|
||||||
|
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_handler(
|
||||||
|
handler: Handler,
|
||||||
|
) -> (
|
||||||
|
tokio::task::JoinHandle<()>,
|
||||||
|
RelayClient<tonic::transport::Channel>,
|
||||||
|
) {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let _ = tonic::transport::Server::builder()
|
||||||
|
.add_service(RelayServer::new(handler))
|
||||||
|
.serve(addr)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
let channel = connect_with_retry(addr).await;
|
||||||
|
(server, RelayClient::new(channel))
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_microphone_drops_stale_packets_when_freshness_budget_is_zero() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
|
||||||
|
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || {
|
||||||
|
with_var("LESAVKA_UPSTREAM_STALE_DROP_MS", Some("0"), || {
|
||||||
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
let (server, mut cli) = serve_handler(handler).await;
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
|
||||||
|
tx.send(AudioPacket {
|
||||||
|
id: 0,
|
||||||
|
pts: 12_345,
|
||||||
|
data: vec![1, 2, 3, 4, 5, 6],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send stale synthetic upstream audio");
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
|
let mut response = cli
|
||||||
|
.stream_microphone(tonic::Request::new(outbound))
|
||||||
|
.await
|
||||||
|
.expect("microphone stream should open");
|
||||||
|
let ack = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
response.get_mut().message(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("microphone ack timeout")
|
||||||
|
.expect("microphone ack grpc")
|
||||||
|
.expect("microphone ack item");
|
||||||
|
assert_eq!(ack, Empty {});
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_camera_drops_frames_that_never_reach_the_audio_master() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
|
||||||
|
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
|
||||||
|
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || {
|
||||||
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
let (server, mut cli) = serve_handler(handler).await;
|
||||||
|
let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
let (video_tx, video_rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
|
||||||
|
let mut audio_response = cli
|
||||||
|
.stream_microphone(tonic::Request::new(
|
||||||
|
tokio_stream::wrappers::ReceiverStream::new(audio_rx),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("microphone stream should open")
|
||||||
|
.into_inner();
|
||||||
|
let mut video_response = cli
|
||||||
|
.stream_camera(tonic::Request::new(
|
||||||
|
tokio_stream::wrappers::ReceiverStream::new(video_rx),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("camera stream should open")
|
||||||
|
.into_inner();
|
||||||
|
|
||||||
|
audio_tx
|
||||||
|
.send(AudioPacket {
|
||||||
|
id: 0,
|
||||||
|
pts: 1_000_000,
|
||||||
|
data: vec![1, 2, 3, 4],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send first audio packet");
|
||||||
|
video_tx
|
||||||
|
.send(VideoPacket {
|
||||||
|
id: 2,
|
||||||
|
pts: 1_050_000,
|
||||||
|
data: vec![0, 0, 0, 1, 0x65, 0x55],
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send unmatched video packet");
|
||||||
|
drop(audio_tx);
|
||||||
|
drop(video_tx);
|
||||||
|
|
||||||
|
let audio_ack = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
audio_response.message(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("microphone ack timeout")
|
||||||
|
.expect("microphone ack grpc")
|
||||||
|
.expect("microphone ack item");
|
||||||
|
let video_ack = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
video_response.message(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("camera ack timeout")
|
||||||
|
.expect("camera ack grpc")
|
||||||
|
.expect("camera ack item");
|
||||||
|
assert_eq!(audio_ack, Empty {});
|
||||||
|
assert_eq!(video_ack, Empty {});
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user