feat(launcher): add webcam quality controls

This commit is contained in:
Brad Stein 2026-04-22 22:10:39 -03:00
parent b322396739
commit 3b112996dd
33 changed files with 1375 additions and 322 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.11.48" version = "0.12.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -500,6 +500,7 @@ impl LesavkaClientApp {
async fn audio_loop(ep: Channel, out: AudioOut) { async fn audio_loop(ep: Channel, out: AudioOut) {
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);
loop { loop {
let mut cli = RelayClient::new(ep.clone()); let mut cli = RelayClient::new(ep.clone());
let req = MonitorRequest { let req = MonitorRequest {
@ -515,6 +516,7 @@ impl LesavkaClientApp {
tracing::info!("🔊 audio stream opened"); tracing::info!("🔊 audio stream opened");
let mut packet_count: u64 = 0; let mut packet_count: u64 = 0;
let mut warned_no_packets = false; let mut warned_no_packets = false;
delay = Duration::from_secs(1);
loop { loop {
match tokio::time::timeout( match tokio::time::timeout(
Duration::from_secs(1), Duration::from_secs(1),
@ -533,9 +535,13 @@ impl LesavkaClientApp {
); );
} }
out.push(pkt); out.push(pkt);
consecutive_source_failures = 0;
} }
Ok(Ok(None)) => { Ok(Ok(None)) => {
tracing::warn!(packets = packet_count, "⚠️🔊 audio stream ended"); tracing::warn!(packets = packet_count, "⚠️🔊 audio stream ended");
if packet_count == 0 {
delay = app_support::next_delay(delay);
}
break; break;
} }
Ok(Err(err)) => { Ok(Err(err)) => {
@ -548,6 +554,7 @@ impl LesavkaClientApp {
&message, &message,
) )
.await; .await;
delay = app_support::next_delay(delay);
break; break;
} }
Err(_) => { Err(_) => {
@ -571,9 +578,10 @@ impl LesavkaClientApp {
&message, &message,
) )
.await; .await;
delay = app_support::next_delay(delay);
} }
} }
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(delay).await;
} }
} }

View File

@ -96,11 +96,9 @@ impl CameraCapture {
|cfg| matches!(cfg.codec, CameraCodec::Mjpeg), |cfg| matches!(cfg.codec, CameraCodec::Mjpeg),
); );
let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100); let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100);
let width = cfg.map_or_else(|| env_u32("LESAVKA_CAM_WIDTH", 1280), |cfg| cfg.width); let width = env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width));
let height = cfg.map_or_else(|| env_u32("LESAVKA_CAM_HEIGHT", 720), |cfg| cfg.height); let height = env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height));
let fps = cfg let fps = env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1);
.map_or_else(|| env_u32("LESAVKA_CAM_FPS", 25), |cfg| cfg.fps)
.max(1);
let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps); let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps);
let source_profile = camera_source_profile(allow_mjpg_source); let source_profile = camera_source_profile(allow_mjpg_source);
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg; let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;

View File

@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use super::devices::CameraMode;
const CAMERA_PREVIEW_WIDTH: i32 = 128; const CAMERA_PREVIEW_WIDTH: i32 = 128;
const CAMERA_PREVIEW_HEIGHT: i32 = 72; const CAMERA_PREVIEW_HEIGHT: i32 = 72;
const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview."; const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview.";
@ -36,6 +38,7 @@ pub enum DeviceTestKind {
pub struct DeviceTestController { pub struct DeviceTestController {
camera: Option<LocalCameraPreview>, camera: Option<LocalCameraPreview>,
selected_camera: Option<String>, selected_camera: Option<String>,
selected_camera_mode: Option<CameraMode>,
microphone: Option<LocalMicrophoneMonitor>, microphone: Option<LocalMicrophoneMonitor>,
microphone_probe: Option<LocalMicrophoneLevelProbe>, microphone_probe: Option<LocalMicrophoneLevelProbe>,
speaker: Option<Child>, speaker: Option<Child>,
@ -49,6 +52,7 @@ impl Default for DeviceTestController {
Self { Self {
camera: None, camera: None,
selected_camera: None, selected_camera: None,
selected_camera_mode: None,
microphone: None, microphone: None,
microphone_probe: None, microphone_probe: None,
speaker: None, speaker: None,
@ -74,6 +78,7 @@ impl DeviceTestController {
} }
let mut preview = LocalCameraPreview::new(camera_picture, camera_status); let mut preview = LocalCameraPreview::new(camera_picture, camera_status);
preview.set_selected(self.selected_camera.as_deref())?; preview.set_selected(self.selected_camera.as_deref())?;
preview.set_selected_mode(self.selected_camera_mode)?;
self.camera = Some(preview); self.camera = Some(preview);
Ok(()) Ok(())
} }
@ -107,6 +112,14 @@ impl DeviceTestController {
Ok(()) Ok(())
} }
pub fn set_camera_quality(&mut self, mode: Option<CameraMode>) -> Result<()> {
self.selected_camera_mode = mode;
if let Some(preview) = self.camera.as_mut() {
preview.set_selected_mode(mode)?;
}
Ok(())
}
pub fn toggle_camera(&mut self) -> Result<bool> { pub fn toggle_camera(&mut self) -> Result<bool> {
let preview = self let preview = self
.camera .camera
@ -361,6 +374,7 @@ struct LocalCameraPreview {
generation: Arc<AtomicU64>, generation: Arc<AtomicU64>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
selected_device: Option<String>, selected_device: Option<String>,
selected_mode: Option<CameraMode>,
relay_preview_path: Option<PathBuf>, relay_preview_path: Option<PathBuf>,
} }
@ -422,6 +436,7 @@ impl LocalCameraPreview {
generation, generation,
running, running,
selected_device: None, selected_device: None,
selected_mode: None,
relay_preview_path: None, relay_preview_path: None,
} }
} }
@ -456,6 +471,27 @@ impl LocalCameraPreview {
Ok(()) Ok(())
} }
fn set_selected_mode(&mut self, mode: Option<CameraMode>) -> Result<()> {
self.selected_mode = mode;
if self.is_device_preview_running() {
self.stop();
self.start()?;
return Ok(());
}
if let Some(camera) = self.selected_device.as_deref() {
let quality = self
.selected_mode
.map(CameraMode::short_label)
.unwrap_or_else(|| "default quality".to_string());
self.set_status(format!(
"Selected {camera} at {quality}. Start Preview to confirm webcam framing here before you launch the relay."
));
}
Ok(())
}
fn toggle(&mut self) -> Result<bool> { fn toggle(&mut self) -> Result<bool> {
if self.is_running() { if self.is_running() {
self.stop(); self.stop();
@ -472,6 +508,7 @@ impl LocalCameraPreview {
.clone() .clone()
.ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?; .ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?;
self.relay_preview_path = None; self.relay_preview_path = None;
let mode = self.selected_mode;
let device = resolve_camera_device(&selected); let device = resolve_camera_device(&selected);
let latest = Arc::clone(&self.latest); let latest = Arc::clone(&self.latest);
let status_text = Arc::clone(&self.status_text); let status_text = Arc::clone(&self.status_text);
@ -485,6 +522,7 @@ impl LocalCameraPreview {
if let Err(err) = run_camera_preview_feed( if let Err(err) = run_camera_preview_feed(
selected, selected,
device, device,
mode,
token, token,
latest, latest,
status_text.clone(), status_text.clone(),
@ -551,8 +589,12 @@ impl LocalCameraPreview {
} else { } else {
match self.selected_device.as_deref() { match self.selected_device.as_deref() {
Some(camera) => { Some(camera) => {
let quality = self
.selected_mode
.map(CameraMode::short_label)
.unwrap_or_else(|| "default quality".to_string());
format!( format!(
"Local preview stopped. {camera} stays selected for the next relay launch." "Local preview stopped. {camera} at {quality} stays selected for the next relay launch."
) )
} }
None => CAMERA_PREVIEW_IDLE.to_string(), None => CAMERA_PREVIEW_IDLE.to_string(),
@ -728,19 +770,23 @@ fn run_microphone_monitor_feed(
fn run_camera_preview_feed( fn run_camera_preview_feed(
selected: String, selected: String,
device: String, device: String,
mode: Option<CameraMode>,
token: u64, token: u64,
latest: Arc<Mutex<Option<PreviewFrame>>>, latest: Arc<Mutex<Option<PreviewFrame>>>,
status_text: Arc<Mutex<String>>, status_text: Arc<Mutex<String>>,
generation: Arc<AtomicU64>, generation: Arc<AtomicU64>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
) -> Result<()> { ) -> Result<()> {
let (pipeline, appsink) = build_camera_preview_pipeline(&device)?; let (pipeline, appsink) = build_camera_preview_pipeline(&device, mode)?;
pipeline pipeline
.set_state(gst::State::Playing) .set_state(gst::State::Playing)
.context("starting in-launcher camera preview pipeline")?; .context("starting in-launcher camera preview pipeline")?;
if let Ok(mut status) = status_text.lock() { if let Ok(mut status) = status_text.lock() {
*status = format!("Local preview live for {selected}."); let quality = mode
.map(CameraMode::short_label)
.unwrap_or_else(|| "default quality".to_string());
*status = format!("Local preview live for {selected} at {quality}.");
} }
while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token {
@ -815,8 +861,11 @@ fn run_microphone_level_probe(
running.store(false, Ordering::Release); running.store(false, Ordering::Release);
} }
fn build_camera_preview_pipeline(device: &str) -> Result<(gst::Pipeline, gst_app::AppSink)> { fn build_camera_preview_pipeline(
let desc = camera_preview_pipeline_desc(device); device: &str,
mode: Option<CameraMode>,
) -> Result<(gst::Pipeline, gst_app::AppSink)> {
let desc = camera_preview_pipeline_desc(device, mode);
let pipeline = gst::parse::launch(&desc)? let pipeline = gst::parse::launch(&desc)?
.downcast::<gst::Pipeline>() .downcast::<gst::Pipeline>()
.expect("camera preview pipeline"); .expect("camera preview pipeline");
@ -858,11 +907,19 @@ fn build_microphone_monitor_pipeline(
Ok((pipeline, appsink)) Ok((pipeline, appsink))
} }
fn camera_preview_pipeline_desc(device: &str) -> String { fn camera_preview_pipeline_desc(device: &str, mode: Option<CameraMode>) -> String {
let device = gst_quote(device); let device = gst_quote(device);
let source_caps = mode
.map(|mode| {
format!(
"capsfilter caps=\"video/x-raw,width=(int){},height=(int){},framerate=(fraction){}/1;image/jpeg,width=(int){},height=(int){},framerate=(fraction){}/1\" ! decodebin ! ",
mode.width, mode.height, mode.fps, mode.width, mode.height, mode.fps
)
})
.unwrap_or_default();
format!( format!(
"v4l2src device=\"{device}\" do-timestamp=true ! \ "v4l2src device=\"{device}\" do-timestamp=true ! \
videoconvert ! videoscale ! videorate ! \ {source_caps}videoconvert ! videoscale ! videorate ! \
video/x-raw,format=RGBA,width={CAMERA_PREVIEW_WIDTH},height={CAMERA_PREVIEW_HEIGHT},framerate=30/1,pixel-aspect-ratio=1/1 ! \ video/x-raw,format=RGBA,width={CAMERA_PREVIEW_WIDTH},height={CAMERA_PREVIEW_HEIGHT},framerate=30/1,pixel-aspect-ratio=1/1 ! \
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true" appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true"
) )
@ -1053,6 +1110,7 @@ mod tests {
microphone_monitor_pipeline_desc, normalize_camera_selection, push_recent_audio, microphone_monitor_pipeline_desc, normalize_camera_selection, push_recent_audio,
read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device, read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device,
}; };
use crate::launcher::devices::CameraMode;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
#[test] #[test]
@ -1077,12 +1135,25 @@ mod tests {
#[test] #[test]
fn camera_preview_pipeline_scales_after_source_instead_of_pinning_raw_source_caps() { fn camera_preview_pipeline_scales_after_source_instead_of_pinning_raw_source_caps() {
let desc = camera_preview_pipeline_desc("/dev/video0"); let desc = camera_preview_pipeline_desc("/dev/video0", None);
assert!(desc.contains("v4l2src device=\"/dev/video0\"")); assert!(desc.contains("v4l2src device=\"/dev/video0\""));
assert!(desc.contains("videoconvert ! videoscale ! videorate !")); assert!(desc.contains("videoconvert ! videoscale ! videorate !"));
assert!(!desc.contains("v4l2src device=\"/dev/video0\" do-timestamp=true ! video/x-raw,")); assert!(!desc.contains("v4l2src device=\"/dev/video0\" do-timestamp=true ! video/x-raw,"));
} }
#[test]
fn camera_preview_pipeline_requests_selected_webcam_quality_before_scaling() {
let desc =
camera_preview_pipeline_desc("/dev/video0", Some(CameraMode::new(1920, 1080, 30)));
assert!(
desc.contains("video/x-raw,width=(int)1920,height=(int)1080,framerate=(fraction)30/1")
);
assert!(
desc.contains("image/jpeg,width=(int)1920,height=(int)1080,framerate=(fraction)30/1")
);
assert!(desc.contains("decodebin ! videoconvert ! videoscale"));
}
#[test] #[test]
fn microphone_monitor_uses_pulse_for_launcher_catalog_source_names() { fn microphone_monitor_uses_pulse_for_launcher_catalog_source_names() {
let desc = microphone_monitor_pipeline_desc( let desc = microphone_monitor_pipeline_desc(

View File

@ -1,17 +1,61 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode}; use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeviceCatalog { pub struct DeviceCatalog {
pub cameras: Vec<String>, pub cameras: Vec<String>,
pub camera_modes: BTreeMap<String, Vec<CameraMode>>,
pub microphones: Vec<String>, pub microphones: Vec<String>,
pub speakers: Vec<String>, pub speakers: Vec<String>,
pub keyboards: Vec<String>, pub keyboards: Vec<String>,
pub mice: Vec<String>, pub mice: Vec<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct CameraMode {
pub width: u32,
pub height: u32,
pub fps: u32,
}
impl CameraMode {
pub const fn new(width: u32, height: u32, fps: u32) -> Self {
Self { width, height, fps }
}
pub fn id(self) -> String {
format!("{}x{}@{}", self.width, self.height, self.fps)
}
pub fn from_id(raw: &str) -> Option<Self> {
let (size, fps) = raw.split_once('@')?;
let (width, height) = size.split_once('x')?;
Some(Self {
width: width.parse().ok()?,
height: height.parse().ok()?,
fps: fps.parse().ok()?,
})
.filter(|mode| mode.width > 0 && mode.height > 0 && mode.fps > 0)
}
pub fn short_label(self) -> String {
format!("{}p@{}", self.height, self.fps)
}
pub fn h264_bitrate_kbit(self) -> u32 {
if self.width >= 1920 || self.height >= 1080 {
12_000
} else if self.width >= 1280 || self.height >= 720 {
6_000
} else {
3_000
}
}
}
impl DeviceCatalog { impl DeviceCatalog {
pub fn discover() -> Self { pub fn discover() -> Self {
Self::discover_with_camera_override(std::env::var("LESAVKA_LAUNCHER_CAMERA_DIR").ok()) Self::discover_with_camera_override(std::env::var("LESAVKA_LAUNCHER_CAMERA_DIR").ok())
@ -27,12 +71,14 @@ impl DeviceCatalog {
fn discover_with_camera_override(override_dir: Option<String>) -> Self { fn discover_with_camera_override(override_dir: Option<String>) -> Self {
let cameras = discover_camera_devices(override_dir); let cameras = discover_camera_devices(override_dir);
let camera_modes = discover_camera_modes(&cameras);
let microphones = discover_microphone_devices(); let microphones = discover_microphone_devices();
let speakers = discover_speaker_devices(); let speakers = discover_speaker_devices();
let keyboards = discover_input_devices(InputDeviceKind::Keyboard); let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
let mice = discover_input_devices(InputDeviceKind::Mouse); let mice = discover_input_devices(InputDeviceKind::Mouse);
Self { Self {
cameras, cameras,
camera_modes,
microphones, microphones,
speakers, speakers,
keyboards, keyboards,
@ -118,6 +164,87 @@ fn discover_camera_devices(override_dir: Option<String>) -> Vec<String> {
dedupe_camera_devices(set) dedupe_camera_devices(set)
} }
fn discover_camera_modes(cameras: &[String]) -> BTreeMap<String, Vec<CameraMode>> {
cameras
.iter()
.filter_map(|camera| {
let modes = discover_camera_modes_for(camera);
(!modes.is_empty()).then(|| (camera.clone(), modes))
})
.collect()
}
fn discover_camera_modes_for(camera: &str) -> Vec<CameraMode> {
let device = if camera.starts_with("/dev/") {
camera.to_string()
} else {
format!("/dev/v4l/by-id/{camera}")
};
let output = std::process::Command::new("v4l2-ctl")
.args(["--list-formats-ext", "-d", &device])
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
parse_supported_camera_modes(&String::from_utf8_lossy(&output.stdout))
}
pub fn lesavka_supported_camera_modes() -> Vec<CameraMode> {
vec![
CameraMode::new(1920, 1080, 30),
CameraMode::new(1280, 720, 30),
]
}
pub fn parse_supported_camera_modes(stdout: &str) -> Vec<CameraMode> {
let mut discovered = BTreeSet::new();
let mut current_size = None::<(u32, u32)>;
for line in stdout.lines() {
let trimmed = line.trim();
if let Some(size) = parse_v4l2_size_line(trimmed) {
current_size = Some(size);
continue;
}
if trimmed.starts_with("Interval:")
&& let Some((width, height)) = current_size
&& let Some(fps) = parse_v4l2_interval_fps(trimmed)
{
discovered.insert(CameraMode::new(width, height, fps));
}
}
lesavka_supported_camera_modes()
.into_iter()
.filter(|supported| {
discovered.iter().any(|actual| {
actual.width == supported.width
&& actual.height == supported.height
&& actual.fps >= supported.fps
})
})
.collect()
}
fn parse_v4l2_size_line(line: &str) -> Option<(u32, u32)> {
let (_, rest) = line.split_once("Size:")?;
let token = rest.split_whitespace().find(|part| part.contains('x'))?;
let (width, height) = token.split_once('x')?;
Some((width.parse().ok()?, height.parse().ok()?))
.filter(|(width, height)| *width > 0 && *height > 0)
}
fn parse_v4l2_interval_fps(line: &str) -> Option<u32> {
let (_, rest) = line.split_once('(')?;
let (fps, _) = rest.split_once(" fps")?;
let rounded = fps.parse::<f32>().ok()?.round() as u32;
(rounded > 0).then_some(rounded)
}
fn discover_pactl_devices(kind: &str) -> Vec<String> { fn discover_pactl_devices(kind: &str) -> Vec<String> {
let output = std::process::Command::new("pactl") let output = std::process::Command::new("pactl")
.args(["list", "short", kind]) .args(["list", "short", kind])
@ -331,6 +458,43 @@ mod tests {
let _ = discover_camera_devices(None); let _ = discover_camera_devices(None);
} }
#[test]
fn camera_mode_parser_keeps_only_supported_lesavka_qualities() {
let stdout = r#"
ioctl: VIDIOC_ENUM_FMT
Type: Video Capture
[0]: 'MJPG' (Motion-JPEG, compressed)
Size: Discrete 1920x1080
Interval: Discrete 0.033s (30.000 fps)
Interval: Discrete 0.067s (15.000 fps)
Size: Discrete 1280x720
Interval: Discrete 0.017s (60.000 fps)
Size: Discrete 640x480
Interval: Discrete 0.033s (30.000 fps)
[1]: 'YUYV' (YUYV 4:2:2)
Size: Discrete 1920x1080
Interval: Discrete 0.200s (5.000 fps)
"#;
assert_eq!(
parse_supported_camera_modes(stdout),
vec![
CameraMode::new(1920, 1080, 30),
CameraMode::new(1280, 720, 30)
]
);
}
#[test]
fn camera_mode_ids_are_short_and_round_trippable() {
let mode = CameraMode::new(1920, 1080, 30);
assert_eq!(mode.id(), "1920x1080@30");
assert_eq!(mode.short_label(), "1080p@30");
assert_eq!(CameraMode::from_id("1920x1080@30"), Some(mode));
assert_eq!(CameraMode::from_id("not-a-mode"), None);
}
#[test] #[test]
fn discover_uses_override_and_tolerates_missing_pactl() { fn discover_uses_override_and_tolerates_missing_pactl() {
let tmp = mk_temp_dir("discover-override"); let tmp = mk_temp_dir("discover-override");

View File

@ -157,6 +157,15 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
&& let Some(camera) = state.devices.camera.as_ref() && let Some(camera) = state.devices.camera.as_ref()
{ {
envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone()); envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone());
if let Some(mode) = state.camera_quality {
envs.insert("LESAVKA_CAM_WIDTH".to_string(), mode.width.to_string());
envs.insert("LESAVKA_CAM_HEIGHT".to_string(), mode.height.to_string());
envs.insert("LESAVKA_CAM_FPS".to_string(), mode.fps.to_string());
envs.insert(
"LESAVKA_CAM_H264_KBIT".to_string(),
mode.h264_bitrate_kbit().to_string(),
);
}
} else { } else {
envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string()); envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string());
} }
@ -256,6 +265,7 @@ mod tests {
state.set_routing(InputRouting::Local); state.set_routing(InputRouting::Local);
state.set_view_mode(ViewMode::Unified); state.set_view_mode(ViewMode::Unified);
state.select_camera(Some("/dev/video0".to_string())); state.select_camera(Some("/dev/video0".to_string()));
state.select_camera_quality(Some(devices::CameraMode::new(1920, 1080, 30)));
state.select_microphone(Some("alsa_input.test".to_string())); state.select_microphone(Some("alsa_input.test".to_string()));
state.select_speaker(Some("alsa_output.test".to_string())); state.select_speaker(Some("alsa_output.test".to_string()));
state.set_camera_channel_enabled(true); state.set_camera_channel_enabled(true);
@ -275,6 +285,13 @@ mod tests {
); );
assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string())); assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string()));
assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string())); assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string()));
assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string()));
assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string()));
assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string()));
assert_eq!(
envs.get("LESAVKA_CAM_H264_KBIT"),
Some(&"12000".to_string())
);
assert_eq!( assert_eq!(
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()) Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())

View File

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::devices::DeviceCatalog; use super::devices::{CameraMode, DeviceCatalog};
use lesavka_common::eye_source::{ use lesavka_common::eye_source::{
EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes, EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes,
}; };
@ -336,6 +336,7 @@ pub struct LauncherState {
pub capture_bitrates_kbit: [u32; 2], pub capture_bitrates_kbit: [u32; 2],
pub breakout_sizes: [BreakoutSizePreset; 2], pub breakout_sizes: [BreakoutSizePreset; 2],
pub devices: DeviceSelection, pub devices: DeviceSelection,
pub camera_quality: Option<CameraMode>,
pub channels: ChannelSelection, pub channels: ChannelSelection,
pub audio_gain_percent: u32, pub audio_gain_percent: u32,
pub mic_gain_percent: u32, pub mic_gain_percent: u32,
@ -364,6 +365,7 @@ impl Default for LauncherState {
capture_bitrates_kbit: [18_000, 18_000], capture_bitrates_kbit: [18_000, 18_000],
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
devices: DeviceSelection::default(), devices: DeviceSelection::default(),
camera_quality: None,
channels: ChannelSelection::default(), channels: ChannelSelection::default(),
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT, audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT, mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT,
@ -658,6 +660,30 @@ impl LauncherState {
self.devices.camera = normalize_selection(camera); self.devices.camera = normalize_selection(camera);
} }
pub fn select_camera_quality(&mut self, mode: Option<CameraMode>) {
self.camera_quality = mode;
}
pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec<CameraMode> {
self.devices
.camera
.as_ref()
.and_then(|camera| catalog.camera_modes.get(camera))
.cloned()
.unwrap_or_default()
}
pub fn selected_camera_quality(&self, catalog: &DeviceCatalog) -> Option<CameraMode> {
let options = self.camera_quality_options(catalog);
self.camera_quality
.filter(|selected| options.contains(selected))
.or_else(|| options.first().copied())
}
pub fn normalize_camera_quality(&mut self, catalog: &DeviceCatalog) {
self.camera_quality = self.selected_camera_quality(catalog);
}
pub fn select_microphone(&mut self, microphone: Option<String>) { pub fn select_microphone(&mut self, microphone: Option<String>) {
self.devices.microphone = normalize_selection(microphone); self.devices.microphone = normalize_selection(microphone);
} }
@ -720,6 +746,7 @@ impl LauncherState {
pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) { pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) {
keep_or_select_first(&mut self.devices.camera, &catalog.cameras); keep_or_select_first(&mut self.devices.camera, &catalog.cameras);
self.normalize_camera_quality(catalog);
keep_or_select_first(&mut self.devices.microphone, &catalog.microphones); keep_or_select_first(&mut self.devices.microphone, &catalog.microphones);
keep_or_select_first(&mut self.devices.speaker, &catalog.speakers); keep_or_select_first(&mut self.devices.speaker, &catalog.speakers);
} }
@ -778,7 +805,7 @@ impl LauncherState {
pub fn status_line(&self) -> String { pub fn status_line(&self) -> String {
format!( format!(
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}", "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
self.server_available, self.server_available,
match self.routing { match self.routing {
InputRouting::Local => "local", InputRouting::Local => "local",
@ -801,6 +828,9 @@ impl LauncherState {
self.feed_source_preset(0).as_id(), self.feed_source_preset(0).as_id(),
self.feed_source_preset(1).as_id(), self.feed_source_preset(1).as_id(),
media_status_label(self.channels.camera, self.devices.camera.as_deref()), 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.microphone, self.devices.microphone.as_deref()),
media_status_label(self.channels.audio, self.devices.speaker.as_deref()), media_status_label(self.channels.audio, self.devices.speaker.as_deref()),
self.channels.camera, self.channels.camera,
@ -1179,6 +1209,12 @@ mod tests {
let catalog = DeviceCatalog { let catalog = DeviceCatalog {
cameras: vec!["/dev/video0".to_string()], cameras: vec!["/dev/video0".to_string()],
camera_modes: [(
"/dev/video0".to_string(),
vec![CameraMode::new(1920, 1080, 30)],
)]
.into_iter()
.collect(),
microphones: vec!["alsa_input.usb".to_string()], microphones: vec!["alsa_input.usb".to_string()],
speakers: vec!["alsa_output.usb".to_string()], speakers: vec!["alsa_output.usb".to_string()],
keyboards: vec!["/dev/input/event10".to_string()], keyboards: vec!["/dev/input/event10".to_string()],
@ -1197,10 +1233,56 @@ mod tests {
let mut fresh = LauncherState::new(); let mut fresh = LauncherState::new();
fresh.apply_catalog_defaults(&catalog); fresh.apply_catalog_defaults(&catalog);
assert_eq!(fresh.devices.camera.as_deref(), Some("/dev/video0")); assert_eq!(fresh.devices.camera.as_deref(), Some("/dev/video0"));
assert_eq!(fresh.camera_quality, Some(CameraMode::new(1920, 1080, 30)));
assert_eq!(fresh.devices.microphone.as_deref(), Some("alsa_input.usb")); assert_eq!(fresh.devices.microphone.as_deref(), Some("alsa_input.usb"));
assert_eq!(fresh.devices.speaker.as_deref(), Some("alsa_output.usb")); assert_eq!(fresh.devices.speaker.as_deref(), Some("alsa_output.usb"));
} }
#[test]
fn camera_quality_tracks_selected_camera_supported_modes() {
let catalog = DeviceCatalog {
cameras: vec!["cam-a".to_string(), "cam-b".to_string()],
camera_modes: [
(
"cam-a".to_string(),
vec![
CameraMode::new(1920, 1080, 30),
CameraMode::new(1280, 720, 30),
],
),
("cam-b".to_string(), vec![CameraMode::new(1280, 720, 30)]),
]
.into_iter()
.collect(),
..DeviceCatalog::default()
};
let mut state = LauncherState::new();
state.apply_catalog_defaults(&catalog);
assert_eq!(state.devices.camera.as_deref(), Some("cam-a"));
assert_eq!(
state.camera_quality_options(&catalog),
vec![
CameraMode::new(1920, 1080, 30),
CameraMode::new(1280, 720, 30)
]
);
state.select_camera_quality(Some(CameraMode::new(1280, 720, 30)));
assert_eq!(
state.selected_camera_quality(&catalog),
Some(CameraMode::new(1280, 720, 30))
);
state.select_camera(Some("cam-b".to_string()));
state.normalize_camera_quality(&catalog);
assert_eq!(state.camera_quality, Some(CameraMode::new(1280, 720, 30)));
state.select_camera(None);
state.normalize_camera_quality(&catalog);
assert_eq!(state.camera_quality, None);
}
#[test] #[test]
fn audio_gain_is_clamped_and_formatted_for_launcher_and_runtime() { fn audio_gain_is_clamped_and_formatted_for_launcher_and_runtime() {
let mut state = LauncherState::new(); let mut state = LauncherState::new();
@ -1244,6 +1326,7 @@ mod tests {
state.set_routing(InputRouting::Local); state.set_routing(InputRouting::Local);
state.set_view_mode(ViewMode::Unified); state.set_view_mode(ViewMode::Unified);
state.select_camera(Some("/dev/video0".to_string())); state.select_camera(Some("/dev/video0".to_string()));
state.select_camera_quality(Some(CameraMode::new(1920, 1080, 30)));
state.select_microphone(Some("alsa_input.usb".to_string())); state.select_microphone(Some("alsa_input.usb".to_string()));
state.select_speaker(Some("alsa_output.usb".to_string())); state.select_speaker(Some("alsa_output.usb".to_string()));
state.set_camera_channel_enabled(true); state.set_camera_channel_enabled(true);
@ -1263,6 +1346,7 @@ mod tests {
assert!(status.contains("d1=preview")); assert!(status.contains("d1=preview"));
assert!(status.contains("d2=preview")); assert!(status.contains("d2=preview"));
assert!(status.contains("camera=/dev/video0")); assert!(status.contains("camera=/dev/video0"));
assert!(status.contains("camera_quality=1080p@30"));
assert!(status.contains("mic=alsa_input.usb")); assert!(status.contains("mic=alsa_input.usb"));
assert!(status.contains("speaker=alsa_output.usb")); assert!(status.contains("speaker=alsa_output.usb"));
assert!(status.contains("audio_gain=200%")); assert!(status.contains("audio_gain=200%"));

View File

@ -4,7 +4,7 @@ use anyhow::Result;
use { use {
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::DeviceCatalog, super::devices::{CameraMode, DeviceCatalog},
super::diagnostics::{PerformanceSample, quality_probe_command}, super::diagnostics::{PerformanceSample, quality_probe_command},
super::launcher_clipboard_control_path, super::launcher_clipboard_control_path,
super::launcher_focus_signal_path, super::launcher_focus_signal_path,
@ -14,7 +14,10 @@ use {
FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
MAX_MIC_GAIN_PERCENT, MAX_MIC_GAIN_PERCENT,
}, },
super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo}, super::ui_components::{
build_launcher_view, sync_camera_quality_combo, sync_input_device_combo,
sync_stage_device_combo,
},
super::ui_runtime::{ super::ui_runtime::{
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams, RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log, audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log,
@ -153,6 +156,25 @@ fn retained_input_selection(current: Option<&str>, values: &[String]) -> Option<
.map(str::to_string) .map(str::to_string)
} }
#[cfg(not(coverage))]
fn selected_camera_quality(combo: &gtk::ComboBoxText) -> Option<CameraMode> {
combo.active_id().as_deref().and_then(CameraMode::from_id)
}
#[cfg(not(coverage))]
fn sync_camera_quality_selection(
combo: &gtk::ComboBoxText,
state: &mut LauncherState,
catalog: &DeviceCatalog,
) {
state.normalize_camera_quality(catalog);
sync_camera_quality_combo(
combo,
&state.camera_quality_options(catalog),
state.camera_quality,
);
}
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 {
if samples.len() < 2 { if samples.len() < 2 {
@ -705,9 +727,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let app = gtk::Application::builder() let app = gtk::Application::builder()
.application_id("dev.lesavka.launcher") .application_id("dev.lesavka.launcher")
.build(); .build();
let catalog = Rc::new(DeviceCatalog::discover()); let catalog = Rc::new(RefCell::new(DeviceCatalog::discover()));
let state = Rc::new(RefCell::new(LauncherState::new())); let state = Rc::new(RefCell::new(LauncherState::new()));
state.borrow_mut().apply_catalog_defaults(&catalog); state.borrow_mut().apply_catalog_defaults(&catalog.borrow());
let child_proc = Rc::new(RefCell::new(None::<RelayChild>)); let child_proc = Rc::new(RefCell::new(None::<RelayChild>));
let tests = Rc::new(RefCell::new(DeviceTestController::new())); let tests = Rc::new(RefCell::new(DeviceTestController::new()));
let server_addr = Rc::new(server_addr); let server_addr = Rc::new(server_addr);
@ -760,12 +782,14 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
state.set_breakout_display_size(display_width, display_height); state.set_breakout_display_size(display_width, display_height);
state.set_breakout_limit_size(physical_width, physical_height); state.set_breakout_limit_size(physical_width, physical_height);
} }
let view = build_launcher_view(app, server_addr.as_ref(), &catalog, &state.borrow()); let view =
build_launcher_view(app, server_addr.as_ref(), &catalog.borrow(), &state.borrow());
let window = view.window.clone(); let window = view.window.clone();
let (launcher_width, launcher_height) = launcher_default_size(display_width, display_height); let (launcher_width, launcher_height) = launcher_default_size(display_width, display_height);
window.set_default_size(launcher_width, launcher_height); window.set_default_size(launcher_width, launcher_height);
let server_entry = view.server_entry.clone(); let server_entry = view.server_entry.clone();
let camera_combo = view.camera_combo.clone(); let camera_combo = view.camera_combo.clone();
let camera_quality_combo = view.camera_quality_combo.clone();
let microphone_combo = view.microphone_combo.clone(); let microphone_combo = view.microphone_combo.clone();
let speaker_combo = view.speaker_combo.clone(); let speaker_combo = view.speaker_combo.clone();
let keyboard_combo = view.keyboard_combo.clone(); let keyboard_combo = view.keyboard_combo.clone();
@ -848,6 +872,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
.status_label .status_label
.set_text(&format!("Camera staging setup failed: {err}")); .set_text(&format!("Camera staging setup failed: {err}"));
} }
if let Err(err) = tests.set_camera_quality(state.borrow().camera_quality) {
widgets
.status_label
.set_text(&format!("Camera quality staging setup failed: {err}"));
}
} }
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
@ -878,24 +907,68 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
{ {
let state = Rc::clone(&state); let state = Rc::clone(&state);
let catalog = Rc::clone(&catalog);
let widgets = widgets.clone(); let widgets = widgets.clone();
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);
let tests = Rc::clone(&tests); let tests = Rc::clone(&tests);
let camera_combo = camera_combo.clone(); let camera_combo = camera_combo.clone();
let camera_quality_combo = camera_quality_combo.clone();
let camera_combo_read = camera_combo.clone(); let camera_combo_read = camera_combo.clone();
camera_combo.connect_changed(move |_| { camera_combo.connect_changed(move |_| {
let selected = selected_combo_value(&camera_combo_read); let selected = selected_combo_value(&camera_combo_read);
let preview_was_running = let preview_was_running =
tests.borrow_mut().is_running(DeviceTestKind::Camera); tests.borrow_mut().is_running(DeviceTestKind::Camera);
state.borrow_mut().select_camera(selected.clone()); {
let catalog = catalog.borrow();
let mut state = state.borrow_mut();
state.select_camera(selected.clone());
sync_camera_quality_selection(&camera_quality_combo, &mut state, &catalog);
}
let quality = state.borrow().camera_quality;
if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) { if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) {
widgets widgets
.status_label .status_label
.set_text(&format!("Camera preview update failed: {err}")); .set_text(&format!("Camera preview update failed: {err}"));
} else if let Err(err) = tests.borrow_mut().set_camera_quality(quality) {
widgets
.status_label
.set_text(&format!("Camera quality update failed: {err}"));
} else if preview_was_running {
widgets.status_label.set_text(&format!(
"Local camera preview switched to {}{}.",
selected.as_deref().unwrap_or("no camera"),
quality
.map(|mode| format!(" at {}", mode.short_label()))
.unwrap_or_default()
));
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
});
}
{
let state = Rc::clone(&state);
let widgets = widgets.clone();
let child_proc = Rc::clone(&child_proc);
let tests = Rc::clone(&tests);
let camera_quality_combo = camera_quality_combo.clone();
let camera_quality_combo_read = camera_quality_combo.clone();
camera_quality_combo.connect_changed(move |_| {
let selected = selected_camera_quality(&camera_quality_combo_read);
let preview_was_running =
tests.borrow_mut().is_running(DeviceTestKind::Camera);
state.borrow_mut().select_camera_quality(selected);
if let Err(err) = tests.borrow_mut().set_camera_quality(selected) {
widgets
.status_label
.set_text(&format!("Camera quality update failed: {err}"));
} else if preview_was_running { } else if preview_was_running {
widgets.status_label.set_text(&format!( widgets.status_label.set_text(&format!(
"Local camera preview switched to {}.", "Local camera preview switched to {}.",
selected.as_deref().unwrap_or("no camera") selected
.map(CameraMode::short_label)
.unwrap_or_else(|| "default quality".to_string())
)); ));
} }
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
@ -1259,17 +1332,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
{ {
let state = Rc::clone(&state); let state = Rc::clone(&state);
let catalog_state = Rc::clone(&catalog);
let widgets = widgets.clone(); let widgets = widgets.clone();
let widgets_handle = widgets.clone(); let widgets_handle = widgets.clone();
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);
let tests = Rc::clone(&tests); let tests = Rc::clone(&tests);
let camera_combo = camera_combo.clone(); let camera_combo = camera_combo.clone();
let camera_quality_combo = camera_quality_combo.clone();
let microphone_combo = microphone_combo.clone(); let microphone_combo = microphone_combo.clone();
let speaker_combo = speaker_combo.clone(); let speaker_combo = speaker_combo.clone();
let keyboard_combo = keyboard_combo.clone(); let keyboard_combo = keyboard_combo.clone();
let mouse_combo = mouse_combo.clone(); let mouse_combo = mouse_combo.clone();
widgets.device_refresh_button.connect_clicked(move |_| { widgets.device_refresh_button.connect_clicked(move |_| {
let catalog = DeviceCatalog::discover(); let fresh_catalog = DeviceCatalog::discover();
let ( let (
selected_camera, selected_camera,
selected_microphone, selected_microphone,
@ -1281,56 +1356,65 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
( (
retained_stage_selection( retained_stage_selection(
state.devices.camera.as_deref(), state.devices.camera.as_deref(),
&catalog.cameras, &fresh_catalog.cameras,
), ),
retained_stage_selection( retained_stage_selection(
state.devices.microphone.as_deref(), state.devices.microphone.as_deref(),
&catalog.microphones, &fresh_catalog.microphones,
), ),
retained_stage_selection( retained_stage_selection(
state.devices.speaker.as_deref(), state.devices.speaker.as_deref(),
&catalog.speakers, &fresh_catalog.speakers,
), ),
retained_input_selection( retained_input_selection(
state.devices.keyboard.as_deref(), state.devices.keyboard.as_deref(),
&catalog.keyboards, &fresh_catalog.keyboards,
),
retained_input_selection(
state.devices.mouse.as_deref(),
&fresh_catalog.mice,
), ),
retained_input_selection(state.devices.mouse.as_deref(), &catalog.mice),
) )
}; };
{ {
let mut state = state.borrow_mut(); let mut state = state.borrow_mut();
state.select_camera(selected_camera); state.select_camera(selected_camera);
sync_camera_quality_selection(
&camera_quality_combo,
&mut state,
&fresh_catalog,
);
state.select_microphone(selected_microphone); state.select_microphone(selected_microphone);
state.select_speaker(selected_speaker); state.select_speaker(selected_speaker);
state.select_keyboard(selected_keyboard); state.select_keyboard(selected_keyboard);
state.select_mouse(selected_mouse); state.select_mouse(selected_mouse);
} }
*catalog_state.borrow_mut() = fresh_catalog.clone();
let state_snapshot = state.borrow().clone(); let state_snapshot = state.borrow().clone();
sync_stage_device_combo( sync_stage_device_combo(
&camera_combo, &camera_combo,
&catalog.cameras, &fresh_catalog.cameras,
state_snapshot.devices.camera.as_deref(), state_snapshot.devices.camera.as_deref(),
); );
sync_stage_device_combo( sync_stage_device_combo(
&microphone_combo, &microphone_combo,
&catalog.microphones, &fresh_catalog.microphones,
state_snapshot.devices.microphone.as_deref(), state_snapshot.devices.microphone.as_deref(),
); );
sync_stage_device_combo( sync_stage_device_combo(
&speaker_combo, &speaker_combo,
&catalog.speakers, &fresh_catalog.speakers,
state_snapshot.devices.speaker.as_deref(), state_snapshot.devices.speaker.as_deref(),
); );
sync_input_device_combo( sync_input_device_combo(
&keyboard_combo, &keyboard_combo,
&catalog.keyboards, &fresh_catalog.keyboards,
state_snapshot.devices.keyboard.as_deref(), state_snapshot.devices.keyboard.as_deref(),
"all keyboards", "all keyboards",
); );
sync_input_device_combo( sync_input_device_combo(
&mouse_combo, &mouse_combo,
&catalog.mice, &fresh_catalog.mice,
state_snapshot.devices.mouse.as_deref(), state_snapshot.devices.mouse.as_deref(),
"all mice", "all mice",
); );
@ -1341,6 +1425,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
widgets_handle widgets_handle
.status_label .status_label
.set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}")); .set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}"));
} else if let Err(err) =
tests.borrow_mut().set_camera_quality(state_snapshot.camera_quality)
{
widgets_handle.status_label.set_text(&format!(
"Device refresh succeeded, but the webcam quality test could not switch cleanly: {err}"
));
} else { } else {
let message = if usb_audio_kernel_support_missing() { let message = if usb_audio_kernel_support_missing() {
"Device staging refreshed. USB audio devices may still stay invisible until the host boots a kernel with snd_usb_audio available; reconnect the relay if you want the live session to use a new webcam, mic, or speaker." "Device staging refreshed. USB audio devices may still stay invisible until the host boots a kernel with snd_usb_audio available; reconnect the relay if you want the live session to use a new webcam, mic, or speaker."
@ -1365,6 +1455,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let widgets = widgets.clone(); let widgets = widgets.clone();
let server_entry = server_entry.clone(); let server_entry = server_entry.clone();
let camera_combo = camera_combo.clone(); let camera_combo = camera_combo.clone();
let camera_quality_combo = camera_quality_combo.clone();
let microphone_combo = microphone_combo.clone(); let microphone_combo = microphone_combo.clone();
let speaker_combo = speaker_combo.clone(); let speaker_combo = speaker_combo.clone();
let input_control_path = Rc::clone(&input_control_path); let input_control_path = Rc::clone(&input_control_path);
@ -1433,6 +1524,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
{ {
let mut state = state.borrow_mut(); let mut state = state.borrow_mut();
state.select_camera(selected_combo_value(&camera_combo)); state.select_camera(selected_combo_value(&camera_combo));
state.select_camera_quality(selected_camera_quality(&camera_quality_combo));
state.select_microphone(selected_combo_value(&microphone_combo)); state.select_microphone(selected_combo_value(&microphone_combo));
state.select_speaker(selected_combo_value(&speaker_combo)); state.select_speaker(selected_combo_value(&speaker_combo));
state.select_keyboard(selected_combo_value(&keyboard_combo)); state.select_keyboard(selected_combo_value(&keyboard_combo));
@ -1729,13 +1821,16 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let widgets = widgets.clone(); let widgets = widgets.clone();
let tests = Rc::clone(&tests); let tests = Rc::clone(&tests);
let camera_combo = camera_combo.clone(); let camera_combo = camera_combo.clone();
let camera_quality_combo = camera_quality_combo.clone();
let camera_test_button = widgets.camera_test_button.clone(); let camera_test_button = widgets.camera_test_button.clone();
let widgets_handle = widgets.clone(); let widgets_handle = widgets.clone();
camera_test_button.connect_clicked(move |_| { camera_test_button.connect_clicked(move |_| {
let selected = selected_combo_value(&camera_combo); let selected = selected_combo_value(&camera_combo);
let quality = selected_camera_quality(&camera_quality_combo);
let result = { let result = {
let mut tests = tests.borrow_mut(); let mut tests = tests.borrow_mut();
let _ = tests.set_camera_selection(selected.as_deref()); let _ = tests.set_camera_selection(selected.as_deref());
let _ = tests.set_camera_quality(quality);
tests.toggle_camera() tests.toggle_camera()
}; };
update_test_action_result( update_test_action_result(

View File

@ -4,7 +4,7 @@ use evdev::Device;
use gtk::{pango, prelude::*}; use gtk::{pango, prelude::*};
use super::{ use super::{
devices::DeviceCatalog, devices::{CameraMode, DeviceCatalog},
diagnostics::DiagnosticsLog, diagnostics::DiagnosticsLog,
preview::{LauncherPreview, PreviewBinding, PreviewSurface}, preview::{LauncherPreview, PreviewBinding, PreviewSurface},
state::{ state::{
@ -67,6 +67,7 @@ pub struct LauncherWidgets {
pub server_entry: gtk::Entry, pub server_entry: gtk::Entry,
pub start_button: gtk::Button, pub start_button: gtk::Button,
pub camera_combo: gtk::ComboBoxText, pub camera_combo: gtk::ComboBoxText,
pub camera_quality_combo: gtk::ComboBoxText,
pub microphone_combo: gtk::ComboBoxText, pub microphone_combo: gtk::ComboBoxText,
pub speaker_combo: gtk::ComboBoxText, pub speaker_combo: gtk::ComboBoxText,
pub keyboard_combo: gtk::ComboBoxText, pub keyboard_combo: gtk::ComboBoxText,
@ -108,6 +109,7 @@ pub struct LauncherView {
pub window: gtk::ApplicationWindow, pub window: gtk::ApplicationWindow,
pub server_entry: gtk::Entry, pub server_entry: gtk::Entry,
pub camera_combo: gtk::ComboBoxText, pub camera_combo: gtk::ComboBoxText,
pub camera_quality_combo: gtk::ComboBoxText,
pub microphone_combo: gtk::ComboBoxText, pub microphone_combo: gtk::ComboBoxText,
pub speaker_combo: gtk::ComboBoxText, pub speaker_combo: gtk::ComboBoxText,
pub keyboard_combo: gtk::ComboBoxText, pub keyboard_combo: gtk::ComboBoxText,
@ -123,12 +125,12 @@ pub struct LauncherView {
pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher"; 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 = 1360; const LAUNCHER_DEFAULT_WIDTH: i32 = 1360;
const LAUNCHER_DEFAULT_HEIGHT: i32 = 820; const LAUNCHER_DEFAULT_HEIGHT: i32 = 940;
const OPERATIONS_RAIL_WIDTH: i32 = 288; const OPERATIONS_RAIL_WIDTH: i32 = 288;
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158; const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158;
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280; const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
const EYE_PREVIEW_MIN_HEIGHT: i32 = 258; const EYE_PREVIEW_MIN_HEIGHT: i32 = 320;
const EYE_PREVIEW_MIN_WIDTH: i32 = 460; const EYE_PREVIEW_MIN_WIDTH: i32 = 568;
const SIDE_LOG_MIN_HEIGHT: i32 = 124; const SIDE_LOG_MIN_HEIGHT: i32 = 124;
pub fn build_launcher_view( pub fn build_launcher_view(
@ -244,6 +246,16 @@ pub fn build_launcher_view(
&catalog.cameras, &catalog.cameras,
state.devices.camera.as_deref(), state.devices.camera.as_deref(),
); );
let camera_quality_combo = gtk::ComboBoxText::new();
sync_camera_quality_combo(
&camera_quality_combo,
&state.camera_quality_options(catalog),
state.selected_camera_quality(catalog),
);
camera_quality_combo.set_size_request(88, -1);
camera_quality_combo.set_tooltip_text(Some(
"Choose the webcam quality Lesavka should capture and send to the relay host.",
));
let camera_test_button = gtk::Button::with_label("Start Preview"); let camera_test_button = gtk::Button::with_label("Start Preview");
stabilize_button(&camera_test_button, 118); stabilize_button(&camera_test_button, 118);
camera_test_button.set_tooltip_text(Some( camera_test_button.set_tooltip_text(Some(
@ -292,14 +304,82 @@ pub fn build_launcher_view(
media_grid.set_row_spacing(10); media_grid.set_row_spacing(10);
media_grid.set_column_spacing(8); media_grid.set_column_spacing(8);
media_group.append(&media_grid); media_group.append(&media_grid);
let camera_channel_toggle = gtk::CheckButton::with_label("Camera");
camera_channel_toggle.set_active(state.channels.camera);
camera_channel_toggle.set_tooltip_text(Some(
"Include the local webcam uplink in the next relay session.",
));
let audio_channel_toggle = gtk::CheckButton::with_label("Speaker");
audio_channel_toggle.set_active(state.channels.audio);
audio_channel_toggle.set_tooltip_text(Some(
"Play remote audio on this client during the next relay session.",
));
let microphone_channel_toggle = gtk::CheckButton::with_label("Mic");
microphone_channel_toggle.set_active(state.channels.microphone);
microphone_channel_toggle.set_tooltip_text(Some(
"Include the local microphone uplink in the next relay session.",
));
let audio_gain_adjustment = gtk::Adjustment::new(
state.audio_gain_percent as f64,
0.0,
super::state::MAX_AUDIO_GAIN_PERCENT as f64,
25.0,
100.0,
0.0,
);
let audio_gain_scale =
gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment));
audio_gain_scale.set_draw_value(false);
audio_gain_scale.set_hexpand(false);
audio_gain_scale.set_size_request(96, -1);
audio_gain_scale.set_tooltip_text(Some(
"Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.",
));
let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label()));
audio_gain_value.set_visible(false);
let mic_gain_adjustment = gtk::Adjustment::new(
state.mic_gain_percent as f64,
0.0,
super::state::MAX_MIC_GAIN_PERCENT as f64,
25.0,
100.0,
0.0,
);
let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment));
mic_gain_scale.set_draw_value(false);
mic_gain_scale.set_hexpand(false);
mic_gain_scale.set_size_request(96, -1);
mic_gain_scale.set_tooltip_text(Some(
"Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.",
));
let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label()));
mic_gain_value.set_visible(false);
camera_combo.set_size_request(0, -1); camera_combo.set_size_request(0, -1);
let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);
camera_combo.set_hexpand(true);
camera_quality_combo.set_hexpand(false);
camera_selectors.append(&camera_combo);
camera_selectors.append(&camera_quality_combo);
speaker_combo.set_size_request(0, -1); speaker_combo.set_size_request(0, -1);
attach_device_row(&media_grid, 0, "Camera", &camera_combo, &camera_test_button); attach_device_control_row(
attach_device_row( &media_grid,
0,
&camera_channel_toggle,
&camera_selectors,
&camera_test_button,
);
let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);
speaker_combo.set_hexpand(true);
speaker_selectors.append(&speaker_combo);
speaker_selectors.append(&audio_gain_scale);
attach_device_control_row(
&media_grid, &media_grid,
1, 1,
"Speaker", &audio_channel_toggle,
&speaker_combo, &speaker_selectors,
&speaker_test_button, &speaker_test_button,
); );
@ -315,11 +395,15 @@ pub fn build_launcher_view(
"Monitor the selected microphone through the selected speaker until you stop the test.", "Monitor the selected microphone through the selected speaker until you stop the test.",
)); ));
microphone_combo.set_size_request(0, -1); microphone_combo.set_size_request(0, -1);
attach_device_row( let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);
microphone_combo.set_hexpand(true);
microphone_selectors.append(&microphone_combo);
microphone_selectors.append(&mic_gain_scale);
attach_device_control_row(
&media_grid, &media_grid,
2, 2,
"Microphone", &microphone_channel_toggle,
&microphone_combo, &microphone_selectors,
&microphone_test_button, &microphone_test_button,
); );
@ -464,37 +548,6 @@ pub fn build_launcher_view(
live_actions_row.append(&usb_recover_button); live_actions_row.append(&usb_recover_button);
connection_body.append(&live_actions_row); connection_body.append(&live_actions_row);
let channel_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
channel_row.set_hexpand(true);
let channel_heading = gtk::Label::new(Some("Streams"));
channel_heading.add_css_class("subgroup-title");
channel_heading.set_halign(gtk::Align::Start);
channel_heading.set_width_chars(10);
channel_row.append(&channel_heading);
let channel_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
channel_buttons.set_hexpand(true);
channel_buttons.set_homogeneous(true);
let camera_channel_toggle = gtk::CheckButton::with_label("Webcam");
camera_channel_toggle.set_active(state.channels.camera);
camera_channel_toggle.set_tooltip_text(Some(
"Include the local webcam uplink in the next relay session.",
));
let microphone_channel_toggle = gtk::CheckButton::with_label("Mic");
microphone_channel_toggle.set_active(state.channels.microphone);
microphone_channel_toggle.set_tooltip_text(Some(
"Include the local microphone uplink in the next relay session.",
));
let audio_channel_toggle = gtk::CheckButton::with_label("Audio");
audio_channel_toggle.set_active(state.channels.audio);
audio_channel_toggle.set_tooltip_text(Some(
"Play remote audio on this client during the next relay session.",
));
channel_buttons.append(&camera_channel_toggle);
channel_buttons.append(&microphone_channel_toggle);
channel_buttons.append(&audio_channel_toggle);
channel_row.append(&channel_buttons);
connection_body.append(&channel_row);
connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal)); connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
let power_heading = gtk::Label::new(Some("GPIO Power")); let power_heading = gtk::Label::new(Some("GPIO Power"));
power_heading.add_css_class("subgroup-title"); power_heading.add_css_class("subgroup-title");
@ -529,66 +582,7 @@ pub fn build_launcher_view(
power_buttons.append(&power_auto_button); power_buttons.append(&power_auto_button);
power_buttons.append(&power_off_button); power_buttons.append(&power_off_button);
power_row.append(&power_buttons); power_row.append(&power_buttons);
let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
audio_gain_row.set_size_request(220, -1);
audio_gain_row.set_hexpand(true);
let audio_gain_label = gtk::Label::new(Some("Audio"));
audio_gain_label.add_css_class("dim-label");
audio_gain_label.set_halign(gtk::Align::Start);
audio_gain_label.set_width_chars(10);
let audio_gain_adjustment = gtk::Adjustment::new(
state.audio_gain_percent as f64,
0.0,
super::state::MAX_AUDIO_GAIN_PERCENT as f64,
25.0,
100.0,
0.0,
);
let audio_gain_scale =
gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment));
audio_gain_scale.set_draw_value(false);
audio_gain_scale.set_hexpand(true);
audio_gain_scale.set_tooltip_text(Some(
"Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.",
));
let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label()));
audio_gain_value.add_css_class("dim-label");
audio_gain_value.set_width_chars(5);
audio_gain_value.set_xalign(1.0);
audio_gain_row.append(&audio_gain_label);
audio_gain_row.append(&audio_gain_scale);
audio_gain_row.append(&audio_gain_value);
let mic_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
mic_gain_row.set_size_request(220, -1);
mic_gain_row.set_hexpand(true);
let mic_gain_label = gtk::Label::new(Some("Mic Gain"));
mic_gain_label.add_css_class("dim-label");
mic_gain_label.set_halign(gtk::Align::Start);
mic_gain_label.set_width_chars(10);
let mic_gain_adjustment = gtk::Adjustment::new(
state.mic_gain_percent as f64,
0.0,
super::state::MAX_MIC_GAIN_PERCENT as f64,
25.0,
100.0,
0.0,
);
let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment));
mic_gain_scale.set_draw_value(false);
mic_gain_scale.set_hexpand(true);
mic_gain_scale.set_tooltip_text(Some(
"Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.",
));
let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label()));
mic_gain_value.add_css_class("dim-label");
mic_gain_value.set_width_chars(5);
mic_gain_value.set_xalign(1.0);
mic_gain_row.append(&mic_gain_label);
mic_gain_row.append(&mic_gain_scale);
mic_gain_row.append(&mic_gain_value);
power_shell.append(&power_row); power_shell.append(&power_row);
power_shell.append(&audio_gain_row);
power_shell.append(&mic_gain_row);
connection_body.append(&power_shell); connection_body.append(&power_shell);
let routing_heading = gtk::Label::new(Some("Inputs")); let routing_heading = gtk::Label::new(Some("Inputs"));
routing_heading.add_css_class("subgroup-title"); routing_heading.add_css_class("subgroup-title");
@ -830,6 +824,7 @@ pub fn build_launcher_view(
server_entry: server_entry.clone(), server_entry: server_entry.clone(),
start_button: start_button.clone(), start_button: start_button.clone(),
camera_combo: camera_combo.clone(), camera_combo: camera_combo.clone(),
camera_quality_combo: camera_quality_combo.clone(),
microphone_combo: microphone_combo.clone(), microphone_combo: microphone_combo.clone(),
speaker_combo: speaker_combo.clone(), speaker_combo: speaker_combo.clone(),
keyboard_combo: keyboard_combo.clone(), keyboard_combo: keyboard_combo.clone(),
@ -872,6 +867,7 @@ pub fn build_launcher_view(
window, window,
server_entry, server_entry,
camera_combo, camera_combo,
camera_quality_combo,
microphone_combo, microphone_combo,
speaker_combo, speaker_combo,
keyboard_combo, keyboard_combo,
@ -1207,6 +1203,29 @@ pub fn sync_stage_device_combo(
set_stage_combo_active_text(combo, selected); set_stage_combo_active_text(combo, selected);
} }
pub fn sync_camera_quality_combo(
combo: &gtk::ComboBoxText,
options: &[CameraMode],
selected: Option<CameraMode>,
) {
combo.remove_all();
if options.is_empty() {
combo.append(Some("none"), "Quality");
combo.set_active_id(Some("none"));
combo.set_sensitive(false);
return;
}
for option in options {
combo.append(Some(&option.id()), &option.short_label());
}
let active = selected
.filter(|mode| options.contains(mode))
.or_else(|| options.first().copied())
.map(CameraMode::id);
combo.set_active_id(active.as_deref());
}
pub fn sync_input_device_combo( pub fn sync_input_device_combo(
combo: &gtk::ComboBoxText, combo: &gtk::ComboBoxText,
values: &[String], values: &[String],
@ -1221,18 +1240,17 @@ pub fn sync_input_device_combo(
super::ui_runtime::set_combo_active_text(combo, selected); super::ui_runtime::set_combo_active_text(combo, selected);
} }
fn attach_device_row( fn attach_device_control_row(
grid: &gtk::Grid, grid: &gtk::Grid,
row: i32, row: i32,
label: &str, stream_toggle: &gtk::CheckButton,
combo: &gtk::ComboBoxText, selector: &impl IsA<gtk::Widget>,
test_button: &gtk::Button, test_button: &gtk::Button,
) { ) {
let label_widget = gtk::Label::new(Some(label)); stream_toggle.set_halign(gtk::Align::Start);
label_widget.set_halign(gtk::Align::Start); selector.set_hexpand(true);
combo.set_hexpand(true); grid.attach(stream_toggle, 0, row, 1, 1);
grid.attach(&label_widget, 0, row, 1, 1); grid.attach(selector, 1, row, 1, 1);
grid.attach(combo, 1, row, 1, 1);
grid.attach(test_button, 2, row, 1, 1); grid.attach(test_button, 2, row, 1, 1);
} }

View File

@ -130,12 +130,21 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets widgets
.camera_combo .camera_combo
.set_sensitive(!relay_live && state.channels.camera); .set_sensitive(!relay_live && state.channels.camera);
widgets.camera_quality_combo.set_sensitive(
!relay_live
&& state.channels.camera
&& state.devices.camera.is_some()
&& state.camera_quality.is_some(),
);
widgets widgets
.microphone_combo .microphone_combo
.set_sensitive(!relay_live && state.channels.microphone); .set_sensitive(!relay_live && state.channels.microphone);
widgets widgets
.speaker_combo .speaker_combo
.set_sensitive(!relay_live && state.channels.audio); .set_sensitive(!relay_live && state.channels.audio);
widgets
.audio_gain_scale
.set_sensitive(!relay_live && state.channels.audio);
widgets.keyboard_combo.set_sensitive(!relay_live); widgets.keyboard_combo.set_sensitive(!relay_live);
widgets.mouse_combo.set_sensitive(!relay_live); widgets.mouse_combo.set_sensitive(!relay_live);
widgets.camera_channel_toggle.set_sensitive(!relay_live); widgets.camera_channel_toggle.set_sensitive(!relay_live);
@ -147,6 +156,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets widgets
.microphone_test_button .microphone_test_button
.set_sensitive(!relay_live && state.channels.microphone); .set_sensitive(!relay_live && state.channels.microphone);
widgets
.mic_gain_scale
.set_sensitive(!relay_live && state.channels.microphone);
widgets widgets
.speaker_test_button .speaker_test_button
.set_sensitive(!relay_live && state.channels.audio); .set_sensitive(!relay_live && state.channels.audio);

View File

@ -41,5 +41,8 @@ pub fn pick_h264_decoder() -> String {
} }
fn buildable_decoder(name: &str) -> bool { fn buildable_decoder(name: &str) -> bool {
if gst::init().is_err() {
return false;
}
gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok() gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok()
} }

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.11.48" version = "0.12.0"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -3,7 +3,7 @@
"client/src/app.rs": { "client/src/app.rs": {
"clippy_warnings": 40, "clippy_warnings": 40,
"doc_debt": 13, "doc_debt": 13,
"loc": 808 "loc": 816
}, },
"client/src/app_support.rs": { "client/src/app_support.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -23,7 +23,7 @@
"client/src/input/camera.rs": { "client/src/input/camera.rs": {
"clippy_warnings": 14, "clippy_warnings": 14,
"doc_debt": 10, "doc_debt": 10,
"loc": 719 "loc": 717
}, },
"client/src/input/inputs.rs": { "client/src/input/inputs.rs": {
"clippy_warnings": 40, "clippy_warnings": 40,
@ -61,14 +61,14 @@
"loc": 178 "loc": 178
}, },
"client/src/launcher/device_test.rs": { "client/src/launcher/device_test.rs": {
"clippy_warnings": 67, "clippy_warnings": 75,
"doc_debt": 40, "doc_debt": 43,
"loc": 1148 "loc": 1219
}, },
"client/src/launcher/devices.rs": { "client/src/launcher/devices.rs": {
"clippy_warnings": 6, "clippy_warnings": 25,
"doc_debt": 14, "doc_debt": 19,
"loc": 400 "loc": 564
}, },
"client/src/launcher/diagnostics.rs": { "client/src/launcher/diagnostics.rs": {
"clippy_warnings": 92, "clippy_warnings": 92,
@ -78,7 +78,7 @@
"client/src/launcher/mod.rs": { "client/src/launcher/mod.rs": {
"clippy_warnings": 8, "clippy_warnings": 8,
"doc_debt": 8, "doc_debt": 8,
"loc": 480 "loc": 497
}, },
"client/src/launcher/power.rs": { "client/src/launcher/power.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -91,24 +91,24 @@
"loc": 2216 "loc": 2216
}, },
"client/src/launcher/state.rs": { "client/src/launcher/state.rs": {
"clippy_warnings": 168, "clippy_warnings": 172,
"doc_debt": 57, "doc_debt": 58,
"loc": 1478 "loc": 1562
}, },
"client/src/launcher/ui.rs": { "client/src/launcher/ui.rs": {
"clippy_warnings": 68, "clippy_warnings": 70,
"doc_debt": 23, "doc_debt": 23,
"loc": 2524 "loc": 2619
}, },
"client/src/launcher/ui_components.rs": { "client/src/launcher/ui_components.rs": {
"clippy_warnings": 22, "clippy_warnings": 22,
"doc_debt": 17, "doc_debt": 18,
"loc": 1497 "loc": 1515
}, },
"client/src/launcher/ui_runtime.rs": { "client/src/launcher/ui_runtime.rs": {
"clippy_warnings": 74, "clippy_warnings": 74,
"doc_debt": 44, "doc_debt": 44,
"loc": 1790 "loc": 1802
}, },
"client/src/layout.rs": { "client/src/layout.rs": {
"clippy_warnings": 6, "clippy_warnings": 6,
@ -157,8 +157,8 @@
}, },
"client/src/video_support.rs": { "client/src/video_support.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 1, "doc_debt": 2,
"loc": 45 "loc": 48
}, },
"common/src/bin/cli.rs": { "common/src/bin/cli.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -196,9 +196,9 @@
"loc": 105 "loc": 105
}, },
"server/src/audio.rs": { "server/src/audio.rs": {
"clippy_warnings": 43, "clippy_warnings": 47,
"doc_debt": 13, "doc_debt": 15,
"loc": 680 "loc": 737
}, },
"server/src/bin/lesavka-uvc.real.inc": { "server/src/bin/lesavka-uvc.real.inc": {
"clippy_warnings": 33, "clippy_warnings": 33,
@ -211,14 +211,14 @@
"loc": 712 "loc": 712
}, },
"server/src/camera.rs": { "server/src/camera.rs": {
"clippy_warnings": 12, "clippy_warnings": 18,
"doc_debt": 11, "doc_debt": 19,
"loc": 392 "loc": 623
}, },
"server/src/camera_runtime.rs": { "server/src/camera_runtime.rs": {
"clippy_warnings": 10, "clippy_warnings": 10,
"doc_debt": 5, "doc_debt": 5,
"loc": 200 "loc": 204
}, },
"server/src/capture_power.rs": { "server/src/capture_power.rs": {
"clippy_warnings": 12, "clippy_warnings": 12,
@ -276,9 +276,9 @@
"loc": 844 "loc": 844
}, },
"server/src/video_sinks.rs": { "server/src/video_sinks.rs": {
"clippy_warnings": 78, "clippy_warnings": 80,
"doc_debt": 11, "doc_debt": 15,
"loc": 574 "loc": 679
}, },
"server/src/video_support.rs": { "server/src/video_support.rs": {
"clippy_warnings": 8, "clippy_warnings": 8,

View File

@ -2,14 +2,14 @@
"files": { "files": {
"client/src/app.rs": { "client/src/app.rs": {
"line_percent": 97.4, "line_percent": 97.4,
"loc": 808 "loc": 816
}, },
"client/src/app_support.rs": { "client/src/app_support.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 132 "loc": 132
}, },
"client/src/bin/lesavka-relayctl.rs": { "client/src/bin/lesavka-relayctl.rs": {
"line_percent": 0.0, "line_percent": 25.24,
"loc": 140 "loc": 140
}, },
"client/src/handshake.rs": { "client/src/handshake.rs": {
@ -17,8 +17,8 @@
"loc": 381 "loc": 381
}, },
"client/src/input/camera.rs": { "client/src/input/camera.rs": {
"line_percent": 95.24, "line_percent": 96.51,
"loc": 719 "loc": 717
}, },
"client/src/input/inputs.rs": { "client/src/input/inputs.rs": {
"line_percent": 96.39, "line_percent": 96.39,
@ -45,24 +45,24 @@
"loc": 178 "loc": 178
}, },
"client/src/launcher/devices.rs": { "client/src/launcher/devices.rs": {
"line_percent": 95.93, "line_percent": 96.0,
"loc": 400 "loc": 564
}, },
"client/src/launcher/diagnostics.rs": { "client/src/launcher/diagnostics.rs": {
"line_percent": 84.3, "line_percent": 84.3,
"loc": 1021 "loc": 1021
}, },
"client/src/launcher/mod.rs": { "client/src/launcher/mod.rs": {
"line_percent": 84.2, "line_percent": 84.85,
"loc": 480 "loc": 497
}, },
"client/src/launcher/state.rs": { "client/src/launcher/state.rs": {
"line_percent": 85.06, "line_percent": 86.04,
"loc": 1478 "loc": 1562
}, },
"client/src/launcher/ui.rs": { "client/src/launcher/ui.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 2524 "loc": 2619
}, },
"client/src/layout.rs": { "client/src/layout.rs": {
"line_percent": 97.73, "line_percent": 97.73,
@ -73,7 +73,7 @@
"loc": 100 "loc": 100
}, },
"client/src/output/audio.rs": { "client/src/output/audio.rs": {
"line_percent": 76.92, "line_percent": 89.42,
"loc": 371 "loc": 371
}, },
"client/src/output/display.rs": { "client/src/output/display.rs": {
@ -93,8 +93,8 @@
"loc": 82 "loc": 82
}, },
"client/src/video_support.rs": { "client/src/video_support.rs": {
"line_percent": 0.0, "line_percent": 83.87,
"loc": 45 "loc": 48
}, },
"common/src/bin/cli.rs": { "common/src/bin/cli.rs": {
"line_percent": 100.0, "line_percent": 100.0,
@ -125,20 +125,20 @@
"loc": 105 "loc": 105
}, },
"server/src/audio.rs": { "server/src/audio.rs": {
"line_percent": 98.97, "line_percent": 96.88,
"loc": 680 "loc": 737
}, },
"server/src/bin/lesavka-uvc.rs": { "server/src/bin/lesavka-uvc.rs": {
"line_percent": 95.92, "line_percent": 95.92,
"loc": 712 "loc": 712
}, },
"server/src/camera.rs": { "server/src/camera.rs": {
"line_percent": 99.1, "line_percent": 96.6,
"loc": 392 "loc": 623
}, },
"server/src/camera_runtime.rs": { "server/src/camera_runtime.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 200 "loc": 204
}, },
"server/src/capture_power.rs": { "server/src/capture_power.rs": {
"line_percent": 100.0, "line_percent": 100.0,
@ -173,8 +173,8 @@
"loc": 844 "loc": 844
}, },
"server/src/video_sinks.rs": { "server/src/video_sinks.rs": {
"line_percent": 100.0, "line_percent": 95.8,
"loc": 574 "loc": 679
}, },
"server/src/video_support.rs": { "server/src/video_support.rs": {
"line_percent": 97.62, "line_percent": 97.62,

View File

@ -27,8 +27,14 @@ build_url=${BUILD_URL:-}
start_seconds=$(date +%s) start_seconds=$(date +%s)
status=0 status=0
set +e set +e
cargo test --workspace --all-targets --color never 2>&1 | tee "${TEST_LOG}" cargo build --workspace --bins --color never 2>&1 | tee "${TEST_LOG}"
status=${PIPESTATUS[0]} build_status=${PIPESTATUS[0]}
if [[ "${build_status}" -eq 0 ]]; then
cargo test --workspace --all-targets --color never 2>&1 | tee -a "${TEST_LOG}"
status=${PIPESTATUS[0]}
else
status=${build_status}
fi
set -e set -e
end_seconds=$(date +%s) end_seconds=$(date +%s)
duration_seconds=$((end_seconds - start_seconds)) duration_seconds=$((end_seconds - start_seconds))

View File

@ -423,7 +423,15 @@ fi
echo "# Edit only for local hardware overrides; rerunning the installer refreshes defaults." echo "# Edit only for local hardware overrides; rerunning the installer refreshes defaults."
if [[ -n $HDMI_CONNECTOR ]]; then if [[ -n $HDMI_CONNECTOR ]]; then
printf 'LESAVKA_HDMI_CONNECTOR=%s\n' "$HDMI_CONNECTOR" printf 'LESAVKA_HDMI_CONNECTOR=%s\n' "$HDMI_CONNECTOR"
printf 'LESAVKA_CAM_OUTPUT=%s\n' "${LESAVKA_CAM_OUTPUT:-hdmi}"
fi fi
printf 'LESAVKA_CAM_WIDTH=%s\n' "${LESAVKA_CAM_WIDTH:-1920}"
printf 'LESAVKA_CAM_HEIGHT=%s\n' "${LESAVKA_CAM_HEIGHT:-1080}"
printf 'LESAVKA_CAM_FPS=%s\n' "${LESAVKA_CAM_FPS:-30}"
printf 'LESAVKA_HDMI_WIDTH=%s\n' "${LESAVKA_HDMI_WIDTH:-1920}"
printf 'LESAVKA_HDMI_HEIGHT=%s\n' "${LESAVKA_HDMI_HEIGHT:-1080}"
printf 'LESAVKA_HDMI_SINK=%s\n' "${LESAVKA_HDMI_SINK:-fbdevsink}"
printf 'LESAVKA_HDMI_FBDEV=%s\n' "${LESAVKA_HDMI_FBDEV:-/dev/fb0}"
printf 'LESAVKA_HDMI_DRIVER=%s\n' "${LESAVKA_HDMI_DRIVER:-vc4}" printf 'LESAVKA_HDMI_DRIVER=%s\n' "${LESAVKA_HDMI_DRIVER:-vc4}"
printf 'LESAVKA_UAC_DEV=%s\n' "${LESAVKA_UAC_DEV:-hw:UAC2Gadget,0}" printf 'LESAVKA_UAC_DEV=%s\n' "${LESAVKA_UAC_DEV:-hw:UAC2Gadget,0}"
printf 'LESAVKA_ALSA_DEV=%s\n' "${LESAVKA_ALSA_DEV:-hw:UAC2Gadget,0}" printf 'LESAVKA_ALSA_DEV=%s\n' "${LESAVKA_ALSA_DEV:-hw:UAC2Gadget,0}"

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.11.48" version = "0.12.0"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -44,6 +44,57 @@ impl Drop for AudioStream {
} }
} }
pub(crate) fn start_pipeline_or_reset(
pipeline: &gst::Pipeline,
context: &'static str,
) -> anyhow::Result<()> {
match pipeline.set_state(gst::State::Playing) {
Ok(_) => Ok(()),
Err(error) => {
let _ = pipeline.set_state(gst::State::Null);
Err(error).context(context)
}
}
}
#[cfg(not(coverage))]
fn spawn_pipeline_bus_logger(bus: gst::Bus, label: &'static str, playing_message: &'static str) {
std::thread::spawn(move || {
for msg in bus.iter_timed(gst::ClockTime::NONE) {
match msg.view() {
Error(e) => error!(
"💥 {label} pipeline from {:?}: {} ({})",
msg.src().map(gst::prelude::GstObjectExt::path_string),
e.error(),
e.debug().unwrap_or_default()
),
Warning(w) => warn!(
"⚠️ {label} pipeline from {:?}: {} ({})",
msg.src().map(gst::prelude::GstObjectExt::path_string),
w.error(),
w.debug().unwrap_or_default()
),
StateChanged(s)
if s.current() == gst::State::Playing
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
{
debug!("{playing_message}")
}
Element(e) => {
if let Some(structure) = e.structure() {
if structure.name() == "level" {
info!("🔊 source audio level {}", structure);
} else {
debug!("🔎 audio element message: {}", structure);
}
}
}
_ => {}
}
}
});
}
/*───────────────────────────────────────────────────────────────────────────*/ /*───────────────────────────────────────────────────────────────────────────*/
/* ear() - capture from ALSA (“speaker”) and push AAC AUs via gRPC */ /* ear() - capture from ALSA (“speaker”) and push AAC AUs via gRPC */
/*───────────────────────────────────────────────────────────────────────────*/ /*───────────────────────────────────────────────────────────────────────────*/
@ -104,35 +155,6 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
let source_health = Arc::new(AudioSourceHealth::new()); let source_health = Arc::new(AudioSourceHealth::new());
let bus = pipeline.bus().expect("bus"); let bus = pipeline.bus().expect("bus");
std::thread::spawn(move || {
for msg in bus.iter_timed(gst::ClockTime::NONE) {
match msg.view() {
Error(e) => error!(
"💥 audio pipeline: {} ({})",
e.error(),
e.debug().unwrap_or_default()
),
Warning(w) => warn!(
"⚠️ audio pipeline: {} ({})",
w.error(),
w.debug().unwrap_or_default()
),
StateChanged(s) if s.current() == gst::State::Playing => {
debug!("🎶 audio pipeline PLAYING")
}
Element(e) => {
if let Some(structure) = e.structure() {
if structure.name() == "level" {
info!("🔊 source audio level {}", structure);
} else {
debug!("🔎 audio element message: {}", structure);
}
}
}
_ => {}
}
}
});
/*──────────── callbacks ────────────*/ /*──────────── callbacks ────────────*/
sink.set_callbacks( sink.set_callbacks(
@ -183,9 +205,8 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
.build(), .build(),
); );
pipeline start_pipeline_or_reset(&pipeline, "starting audio pipeline")?;
.set_state(gst::State::Playing) spawn_pipeline_bus_logger(bus, "audio", "🎶 audio pipeline PLAYING");
.context("starting audio pipeline")?;
spawn_audio_source_watchdog( spawn_audio_source_watchdog(
pipeline.clone(), pipeline.clone(),
@ -465,6 +486,12 @@ pub struct Voice {
tap: ClipTap, tap: ClipTap,
} }
impl Drop for Voice {
fn drop(&mut self) {
let _ = self._pipe.set_state(gst::State::Null);
}
}
fn voice_input_caps() -> gst::Caps { fn voice_input_caps() -> gst::Caps {
gst::Caps::builder("audio/mpeg") gst::Caps::builder("audio/mpeg")
.field("mpegversion", 4i32) .field("mpegversion", 4i32)
@ -494,7 +521,7 @@ impl Voice {
.context("make fakesink")?; .context("make fakesink")?;
pipeline.add_many(&[appsrc.upcast_ref(), &sink])?; pipeline.add_many(&[appsrc.upcast_ref(), &sink])?;
gst::Element::link_many(&[appsrc.upcast_ref(), &sink])?; gst::Element::link_many(&[appsrc.upcast_ref(), &sink])?;
pipeline.set_state(gst::State::Playing)?; start_pipeline_or_reset(&pipeline, "starting voice pipeline")?;
Ok(Self { Ok(Self {
appsrc, appsrc,
@ -582,31 +609,6 @@ impl Voice {
}); });
let bus = pipeline.bus().context("voice pipeline bus")?; let bus = pipeline.bus().context("voice pipeline bus")?;
std::thread::spawn(move || {
for msg in bus.iter_timed(gst::ClockTime::NONE) {
match msg.view() {
Error(e) => error!(
"🎤💥 voice pipeline from {:?}: {} ({})",
msg.src().map(gst::prelude::GstObjectExt::path_string),
e.error(),
e.debug().unwrap_or_default()
),
Warning(w) => warn!(
"🎤⚠️ voice pipeline from {:?}: {} ({})",
msg.src().map(gst::prelude::GstObjectExt::path_string),
w.error(),
w.debug().unwrap_or_default()
),
StateChanged(s)
if s.current() == gst::State::Playing
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
{
debug!("🎤 voice pipeline ▶️")
}
_ => {}
}
}
});
// underrun ≠ error just show a warning // underrun ≠ error just show a warning
// let _id = alsa_sink.connect("underrun", false, |_| { // let _id = alsa_sink.connect("underrun", false, |_| {
@ -614,7 +616,8 @@ impl Voice {
// None // None
// }); // });
pipeline.set_state(gst::State::Playing)?; start_pipeline_or_reset(&pipeline, "starting voice pipeline")?;
spawn_pipeline_bus_logger(bus, "voice", "🎤 voice pipeline ▶️");
Ok(Self { Ok(Self {
appsrc, appsrc,

View File

@ -193,13 +193,24 @@ fn parse_camera_output(raw: &str) -> Option<CameraOutput> {
fn select_hdmi_config(hdmi: Option<HdmiConnector>) -> CameraConfig { fn select_hdmi_config(hdmi: Option<HdmiConnector>) -> CameraConfig {
let hw_decode = has_hw_h264_decode(); let hw_decode = has_hw_h264_decode();
let (width, height) = if hw_decode { (1920, 1080) } else { (1280, 720) }; let (default_width, default_height) = if hw_decode { (1920, 1080) } else { (1280, 720) };
let fps = 30; let width = read_u32_from_env("LESAVKA_CAM_WIDTH").unwrap_or(default_width);
let height = read_u32_from_env("LESAVKA_CAM_HEIGHT").unwrap_or(default_height);
let fps = read_u32_from_env("LESAVKA_CAM_FPS").unwrap_or(30).max(1);
#[cfg(not(coverage))] #[cfg(not(coverage))]
if !hw_decode { if !hw_decode {
warn!( if width == default_width && height == default_height {
"📷 HDMI output: hardware H264 decoder not detected; requesting 720p30 camera uplink" warn!(
); "📷 HDMI output: hardware H264 decoder not detected; requesting 720p30 camera uplink"
);
} else {
warn!(
width,
height,
fps,
"📷 HDMI output: hardware H264 decoder not detected; using configured camera uplink size"
);
}
} }
CameraConfig { CameraConfig {
output: CameraOutput::Hdmi, output: CameraOutput::Hdmi,
@ -490,6 +501,25 @@ mod tests {
}); });
} }
#[test]
#[serial]
fn hdmi_camera_profile_honors_installed_1080p_override() {
with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || {
with_var("LESAVKA_CAM_WIDTH", Some("1920"), || {
with_var("LESAVKA_CAM_HEIGHT", Some("1080"), || {
with_var("LESAVKA_CAM_FPS", Some("30"), || {
let cfg = update_camera_config();
assert_eq!(cfg.output, CameraOutput::Hdmi);
assert_eq!(cfg.codec, CameraCodec::H264);
assert_eq!(cfg.width, 1920);
assert_eq!(cfg.height, 1080);
assert_eq!(cfg.fps, 30);
});
});
});
});
}
#[test] #[test]
fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() { fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() {
assert_eq!( assert_eq!(

View File

@ -3,6 +3,8 @@ use gstreamer as gst;
use gstreamer::prelude::*; use gstreamer::prelude::*;
use gstreamer_app as gst_app; use gstreamer_app as gst_app;
use lesavka_common::lesavka::VideoPacket; use lesavka_common::lesavka::VideoPacket;
use std::fs;
use std::path::Path;
use std::sync::atomic::AtomicU64; use std::sync::atomic::AtomicU64;
use tracing::warn; use tracing::warn;
@ -423,12 +425,20 @@ fn build_hdmi_sink(_cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result<gst::Element> { fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") { if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") {
return gst::ElementFactory::make(&name) let normalized = name.trim().to_ascii_lowercase();
if normalized == "fbdev" || normalized == "fbdevsink" {
return build_fbdev_hdmi_sink();
}
let sink = gst::ElementFactory::make(&name)
.build() .build()
.context("building HDMI sink"); .context("building HDMI sink")?;
disable_sink_clock_sync(&sink);
return Ok(sink);
} }
if gst::ElementFactory::find("kmssink").is_some() { if gst::ElementFactory::find("kmssink").is_some() {
unblank_framebuffer(&hdmi_fbdev_device());
let sink = gst::ElementFactory::make("kmssink").build()?; let sink = gst::ElementFactory::make("kmssink").build()?;
if sink.has_property("driver-name", None) { if sink.has_property("driver-name", None) {
let driver = std::env::var("LESAVKA_HDMI_DRIVER").unwrap_or_else(|_| "vc4".to_string()); let driver = std::env::var("LESAVKA_HDMI_DRIVER").unwrap_or_else(|_| "vc4".to_string());
@ -448,17 +458,98 @@ fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
if sink.has_property("force-modesetting", None) { if sink.has_property("force-modesetting", None) {
sink.set_property("force-modesetting", &true); sink.set_property("force-modesetting", &true);
} }
sink.set_property("sync", &false); if sink.has_property("restore-crtc", None) {
let restore = read_bool_env("LESAVKA_HDMI_RESTORE_CRTC").unwrap_or(false);
sink.set_property("restore-crtc", &restore);
}
if sink.has_property("skip-vsync", None) {
let skip = read_bool_env("LESAVKA_HDMI_SKIP_VSYNC").unwrap_or(false);
sink.set_property("skip-vsync", &skip);
}
disable_sink_clock_sync(&sink);
return Ok(sink); return Ok(sink);
} }
let sink = gst::ElementFactory::make("autovideosink") let sink = gst::ElementFactory::make("autovideosink")
.build() .build()
.context("building HDMI sink")?; .context("building HDMI sink")?;
let _ = sink.set_property("sync", &false); disable_sink_clock_sync(&sink);
Ok(sink) Ok(sink)
} }
#[cfg(not(coverage))]
fn build_fbdev_hdmi_sink() -> anyhow::Result<gst::Element> {
let device = hdmi_fbdev_device();
unblank_framebuffer(&device);
let sink = gst::ElementFactory::make("fbdevsink")
.property("device", &device)
.build()
.context("building framebuffer HDMI sink")?;
disable_sink_clock_sync(&sink);
tracing::info!(
target: "lesavka_server::video",
%device,
"📺 HDMI sink using framebuffer scanout"
);
Ok(sink)
}
#[cfg(not(coverage))]
fn hdmi_fbdev_device() -> String {
std::env::var("LESAVKA_HDMI_FBDEV")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "/dev/fb0".to_string())
}
#[cfg(not(coverage))]
fn unblank_framebuffer(device: &str) {
let Some(name) = Path::new(device)
.file_name()
.and_then(|value| value.to_str())
else {
return;
};
if !name.starts_with("fb") {
return;
}
let blank_path = format!("/sys/class/graphics/{name}/blank");
match fs::write(&blank_path, b"0\n") {
Ok(()) => tracing::debug!(
target: "lesavka_server::video",
%device,
%blank_path,
"📺 HDMI framebuffer unblanked"
),
Err(error) => tracing::debug!(
target: "lesavka_server::video",
%device,
%blank_path,
%error,
"📺 HDMI framebuffer unblank skipped"
),
}
}
fn disable_sink_clock_sync(sink: &gst::Element) {
if sink.has_property("sync", None) {
sink.set_property("sync", &false);
}
}
fn read_bool_env(name: &str) -> Option<bool> {
let value = std::env::var(name).ok()?;
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
enum CameraSink { enum CameraSink {
Uvc(WebcamSink), Uvc(WebcamSink),
Hdmi(HdmiSink), Hdmi(HdmiSink),

View File

@ -66,6 +66,14 @@ fn main() {
.join("client/src/output/video.rs") .join("client/src/output/video.rs")
.canonicalize() .canonicalize()
.expect("canonical client output video path"); .expect("canonical client output video path");
let client_video_support = workspace_dir
.join("client/src/video_support.rs")
.canonicalize()
.expect("canonical client video_support path");
let client_relayctl = workspace_dir
.join("client/src/bin/lesavka-relayctl.rs")
.canonicalize()
.expect("canonical client relayctl bin path");
let common_cli = workspace_dir let common_cli = workspace_dir
.join("common/src/bin/cli.rs") .join("common/src/bin/cli.rs")
.canonicalize() .canonicalize()
@ -131,6 +139,14 @@ fn main() {
"cargo:rustc-env=LESAVKA_CLIENT_OUTPUT_VIDEO_SRC={}", "cargo:rustc-env=LESAVKA_CLIENT_OUTPUT_VIDEO_SRC={}",
client_output_video.display() client_output_video.display()
); );
println!(
"cargo:rustc-env=LESAVKA_CLIENT_VIDEO_SUPPORT_SRC={}",
client_video_support.display()
);
println!(
"cargo:rustc-env=LESAVKA_CLIENT_RELAYCTL_BIN_SRC={}",
client_relayctl.display()
);
println!( println!(
"cargo:rustc-env=LESAVKA_COMMON_CLI_BIN_SRC={}", "cargo:rustc-env=LESAVKA_COMMON_CLI_BIN_SRC={}",
common_cli.display() common_cli.display()

View File

@ -228,6 +228,8 @@ mod tests {
use temp_env::with_var; use temp_env::with_var;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
const APP_SRC: &str = include_str!("../../client/src/app.rs");
#[test] #[test]
#[serial] #[serial]
fn run_headless_reaches_pending_reactor_branch() { fn run_headless_reaches_pending_reactor_branch() {
@ -372,4 +374,12 @@ mod tests {
"stale mouse packets should be dropped locally" "stale mouse packets should be dropped locally"
); );
} }
#[test]
fn audio_loop_backoff_contract_protects_server_from_reconnect_storms() {
assert!(APP_SRC.contains("let mut delay = Duration::from_secs(1);"));
assert!(APP_SRC.contains("tokio::time::sleep(delay).await;"));
assert!(APP_SRC.contains("delay = app_support::next_delay(delay);"));
assert!(APP_SRC.contains("consecutive_source_failures = 0;"));
}
} }

View File

@ -30,15 +30,15 @@ fn source_index(needle: &str) -> usize {
#[test] #[test]
fn launcher_default_size_stays_inside_1080p() { fn launcher_default_size_stays_inside_1080p() {
assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360); assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360);
assert_eq!(const_i32("LAUNCHER_DEFAULT_HEIGHT"), 820); assert_eq!(const_i32("LAUNCHER_DEFAULT_HEIGHT"), 940);
assert!(const_i32("LAUNCHER_DEFAULT_WIDTH") <= 1920); assert!(const_i32("LAUNCHER_DEFAULT_WIDTH") <= 1920);
assert!(const_i32("LAUNCHER_DEFAULT_HEIGHT") <= 1080); assert!(const_i32("LAUNCHER_DEFAULT_HEIGHT") <= 1080);
} }
#[test] #[test]
fn eye_panes_keep_the_locked_larger_preview_footprint() { fn eye_panes_keep_the_locked_larger_preview_footprint() {
assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 460); assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 568);
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 258); assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 320);
assert!( assert!(
UI_SRC.contains("caption_label.set_halign(gtk::Align::End)") UI_SRC.contains("caption_label.set_halign(gtk::Align::End)")
|| UI_SRC.contains("capture_label.set_halign(gtk::Align::End)") || UI_SRC.contains("capture_label.set_halign(gtk::Align::End)")
@ -117,13 +117,42 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
} }
#[test] #[test]
fn remote_audio_gain_control_stays_in_the_operations_rail() { fn media_controls_own_stream_toggles_and_inline_gain_controls() {
assert!(!UI_SRC.contains("Remote Audio")); assert!(!UI_SRC.contains("Remote Audio"));
assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);")); assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);"));
assert!(UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));")); assert!(!UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));"));
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Webcam\")")); assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Camera\")"));
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Mic\")")); assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Mic\")"));
assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Audio\")")); assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Speaker\")"));
assert!(UI_SRC.contains("let camera_quality_combo = gtk::ComboBoxText::new();"));
assert!(UI_SRC.contains("sync_camera_quality_combo("));
assert!(UI_SRC.contains("camera_quality_combo.set_size_request(88, -1);"));
assert!(
UI_SRC.contains("let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);")
);
assert!(UI_SRC.contains("camera_selectors.append(&camera_combo);"));
assert!(UI_SRC.contains("camera_selectors.append(&camera_quality_combo);"));
assert!(
source_index("camera_selectors.append(&camera_combo);")
< source_index("camera_selectors.append(&camera_quality_combo);")
);
assert!(
UI_SRC.contains("let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);")
);
assert!(UI_SRC.contains("speaker_selectors.append(&speaker_combo);"));
assert!(UI_SRC.contains("speaker_selectors.append(&audio_gain_scale);"));
assert!(
UI_SRC
.contains("let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);")
);
assert!(UI_SRC.contains("microphone_selectors.append(&microphone_combo);"));
assert!(UI_SRC.contains("microphone_selectors.append(&mic_gain_scale);"));
assert_eq!(
UI_SRC
.matches("attach_device_control_row(\n &media_grid")
.count(),
3
);
assert!(!UI_SRC.contains("camera_combo.append(Some(\"auto\")")); assert!(!UI_SRC.contains("camera_combo.append(Some(\"auto\")"));
assert!(!UI_SRC.contains("speaker_combo.append(Some(\"auto\")")); assert!(!UI_SRC.contains("speaker_combo.append(Some(\"auto\")"));
assert!(!UI_SRC.contains("microphone_combo.append(Some(\"auto\")")); assert!(!UI_SRC.contains("microphone_combo.append(Some(\"auto\")"));
@ -132,14 +161,14 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() {
assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);")); assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);"));
assert!(UI_SRC.contains("let audio_gain_scale =")); assert!(UI_SRC.contains("let audio_gain_scale ="));
assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);")); assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);"));
assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);")); assert!(UI_SRC.contains("audio_gain_scale.set_size_request(96, -1);"));
assert!(UI_SRC.contains("let mic_gain_scale =")); assert!(UI_SRC.contains("let mic_gain_scale ="));
assert!(UI_SRC.contains("mic_gain_scale.set_draw_value(false);")); assert!(UI_SRC.contains("mic_gain_scale.set_draw_value(false);"));
assert!(UI_SRC.contains("mic_gain_value.set_width_chars(5);")); assert!(UI_SRC.contains("mic_gain_scale.set_size_request(96, -1);"));
assert!(UI_SRC.contains("audio_gain_row.set_size_request(220, -1);")); assert!(!UI_SRC.contains("audio_gain_row.set_size_request(220, -1);"));
assert!(UI_SRC.contains("mic_gain_row.set_size_request(220, -1);")); assert!(!UI_SRC.contains("mic_gain_row.set_size_request(220, -1);"));
assert!(UI_SRC.contains("power_shell.append(&audio_gain_row);")); assert!(!UI_SRC.contains("power_shell.append(&audio_gain_row);"));
assert!(UI_SRC.contains("power_shell.append(&mic_gain_row);")); assert!(!UI_SRC.contains("power_shell.append(&mic_gain_row);"));
assert_eq!( assert_eq!(
UI_SRC UI_SRC
.matches("connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));") .matches("connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));")
@ -149,14 +178,10 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() {
); );
assert!( assert!(
source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));") source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")
< source_index("let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);") < source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
); );
assert!( assert!(
source_index("power_shell.append(&audio_gain_row);") source_index("power_shell.append(&power_row);")
< source_index("power_shell.append(&mic_gain_row);")
);
assert!(
source_index("power_shell.append(&mic_gain_row);")
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));") < source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
); );
assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);")); assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);"));

View File

@ -38,6 +38,10 @@ fn relay_address_entry_is_locked_while_relay_is_live() {
".camera_combo\n .set_sensitive(!relay_live && state.channels.camera);" ".camera_combo\n .set_sensitive(!relay_live && state.channels.camera);"
) )
); );
assert!(UI_RUNTIME_SRC.contains(".camera_quality_combo"));
assert!(UI_RUNTIME_SRC.contains("widgets.camera_quality_combo.set_sensitive("));
assert!(UI_RUNTIME_SRC.contains("state.devices.camera.is_some()"));
assert!(UI_RUNTIME_SRC.contains("state.camera_quality.is_some()"));
assert!(UI_RUNTIME_SRC.contains( assert!(UI_RUNTIME_SRC.contains(
".microphone_combo\n .set_sensitive(!relay_live && state.channels.microphone);" ".microphone_combo\n .set_sensitive(!relay_live && state.channels.microphone);"
)); ));
@ -46,6 +50,10 @@ fn relay_address_entry_is_locked_while_relay_is_live() {
".speaker_combo\n .set_sensitive(!relay_live && state.channels.audio);" ".speaker_combo\n .set_sensitive(!relay_live && state.channels.audio);"
) )
); );
assert!(UI_RUNTIME_SRC.contains(".audio_gain_scale"));
assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.audio);"));
assert!(UI_RUNTIME_SRC.contains(".mic_gain_scale"));
assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.microphone);"));
assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);"));
assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);"));
assert!(UI_RUNTIME_SRC.contains("widgets.camera_channel_toggle.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("widgets.camera_channel_toggle.set_sensitive(!relay_live);"));
@ -98,3 +106,16 @@ fn active_relay_keeps_local_upstream_camera_and_microphone_evidence_visible() {
assert!(MICROPHONE_SRC.contains("appsink name=level_sink")); assert!(MICROPHONE_SRC.contains("appsink name=level_sink"));
assert!(MICROPHONE_SRC.contains("spawn_mic_level_tap")); assert!(MICROPHONE_SRC.contains("spawn_mic_level_tap"));
} }
#[test]
fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() {
assert!(UI_SRC.contains("selected_camera_quality(&camera_quality_combo"));
assert!(UI_SRC.contains("sync_camera_quality_selection"));
assert!(UI_SRC.contains("tests.set_camera_quality"));
assert!(DEVICE_TEST_SRC.contains("pub fn set_camera_quality"));
assert!(DEVICE_TEST_SRC.contains("build_camera_preview_pipeline(&device, mode)"));
assert!(DEVICE_TEST_SRC.contains("capsfilter caps=\\\"video/x-raw"));
assert!(CAMERA_SRC.contains("env_u32(\"LESAVKA_CAM_WIDTH\", cfg.map_or"));
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_WIDTH\""));
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\""));
}

View File

@ -231,4 +231,49 @@ exit 0
fs::write(&path, "bad nonce\n").expect("write invalid gain"); fs::write(&path, "bad nonce\n").expect("write invalid gain");
assert_eq!(read_audio_gain_control(&path), None); assert_eq!(read_audio_gain_control(&path), None);
} }
#[test]
fn audio_gain_parsing_and_formatting_are_stable() {
assert_eq!(parse_audio_gain("3.5 ignored"), Some(3.5));
assert_eq!(parse_audio_gain("99"), Some(MAX_AUDIO_GAIN));
assert_eq!(parse_audio_gain("-1"), Some(0.0));
assert_eq!(parse_audio_gain("nan"), None);
assert_eq!(parse_audio_gain(""), None);
assert_eq!(format_audio_gain_for_gst(2.1256), "2.126");
}
#[test]
fn sink_override_escaping_and_pactl_ranking_are_stable() {
assert_eq!(normalize_sink_override("autoaudiosink"), "autoaudiosink");
assert_eq!(
normalize_sink_override("fakesink sync=false"),
"fakesink sync=false"
);
let normal = normalize_sink_override("alsa_output.pci");
assert!(normal.contains("pulsesink device=\"alsa_output.pci\""));
assert!(normal.contains("buffer-time=350000"));
let bluetooth = pulsesink_device_element("bluez_output.headset");
assert!(bluetooth.contains("buffer-time=750000"));
let escaped = pulsesink_device_element("sink\\\"name");
assert!(escaped.contains("sink\\\\\\\"name"));
assert_eq!(
parse_pactl_default_sink("Server: x\nDefault Sink: my.default \n"),
Some("my.default".to_string())
);
assert_eq!(parse_pactl_default_sink("Default Sink: \n"), None);
let sinks = parse_pactl_short_sinks(
"bad\n1 idle.sink module IDLE\n2 run.sink module RUNNING\n3 suspended.sink module SUSPENDED\n",
Some("missing.default"),
);
assert_eq!(
sinks[0],
("missing.default".to_string(), "DEFAULT".to_string())
);
assert_eq!(sinks[1], ("run.sink".to_string(), "RUNNING".to_string()));
assert_eq!(sink_state_rank("RUNNING"), 0);
assert_eq!(sink_state_rank("IDLE"), 1);
assert_eq!(sink_state_rank("SUSPENDED"), 2);
assert_eq!(sink_state_rank("UNKNOWN"), 3);
}
} }

View File

@ -0,0 +1,49 @@
//! Include-based coverage for relay control CLI parsing and helpers.
//!
//! Scope: include `client/src/bin/lesavka-relayctl.rs` and exercise helper
//! branches that do not require a live relay server.
//! Targets: `client/src/bin/lesavka-relayctl.rs`.
//! Why: relay power recovery controls need parser coverage without depending on
//! a live relay endpoint.
#[allow(warnings)]
mod relayctl_binary {
include!(env!("LESAVKA_CLIENT_RELAYCTL_BIN_SRC"));
#[test]
fn command_aliases_and_usage_are_stable() {
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status));
assert_eq!(CommandKind::parse("auto"), Some(CommandKind::Auto));
assert_eq!(CommandKind::parse("on"), Some(CommandKind::On));
assert_eq!(CommandKind::parse("force-on"), Some(CommandKind::On));
assert_eq!(CommandKind::parse("off"), Some(CommandKind::Off));
assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off));
assert_eq!(CommandKind::parse("reset-usb"), Some(CommandKind::ResetUsb));
assert_eq!(
CommandKind::parse("recover-usb"),
Some(CommandKind::ResetUsb)
);
assert_eq!(CommandKind::parse("bad"), None);
assert!(usage().contains("lesavka-relayctl"));
}
#[tokio::test(flavor = "current_thread")]
async fn connect_rejects_invalid_endpoint_without_network_retry() {
let err = connect("not a uri").await.expect_err("invalid endpoint");
assert!(err.to_string().contains("invalid relay server address"));
}
#[test]
fn print_state_is_non_panicking_for_populated_payload() {
print_state(lesavka_common::lesavka::CapturePowerState {
available: true,
enabled: true,
mode: "manual".to_string(),
detected_devices: 2,
active_leases: 1,
unit: "lesavka-capture.service".to_string(),
detail: "ok".to_string(),
});
}
}

View File

@ -0,0 +1,65 @@
//! Process-level coverage for relay control CLI argument handling.
//!
//! Scope: launch the real `lesavka-relayctl` binary for argument-only paths.
//! Targets: `client/src/bin/lesavka-relayctl.rs`.
//! Why: CLI-only failures should stay fast and local instead of retrying a bad
//! network endpoint.
use serial_test::serial;
use std::path::{Path, PathBuf};
use std::process::Command;
fn candidate_dirs() -> Vec<PathBuf> {
let exe = std::env::current_exe().expect("current exe path");
let mut dirs = Vec::new();
if let Some(parent) = exe.parent() {
dirs.push(parent.to_path_buf());
if let Some(grand) = parent.parent() {
dirs.push(grand.to_path_buf());
}
}
dirs.push(PathBuf::from("target/debug"));
dirs.push(PathBuf::from("target/llvm-cov-target/debug"));
dirs
}
fn find_binary(name: &str) -> Option<PathBuf> {
candidate_dirs()
.into_iter()
.map(|dir| dir.join(name))
.find(|path| path.exists() && path.is_file())
}
#[test]
#[serial]
fn relayctl_help_exits_successfully() {
let Some(bin) = find_binary("lesavka-relayctl") else {
return;
};
let output = Command::new(Path::new(&bin))
.arg("--help")
.output()
.expect("spawn lesavka-relayctl --help");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("lesavka-relayctl"));
}
#[test]
#[serial]
fn relayctl_rejects_bad_arguments_before_network_use() {
let Some(bin) = find_binary("lesavka-relayctl") else {
return;
};
let output = Command::new(Path::new(&bin))
.arg("nonsense")
.output()
.expect("spawn lesavka-relayctl bad command");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("unknown command"));
}

View File

@ -0,0 +1,31 @@
//! Module-path coverage for client-side H.264 decoder selection.
//!
//! Scope: include the client decoder selection helper directly.
//! Targets: `client/src/video_support.rs`.
//! Why: operator decoder overrides should fall back cleanly on machines with
//! different GStreamer plugin sets.
#[path = "../../client/src/video_support.rs"]
mod video_support;
use serial_test::serial;
use temp_env::with_var;
#[test]
#[serial]
fn decoder_override_accepts_decodebin_without_factory_lookup() {
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
});
}
#[test]
#[serial]
fn decoder_override_ignores_blank_or_unknown_values() {
with_var("LESAVKA_H264_DECODER", Some(" "), || {
assert!(!video_support::pick_h264_decoder().trim().is_empty());
});
with_var("LESAVKA_H264_DECODER", Some("not-a-real-decoder"), || {
assert!(!video_support::pick_h264_decoder().trim().is_empty());
});
}

View File

@ -11,12 +11,47 @@
mod server_audio_contract; mod server_audio_contract;
mod tests { mod tests {
use super::server_audio_contract::{ClipTap, Voice, ear}; use super::server_audio_contract::{ClipTap, Voice, ear, start_pipeline_or_reset};
#[cfg(coverage)] #[cfg(coverage)]
use futures_util::StreamExt; use futures_util::StreamExt;
use gstreamer as gst;
use gstreamer::prelude::*;
use lesavka_common::lesavka::AudioPacket; use lesavka_common::lesavka::AudioPacket;
use serial_test::serial; use serial_test::serial;
const AUDIO_SRC: &str = include_str!("../../server/src/audio.rs");
fn source_index(needle: &str) -> usize {
AUDIO_SRC
.find(needle)
.unwrap_or_else(|| panic!("missing source marker: {needle}"))
}
#[test]
fn failed_pipeline_start_contract_resets_and_does_not_spawn_bus_first() {
assert!(AUDIO_SRC.contains("let _ = pipeline.set_state(gst::State::Null);"));
assert!(AUDIO_SRC.contains("impl Drop for Voice"));
assert!(
source_index("start_pipeline_or_reset(&pipeline, \"starting audio pipeline\")?")
< source_index("spawn_pipeline_bus_logger(bus, \"audio\"")
);
assert!(
source_index("start_pipeline_or_reset(&pipeline, \"starting voice pipeline\")?")
< source_index("spawn_pipeline_bus_logger(bus, \"voice\"")
);
}
#[test]
#[serial]
fn start_pipeline_or_reset_starts_empty_pipeline() {
let _ = gst::init();
let pipeline = gst::Pipeline::new();
start_pipeline_or_reset(&pipeline, "starting contract pipeline")
.expect("empty pipeline should enter playing");
assert_eq!(pipeline.current_state(), gst::State::Playing);
let _ = pipeline.set_state(gst::State::Null);
}
#[test] #[test]
#[serial] #[serial]
fn ear_rejects_malformed_pipeline_device_string() { fn ear_rejects_malformed_pipeline_device_string() {

View File

@ -93,6 +93,25 @@ fn camera_config_forced_hdmi_tracks_cached_state() {
}); });
} }
#[test]
#[serial]
fn camera_config_forced_hdmi_honors_1080p_uplink_override() {
with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || {
with_var("LESAVKA_CAM_WIDTH", Some("1920"), || {
with_var("LESAVKA_CAM_HEIGHT", Some("1080"), || {
with_var("LESAVKA_CAM_FPS", Some("30"), || {
let cfg = update_camera_config();
assert_eq!(cfg.output, CameraOutput::Hdmi);
assert_eq!(cfg.codec, CameraCodec::H264);
assert_eq!(cfg.width, 1920);
assert_eq!(cfg.height, 1080);
assert_eq!(cfg.fps, 30);
});
});
});
});
}
#[test] #[test]
#[serial] #[serial]
fn camera_config_output_override_is_case_insensitive() { fn camera_config_output_override_is_case_insensitive() {

View File

@ -0,0 +1,33 @@
//! Contract tests for server install-time operational defaults.
//!
//! Scope: statically guard the generated `/etc/lesavka/server.env` values.
//! Targets: `scripts/install/server.sh`.
//! Why: HDMI capture adapter settings should be reproducible after reboot or
//! reinstall instead of living as one-off shell state.
const SERVER_INSTALL: &str = include_str!("../../scripts/install/server.sh");
#[test]
fn server_install_pins_hdmi_camera_and_display_defaults() {
for expected in [
"LESAVKA_CAM_OUTPUT=%s",
"LESAVKA_CAM_WIDTH=%s",
"LESAVKA_CAM_HEIGHT=%s",
"LESAVKA_CAM_FPS=%s",
"LESAVKA_HDMI_WIDTH=%s",
"LESAVKA_HDMI_HEIGHT=%s",
"LESAVKA_HDMI_SINK=%s",
"LESAVKA_HDMI_FBDEV=%s",
] {
assert!(
SERVER_INSTALL.contains(expected),
"install script should emit {expected}"
);
}
assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_WIDTH:-1920}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_HEIGHT:-1080}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_FPS:-30}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_WIDTH:-1920}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_HEIGHT:-1080}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_SINK:-fbdevsink}"));
}

View File

@ -1,16 +1,26 @@
//! Integration coverage for server process startup behavior. //! Integration coverage for server process startup behavior.
//! //!
//! Scope: launch the real `lesavka-server` binary and assert startup reaches a //! Scope: launch the real `lesavka-server` binary and assert startup stays
//! terminal state quickly in this non-gadget test environment. //! resilient in this non-gadget test environment.
//! Targets: `server/src/main.rs`. //! Targets: `server/src/main.rs`.
//! Why: process-level boot behavior should remain deterministic when required //! Why: missing gadget endpoints should not crash the relay; the server keeps
//! gadget endpoints are unavailable. //! running and opens HID lazily when the device nodes appear.
use serial_test::serial; use serial_test::serial;
use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
fn cargo_binary(name: &str) -> Option<PathBuf> {
let key = format!("CARGO_BIN_EXE_{name}");
option_env!("CARGO_BIN_EXE_lesavka-server")
.filter(|_| name == "lesavka-server")
.map(PathBuf::from)
.or_else(|| std::env::var_os(key).map(PathBuf::from))
.filter(|path| path.exists() && path.is_file())
}
fn candidate_dirs() -> Vec<PathBuf> { fn candidate_dirs() -> Vec<PathBuf> {
let exe = std::env::current_exe().expect("current exe path"); let exe = std::env::current_exe().expect("current exe path");
let mut dirs = Vec::new(); let mut dirs = Vec::new();
@ -26,18 +36,51 @@ fn candidate_dirs() -> Vec<PathBuf> {
} }
fn find_binary(name: &str) -> Option<PathBuf> { fn find_binary(name: &str) -> Option<PathBuf> {
candidate_dirs() cargo_binary(name).or_else(|| {
.into_iter() candidate_dirs()
.map(|dir| dir.join(name)) .into_iter()
.find(|path| path.exists() && path.is_file()) .map(|dir| dir.join(name))
.find(|path| path.exists() && path.is_file())
})
}
fn server_package_version() -> Option<String> {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()?
.join("server/Cargo.toml");
fs::read_to_string(manifest).ok()?.lines().find_map(|line| {
let trimmed = line.trim();
trimmed
.strip_prefix("version")
.and_then(|value| value.split('=').nth(1))
.map(str::trim)
.and_then(|value| value.strip_prefix('"')?.strip_suffix('"'))
.map(str::to_string)
})
}
fn wait_for_log(path: &PathBuf, needle: &str, deadline: Instant) -> String {
loop {
let log = fs::read_to_string(path).unwrap_or_default();
if log.contains(needle) {
return log;
}
assert!(
Instant::now() < deadline,
"timed out waiting for log line {needle:?}; log was:\n{log}"
);
std::thread::sleep(Duration::from_millis(50));
}
} }
#[test] #[test]
#[serial] #[serial]
fn server_binary_exits_quickly_without_hid_nodes() { fn server_binary_stays_up_with_missing_hid_nodes_and_current_version() {
let Some(bin) = find_binary("lesavka-server") else { let Some(bin) = find_binary("lesavka-server") else {
return; return;
}; };
let log_path = PathBuf::from("/tmp/lesavka-server.log");
let _ = fs::remove_file(&log_path);
let mut child = Command::new(bin) let mut child = Command::new(bin)
.env("LESAVKA_DISABLE_UVC", "1") .env("LESAVKA_DISABLE_UVC", "1")
@ -45,18 +88,22 @@ fn server_binary_exits_quickly_without_hid_nodes() {
.expect("spawn lesavka-server"); .expect("spawn lesavka-server");
let deadline = Instant::now() + Duration::from_secs(3); let deadline = Instant::now() + Duration::from_secs(3);
loop { if let Some(version) = server_package_version() {
if let Some(status) = child.try_wait().expect("poll child") { let _ = wait_for_log(
assert!( &log_path,
!status.success(), &format!("lesavka_server v{version} starting up"),
"server unexpectedly succeeded in test environment" deadline,
); );
break;
}
if Instant::now() >= deadline {
let _ = child.kill();
panic!("server did not terminate within startup timeout");
}
std::thread::sleep(Duration::from_millis(50));
} }
let log = wait_for_log(
&log_path,
"HID endpoints are not ready; relay will keep running and open them lazily",
deadline,
);
assert!(
child.try_wait().expect("poll child").is_none(),
"server should stay alive with lazy HID recovery; log was:\n{log}"
);
let _ = child.kill();
let _ = child.wait();
} }

View File

@ -70,6 +70,33 @@ mod video_sinks_include_contract {
}); });
} }
#[test]
#[serial]
#[cfg(not(coverage))]
fn build_hdmi_sink_configures_fbdev_override_for_capture_adapters() {
init_gst();
if gst::ElementFactory::find("fbdevsink").is_none() {
return;
}
with_var("LESAVKA_HDMI_SINK", Some("fbdevsink"), || {
with_var("LESAVKA_HDMI_FBDEV", Some("/dev/fb42"), || {
let sink = build_hdmi_sink(&cfg(CameraCodec::H264))
.expect("fbdevsink override should build");
if sink.has_property("device", None) {
assert_eq!(sink.property::<String>("device"), "/dev/fb42");
}
if sink.has_property("sync", None) {
assert!(
!sink.property::<bool>("sync"),
"fbdev HDMI output should not clock-sync WAN camera frames"
);
}
});
});
}
#[test] #[test]
#[serial] #[serial]
fn build_hdmi_sink_invalid_override_surfaces_error() { fn build_hdmi_sink_invalid_override_surfaces_error() {
@ -112,25 +139,47 @@ mod video_sinks_include_contract {
with_var("LESAVKA_HDMI_SINK", None::<&str>, || { with_var("LESAVKA_HDMI_SINK", None::<&str>, || {
with_var("LESAVKA_HDMI_DRIVER", Some("vc4"), || { with_var("LESAVKA_HDMI_DRIVER", Some("vc4"), || {
let sink = build_hdmi_sink(&hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264)) with_var("LESAVKA_HDMI_RESTORE_CRTC", None::<&str>, || {
.expect("kmssink should build"); let sink = build_hdmi_sink(&hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264))
.expect("kmssink should build");
if sink.has_property("force-modesetting", None) { if sink.has_property("force-modesetting", None) {
assert!( assert!(
sink.property::<bool>("force-modesetting"), sink.property::<bool>("force-modesetting"),
"kmssink must drive the HDMI mode instead of relying on desktop state" "kmssink must drive the HDMI mode instead of relying on desktop state"
); );
} }
if sink.has_property("connector-id", None) { if sink.has_property("restore-crtc", None) {
assert_eq!(sink.property::<i32>("connector-id"), 43); assert!(
} !sink.property::<bool>("restore-crtc"),
if sink.has_property("driver-name", None) { "dedicated HDMI capture output should not restore the console CRTC"
assert_eq!(sink.property::<String>("driver-name"), "vc4"); );
} }
if sink.has_property("connector-id", None) {
assert_eq!(sink.property::<i32>("connector-id"), 43);
}
if sink.has_property("driver-name", None) {
assert_eq!(sink.property::<String>("driver-name"), "vc4");
}
});
}); });
}); });
} }
#[test]
#[serial]
fn bool_env_parser_accepts_operator_friendly_values() {
with_var("LESAVKA_BOOL_TEST", Some("yes"), || {
assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(true));
});
with_var("LESAVKA_BOOL_TEST", Some("off"), || {
assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(false));
});
with_var("LESAVKA_BOOL_TEST", Some("shrug"), || {
assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), None);
});
}
#[test] #[test]
#[serial] #[serial]
fn camera_sink_dispatch_is_stable_for_hdmi_variant() { fn camera_sink_dispatch_is_stable_for_hdmi_variant() {