feat(launcher): add webcam quality controls
This commit is contained in:
parent
b322396739
commit
3b112996dd
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.48"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -500,6 +500,7 @@ impl LesavkaClientApp {
|
||||
async fn audio_loop(ep: Channel, out: AudioOut) {
|
||||
let mut consecutive_source_failures = 0_u32;
|
||||
let mut last_usb_recovery_at: Option<Instant> = None;
|
||||
let mut delay = Duration::from_secs(1);
|
||||
loop {
|
||||
let mut cli = RelayClient::new(ep.clone());
|
||||
let req = MonitorRequest {
|
||||
@ -515,6 +516,7 @@ impl LesavkaClientApp {
|
||||
tracing::info!("🔊 audio stream opened");
|
||||
let mut packet_count: u64 = 0;
|
||||
let mut warned_no_packets = false;
|
||||
delay = Duration::from_secs(1);
|
||||
loop {
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
@ -533,9 +535,13 @@ impl LesavkaClientApp {
|
||||
);
|
||||
}
|
||||
out.push(pkt);
|
||||
consecutive_source_failures = 0;
|
||||
}
|
||||
Ok(Ok(None)) => {
|
||||
tracing::warn!(packets = packet_count, "⚠️🔊 audio stream ended");
|
||||
if packet_count == 0 {
|
||||
delay = app_support::next_delay(delay);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
@ -548,6 +554,7 @@ impl LesavkaClientApp {
|
||||
&message,
|
||||
)
|
||||
.await;
|
||||
delay = app_support::next_delay(delay);
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
@ -571,9 +578,10 @@ impl LesavkaClientApp {
|
||||
&message,
|
||||
)
|
||||
.await;
|
||||
delay = app_support::next_delay(delay);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -96,11 +96,9 @@ impl CameraCapture {
|
||||
|cfg| matches!(cfg.codec, CameraCodec::Mjpeg),
|
||||
);
|
||||
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 height = cfg.map_or_else(|| env_u32("LESAVKA_CAM_HEIGHT", 720), |cfg| cfg.height);
|
||||
let fps = cfg
|
||||
.map_or_else(|| env_u32("LESAVKA_CAM_FPS", 25), |cfg| cfg.fps)
|
||||
.max(1);
|
||||
let width = env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width));
|
||||
let height = env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height));
|
||||
let fps = env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1);
|
||||
let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps);
|
||||
let source_profile = camera_source_profile(allow_mjpg_source);
|
||||
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;
|
||||
|
||||
@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::devices::CameraMode;
|
||||
|
||||
const CAMERA_PREVIEW_WIDTH: i32 = 128;
|
||||
const CAMERA_PREVIEW_HEIGHT: i32 = 72;
|
||||
const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview.";
|
||||
@ -36,6 +38,7 @@ pub enum DeviceTestKind {
|
||||
pub struct DeviceTestController {
|
||||
camera: Option<LocalCameraPreview>,
|
||||
selected_camera: Option<String>,
|
||||
selected_camera_mode: Option<CameraMode>,
|
||||
microphone: Option<LocalMicrophoneMonitor>,
|
||||
microphone_probe: Option<LocalMicrophoneLevelProbe>,
|
||||
speaker: Option<Child>,
|
||||
@ -49,6 +52,7 @@ impl Default for DeviceTestController {
|
||||
Self {
|
||||
camera: None,
|
||||
selected_camera: None,
|
||||
selected_camera_mode: None,
|
||||
microphone: None,
|
||||
microphone_probe: None,
|
||||
speaker: None,
|
||||
@ -74,6 +78,7 @@ impl DeviceTestController {
|
||||
}
|
||||
let mut preview = LocalCameraPreview::new(camera_picture, camera_status);
|
||||
preview.set_selected(self.selected_camera.as_deref())?;
|
||||
preview.set_selected_mode(self.selected_camera_mode)?;
|
||||
self.camera = Some(preview);
|
||||
Ok(())
|
||||
}
|
||||
@ -107,6 +112,14 @@ impl DeviceTestController {
|
||||
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> {
|
||||
let preview = self
|
||||
.camera
|
||||
@ -361,6 +374,7 @@ struct LocalCameraPreview {
|
||||
generation: Arc<AtomicU64>,
|
||||
running: Arc<AtomicBool>,
|
||||
selected_device: Option<String>,
|
||||
selected_mode: Option<CameraMode>,
|
||||
relay_preview_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@ -422,6 +436,7 @@ impl LocalCameraPreview {
|
||||
generation,
|
||||
running,
|
||||
selected_device: None,
|
||||
selected_mode: None,
|
||||
relay_preview_path: None,
|
||||
}
|
||||
}
|
||||
@ -456,6 +471,27 @@ impl LocalCameraPreview {
|
||||
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> {
|
||||
if self.is_running() {
|
||||
self.stop();
|
||||
@ -472,6 +508,7 @@ impl LocalCameraPreview {
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?;
|
||||
self.relay_preview_path = None;
|
||||
let mode = self.selected_mode;
|
||||
let device = resolve_camera_device(&selected);
|
||||
let latest = Arc::clone(&self.latest);
|
||||
let status_text = Arc::clone(&self.status_text);
|
||||
@ -485,6 +522,7 @@ impl LocalCameraPreview {
|
||||
if let Err(err) = run_camera_preview_feed(
|
||||
selected,
|
||||
device,
|
||||
mode,
|
||||
token,
|
||||
latest,
|
||||
status_text.clone(),
|
||||
@ -551,8 +589,12 @@ impl LocalCameraPreview {
|
||||
} else {
|
||||
match self.selected_device.as_deref() {
|
||||
Some(camera) => {
|
||||
let quality = self
|
||||
.selected_mode
|
||||
.map(CameraMode::short_label)
|
||||
.unwrap_or_else(|| "default quality".to_string());
|
||||
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(),
|
||||
@ -728,19 +770,23 @@ fn run_microphone_monitor_feed(
|
||||
fn run_camera_preview_feed(
|
||||
selected: String,
|
||||
device: String,
|
||||
mode: Option<CameraMode>,
|
||||
token: u64,
|
||||
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
||||
status_text: Arc<Mutex<String>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
running: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let (pipeline, appsink) = build_camera_preview_pipeline(&device)?;
|
||||
let (pipeline, appsink) = build_camera_preview_pipeline(&device, mode)?;
|
||||
pipeline
|
||||
.set_state(gst::State::Playing)
|
||||
.context("starting in-launcher camera preview pipeline")?;
|
||||
|
||||
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 {
|
||||
@ -815,8 +861,11 @@ fn run_microphone_level_probe(
|
||||
running.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn build_camera_preview_pipeline(device: &str) -> Result<(gst::Pipeline, gst_app::AppSink)> {
|
||||
let desc = camera_preview_pipeline_desc(device);
|
||||
fn build_camera_preview_pipeline(
|
||||
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)?
|
||||
.downcast::<gst::Pipeline>()
|
||||
.expect("camera preview pipeline");
|
||||
@ -858,11 +907,19 @@ fn build_microphone_monitor_pipeline(
|
||||
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 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!(
|
||||
"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 ! \
|
||||
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,
|
||||
read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device,
|
||||
};
|
||||
use crate::launcher::devices::CameraMode;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
@ -1077,12 +1135,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("videoconvert ! videoscale ! videorate !"));
|
||||
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]
|
||||
fn microphone_monitor_uses_pulse_for_launcher_catalog_source_names() {
|
||||
let desc = microphone_monitor_pipeline_desc(
|
||||
|
||||
@ -1,17 +1,61 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DeviceCatalog {
|
||||
pub cameras: Vec<String>,
|
||||
pub camera_modes: BTreeMap<String, Vec<CameraMode>>,
|
||||
pub microphones: Vec<String>,
|
||||
pub speakers: Vec<String>,
|
||||
pub keyboards: 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 {
|
||||
pub fn discover() -> Self {
|
||||
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 {
|
||||
let cameras = discover_camera_devices(override_dir);
|
||||
let camera_modes = discover_camera_modes(&cameras);
|
||||
let microphones = discover_microphone_devices();
|
||||
let speakers = discover_speaker_devices();
|
||||
let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
|
||||
let mice = discover_input_devices(InputDeviceKind::Mouse);
|
||||
Self {
|
||||
cameras,
|
||||
camera_modes,
|
||||
microphones,
|
||||
speakers,
|
||||
keyboards,
|
||||
@ -118,6 +164,87 @@ fn discover_camera_devices(override_dir: Option<String>) -> Vec<String> {
|
||||
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> {
|
||||
let output = std::process::Command::new("pactl")
|
||||
.args(["list", "short", kind])
|
||||
@ -331,6 +458,43 @@ mod tests {
|
||||
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]
|
||||
fn discover_uses_override_and_tolerates_missing_pactl() {
|
||||
let tmp = mk_temp_dir("discover-override");
|
||||
|
||||
@ -157,6 +157,15 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
|
||||
&& let Some(camera) = state.devices.camera.as_ref()
|
||||
{
|
||||
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 {
|
||||
envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string());
|
||||
}
|
||||
@ -256,6 +265,7 @@ mod tests {
|
||||
state.set_routing(InputRouting::Local);
|
||||
state.set_view_mode(ViewMode::Unified);
|
||||
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_speaker(Some("alsa_output.test".to_string()));
|
||||
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_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!(
|
||||
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
|
||||
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::devices::DeviceCatalog;
|
||||
use super::devices::{CameraMode, DeviceCatalog};
|
||||
use lesavka_common::eye_source::{
|
||||
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 breakout_sizes: [BreakoutSizePreset; 2],
|
||||
pub devices: DeviceSelection,
|
||||
pub camera_quality: Option<CameraMode>,
|
||||
pub channels: ChannelSelection,
|
||||
pub audio_gain_percent: u32,
|
||||
pub mic_gain_percent: u32,
|
||||
@ -364,6 +365,7 @@ impl Default for LauncherState {
|
||||
capture_bitrates_kbit: [18_000, 18_000],
|
||||
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
|
||||
devices: DeviceSelection::default(),
|
||||
camera_quality: None,
|
||||
channels: ChannelSelection::default(),
|
||||
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
|
||||
mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT,
|
||||
@ -658,6 +660,30 @@ impl LauncherState {
|
||||
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>) {
|
||||
self.devices.microphone = normalize_selection(microphone);
|
||||
}
|
||||
@ -720,6 +746,7 @@ impl LauncherState {
|
||||
|
||||
pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) {
|
||||
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.speaker, &catalog.speakers);
|
||||
}
|
||||
@ -778,7 +805,7 @@ impl LauncherState {
|
||||
|
||||
pub fn status_line(&self) -> String {
|
||||
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,
|
||||
match self.routing {
|
||||
InputRouting::Local => "local",
|
||||
@ -801,6 +828,9 @@ impl LauncherState {
|
||||
self.feed_source_preset(0).as_id(),
|
||||
self.feed_source_preset(1).as_id(),
|
||||
media_status_label(self.channels.camera, self.devices.camera.as_deref()),
|
||||
self.camera_quality
|
||||
.map(CameraMode::short_label)
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
media_status_label(self.channels.microphone, self.devices.microphone.as_deref()),
|
||||
media_status_label(self.channels.audio, self.devices.speaker.as_deref()),
|
||||
self.channels.camera,
|
||||
@ -1179,6 +1209,12 @@ mod tests {
|
||||
|
||||
let catalog = DeviceCatalog {
|
||||
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()],
|
||||
speakers: vec!["alsa_output.usb".to_string()],
|
||||
keyboards: vec!["/dev/input/event10".to_string()],
|
||||
@ -1197,10 +1233,56 @@ mod tests {
|
||||
let mut fresh = LauncherState::new();
|
||||
fresh.apply_catalog_defaults(&catalog);
|
||||
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.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]
|
||||
fn audio_gain_is_clamped_and_formatted_for_launcher_and_runtime() {
|
||||
let mut state = LauncherState::new();
|
||||
@ -1244,6 +1326,7 @@ mod tests {
|
||||
state.set_routing(InputRouting::Local);
|
||||
state.set_view_mode(ViewMode::Unified);
|
||||
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_speaker(Some("alsa_output.usb".to_string()));
|
||||
state.set_camera_channel_enabled(true);
|
||||
@ -1263,6 +1346,7 @@ mod tests {
|
||||
assert!(status.contains("d1=preview"));
|
||||
assert!(status.contains("d2=preview"));
|
||||
assert!(status.contains("camera=/dev/video0"));
|
||||
assert!(status.contains("camera_quality=1080p@30"));
|
||||
assert!(status.contains("mic=alsa_input.usb"));
|
||||
assert!(status.contains("speaker=alsa_output.usb"));
|
||||
assert!(status.contains("audio_gain=200%"));
|
||||
|
||||
@ -4,7 +4,7 @@ use anyhow::Result;
|
||||
use {
|
||||
super::clipboard::send_clipboard_text_to_remote,
|
||||
super::device_test::{DeviceTestController, DeviceTestKind},
|
||||
super::devices::DeviceCatalog,
|
||||
super::devices::{CameraMode, DeviceCatalog},
|
||||
super::diagnostics::{PerformanceSample, quality_probe_command},
|
||||
super::launcher_clipboard_control_path,
|
||||
super::launcher_focus_signal_path,
|
||||
@ -14,7 +14,10 @@ use {
|
||||
FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_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::{
|
||||
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,
|
||||
@ -153,6 +156,25 @@ fn retained_input_selection(current: Option<&str>, values: &[String]) -> Option<
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn selected_camera_quality(combo: >k::ComboBoxText) -> Option<CameraMode> {
|
||||
combo.active_id().as_deref().and_then(CameraMode::from_id)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn sync_camera_quality_selection(
|
||||
combo: >k::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))]
|
||||
fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 {
|
||||
if samples.len() < 2 {
|
||||
@ -705,9 +727,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let app = gtk::Application::builder()
|
||||
.application_id("dev.lesavka.launcher")
|
||||
.build();
|
||||
let catalog = Rc::new(DeviceCatalog::discover());
|
||||
let catalog = Rc::new(RefCell::new(DeviceCatalog::discover()));
|
||||
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 tests = Rc::new(RefCell::new(DeviceTestController::new()));
|
||||
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_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 (launcher_width, launcher_height) = launcher_default_size(display_width, display_height);
|
||||
window.set_default_size(launcher_width, launcher_height);
|
||||
let server_entry = view.server_entry.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 speaker_combo = view.speaker_combo.clone();
|
||||
let keyboard_combo = view.keyboard_combo.clone();
|
||||
@ -848,6 +872,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
.status_label
|
||||
.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());
|
||||
@ -878,24 +907,68 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
|
||||
{
|
||||
let state = Rc::clone(&state);
|
||||
let catalog = Rc::clone(&catalog);
|
||||
let widgets = widgets.clone();
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let tests = Rc::clone(&tests);
|
||||
let camera_combo = camera_combo.clone();
|
||||
let camera_quality_combo = camera_quality_combo.clone();
|
||||
let camera_combo_read = camera_combo.clone();
|
||||
camera_combo.connect_changed(move |_| {
|
||||
let selected = selected_combo_value(&camera_combo_read);
|
||||
let preview_was_running =
|
||||
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()) {
|
||||
widgets
|
||||
.status_label
|
||||
.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 {
|
||||
widgets.status_label.set_text(&format!(
|
||||
"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());
|
||||
@ -1259,17 +1332,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
|
||||
{
|
||||
let state = Rc::clone(&state);
|
||||
let catalog_state = Rc::clone(&catalog);
|
||||
let widgets = widgets.clone();
|
||||
let widgets_handle = widgets.clone();
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let tests = Rc::clone(&tests);
|
||||
let camera_combo = camera_combo.clone();
|
||||
let camera_quality_combo = camera_quality_combo.clone();
|
||||
let microphone_combo = microphone_combo.clone();
|
||||
let speaker_combo = speaker_combo.clone();
|
||||
let keyboard_combo = keyboard_combo.clone();
|
||||
let mouse_combo = mouse_combo.clone();
|
||||
widgets.device_refresh_button.connect_clicked(move |_| {
|
||||
let catalog = DeviceCatalog::discover();
|
||||
let fresh_catalog = DeviceCatalog::discover();
|
||||
let (
|
||||
selected_camera,
|
||||
selected_microphone,
|
||||
@ -1281,56 +1356,65 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
(
|
||||
retained_stage_selection(
|
||||
state.devices.camera.as_deref(),
|
||||
&catalog.cameras,
|
||||
&fresh_catalog.cameras,
|
||||
),
|
||||
retained_stage_selection(
|
||||
state.devices.microphone.as_deref(),
|
||||
&catalog.microphones,
|
||||
&fresh_catalog.microphones,
|
||||
),
|
||||
retained_stage_selection(
|
||||
state.devices.speaker.as_deref(),
|
||||
&catalog.speakers,
|
||||
&fresh_catalog.speakers,
|
||||
),
|
||||
retained_input_selection(
|
||||
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();
|
||||
state.select_camera(selected_camera);
|
||||
sync_camera_quality_selection(
|
||||
&camera_quality_combo,
|
||||
&mut state,
|
||||
&fresh_catalog,
|
||||
);
|
||||
state.select_microphone(selected_microphone);
|
||||
state.select_speaker(selected_speaker);
|
||||
state.select_keyboard(selected_keyboard);
|
||||
state.select_mouse(selected_mouse);
|
||||
}
|
||||
*catalog_state.borrow_mut() = fresh_catalog.clone();
|
||||
let state_snapshot = state.borrow().clone();
|
||||
sync_stage_device_combo(
|
||||
&camera_combo,
|
||||
&catalog.cameras,
|
||||
&fresh_catalog.cameras,
|
||||
state_snapshot.devices.camera.as_deref(),
|
||||
);
|
||||
sync_stage_device_combo(
|
||||
µphone_combo,
|
||||
&catalog.microphones,
|
||||
&fresh_catalog.microphones,
|
||||
state_snapshot.devices.microphone.as_deref(),
|
||||
);
|
||||
sync_stage_device_combo(
|
||||
&speaker_combo,
|
||||
&catalog.speakers,
|
||||
&fresh_catalog.speakers,
|
||||
state_snapshot.devices.speaker.as_deref(),
|
||||
);
|
||||
sync_input_device_combo(
|
||||
&keyboard_combo,
|
||||
&catalog.keyboards,
|
||||
&fresh_catalog.keyboards,
|
||||
state_snapshot.devices.keyboard.as_deref(),
|
||||
"all keyboards",
|
||||
);
|
||||
sync_input_device_combo(
|
||||
&mouse_combo,
|
||||
&catalog.mice,
|
||||
&fresh_catalog.mice,
|
||||
state_snapshot.devices.mouse.as_deref(),
|
||||
"all mice",
|
||||
);
|
||||
@ -1341,6 +1425,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
widgets_handle
|
||||
.status_label
|
||||
.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 {
|
||||
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."
|
||||
@ -1365,6 +1455,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let widgets = widgets.clone();
|
||||
let server_entry = server_entry.clone();
|
||||
let camera_combo = camera_combo.clone();
|
||||
let camera_quality_combo = camera_quality_combo.clone();
|
||||
let microphone_combo = microphone_combo.clone();
|
||||
let speaker_combo = speaker_combo.clone();
|
||||
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();
|
||||
state.select_camera(selected_combo_value(&camera_combo));
|
||||
state.select_camera_quality(selected_camera_quality(&camera_quality_combo));
|
||||
state.select_microphone(selected_combo_value(µphone_combo));
|
||||
state.select_speaker(selected_combo_value(&speaker_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 tests = Rc::clone(&tests);
|
||||
let camera_combo = camera_combo.clone();
|
||||
let camera_quality_combo = camera_quality_combo.clone();
|
||||
let camera_test_button = widgets.camera_test_button.clone();
|
||||
let widgets_handle = widgets.clone();
|
||||
camera_test_button.connect_clicked(move |_| {
|
||||
let selected = selected_combo_value(&camera_combo);
|
||||
let quality = selected_camera_quality(&camera_quality_combo);
|
||||
let result = {
|
||||
let mut tests = tests.borrow_mut();
|
||||
let _ = tests.set_camera_selection(selected.as_deref());
|
||||
let _ = tests.set_camera_quality(quality);
|
||||
tests.toggle_camera()
|
||||
};
|
||||
update_test_action_result(
|
||||
|
||||
@ -4,7 +4,7 @@ use evdev::Device;
|
||||
use gtk::{pango, prelude::*};
|
||||
|
||||
use super::{
|
||||
devices::DeviceCatalog,
|
||||
devices::{CameraMode, DeviceCatalog},
|
||||
diagnostics::DiagnosticsLog,
|
||||
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
|
||||
state::{
|
||||
@ -67,6 +67,7 @@ pub struct LauncherWidgets {
|
||||
pub server_entry: gtk::Entry,
|
||||
pub start_button: gtk::Button,
|
||||
pub camera_combo: gtk::ComboBoxText,
|
||||
pub camera_quality_combo: gtk::ComboBoxText,
|
||||
pub microphone_combo: gtk::ComboBoxText,
|
||||
pub speaker_combo: gtk::ComboBoxText,
|
||||
pub keyboard_combo: gtk::ComboBoxText,
|
||||
@ -108,6 +109,7 @@ pub struct LauncherView {
|
||||
pub window: gtk::ApplicationWindow,
|
||||
pub server_entry: gtk::Entry,
|
||||
pub camera_combo: gtk::ComboBoxText,
|
||||
pub camera_quality_combo: gtk::ComboBoxText,
|
||||
pub microphone_combo: gtk::ComboBoxText,
|
||||
pub speaker_combo: gtk::ComboBoxText,
|
||||
pub keyboard_combo: gtk::ComboBoxText,
|
||||
@ -123,12 +125,12 @@ pub struct LauncherView {
|
||||
pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
|
||||
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
|
||||
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 CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158;
|
||||
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
|
||||
const EYE_PREVIEW_MIN_HEIGHT: i32 = 258;
|
||||
const EYE_PREVIEW_MIN_WIDTH: i32 = 460;
|
||||
const EYE_PREVIEW_MIN_HEIGHT: i32 = 320;
|
||||
const EYE_PREVIEW_MIN_WIDTH: i32 = 568;
|
||||
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
|
||||
|
||||
pub fn build_launcher_view(
|
||||
@ -244,6 +246,16 @@ pub fn build_launcher_view(
|
||||
&catalog.cameras,
|
||||
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");
|
||||
stabilize_button(&camera_test_button, 118);
|
||||
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_column_spacing(8);
|
||||
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);
|
||||
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);
|
||||
attach_device_row(&media_grid, 0, "Camera", &camera_combo, &camera_test_button);
|
||||
attach_device_row(
|
||||
attach_device_control_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,
|
||||
1,
|
||||
"Speaker",
|
||||
&speaker_combo,
|
||||
&audio_channel_toggle,
|
||||
&speaker_selectors,
|
||||
&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.",
|
||||
));
|
||||
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(µphone_combo);
|
||||
microphone_selectors.append(&mic_gain_scale);
|
||||
attach_device_control_row(
|
||||
&media_grid,
|
||||
2,
|
||||
"Microphone",
|
||||
µphone_combo,
|
||||
µphone_channel_toggle,
|
||||
µphone_selectors,
|
||||
µphone_test_button,
|
||||
);
|
||||
|
||||
@ -464,37 +548,6 @@ pub fn build_launcher_view(
|
||||
live_actions_row.append(&usb_recover_button);
|
||||
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(µphone_channel_toggle);
|
||||
channel_buttons.append(&audio_channel_toggle);
|
||||
channel_row.append(&channel_buttons);
|
||||
connection_body.append(&channel_row);
|
||||
|
||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
||||
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_off_button);
|
||||
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(&audio_gain_row);
|
||||
power_shell.append(&mic_gain_row);
|
||||
connection_body.append(&power_shell);
|
||||
let routing_heading = gtk::Label::new(Some("Inputs"));
|
||||
routing_heading.add_css_class("subgroup-title");
|
||||
@ -830,6 +824,7 @@ pub fn build_launcher_view(
|
||||
server_entry: server_entry.clone(),
|
||||
start_button: start_button.clone(),
|
||||
camera_combo: camera_combo.clone(),
|
||||
camera_quality_combo: camera_quality_combo.clone(),
|
||||
microphone_combo: microphone_combo.clone(),
|
||||
speaker_combo: speaker_combo.clone(),
|
||||
keyboard_combo: keyboard_combo.clone(),
|
||||
@ -872,6 +867,7 @@ pub fn build_launcher_view(
|
||||
window,
|
||||
server_entry,
|
||||
camera_combo,
|
||||
camera_quality_combo,
|
||||
microphone_combo,
|
||||
speaker_combo,
|
||||
keyboard_combo,
|
||||
@ -1207,6 +1203,29 @@ pub fn sync_stage_device_combo(
|
||||
set_stage_combo_active_text(combo, selected);
|
||||
}
|
||||
|
||||
pub fn sync_camera_quality_combo(
|
||||
combo: >k::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(
|
||||
combo: >k::ComboBoxText,
|
||||
values: &[String],
|
||||
@ -1221,18 +1240,17 @@ pub fn sync_input_device_combo(
|
||||
super::ui_runtime::set_combo_active_text(combo, selected);
|
||||
}
|
||||
|
||||
fn attach_device_row(
|
||||
fn attach_device_control_row(
|
||||
grid: >k::Grid,
|
||||
row: i32,
|
||||
label: &str,
|
||||
combo: >k::ComboBoxText,
|
||||
stream_toggle: >k::CheckButton,
|
||||
selector: &impl IsA<gtk::Widget>,
|
||||
test_button: >k::Button,
|
||||
) {
|
||||
let label_widget = gtk::Label::new(Some(label));
|
||||
label_widget.set_halign(gtk::Align::Start);
|
||||
combo.set_hexpand(true);
|
||||
grid.attach(&label_widget, 0, row, 1, 1);
|
||||
grid.attach(combo, 1, row, 1, 1);
|
||||
stream_toggle.set_halign(gtk::Align::Start);
|
||||
selector.set_hexpand(true);
|
||||
grid.attach(stream_toggle, 0, row, 1, 1);
|
||||
grid.attach(selector, 1, row, 1, 1);
|
||||
grid.attach(test_button, 2, row, 1, 1);
|
||||
}
|
||||
|
||||
|
||||
@ -130,12 +130,21 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
widgets
|
||||
.camera_combo
|
||||
.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
|
||||
.microphone_combo
|
||||
.set_sensitive(!relay_live && state.channels.microphone);
|
||||
widgets
|
||||
.speaker_combo
|
||||
.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.mouse_combo.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
|
||||
.microphone_test_button
|
||||
.set_sensitive(!relay_live && state.channels.microphone);
|
||||
widgets
|
||||
.mic_gain_scale
|
||||
.set_sensitive(!relay_live && state.channels.microphone);
|
||||
widgets
|
||||
.speaker_test_button
|
||||
.set_sensitive(!relay_live && state.channels.audio);
|
||||
|
||||
@ -41,5 +41,8 @@ pub fn pick_h264_decoder() -> String {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.48"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"client/src/app.rs": {
|
||||
"clippy_warnings": 40,
|
||||
"doc_debt": 13,
|
||||
"loc": 808
|
||||
"loc": 816
|
||||
},
|
||||
"client/src/app_support.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -23,7 +23,7 @@
|
||||
"client/src/input/camera.rs": {
|
||||
"clippy_warnings": 14,
|
||||
"doc_debt": 10,
|
||||
"loc": 719
|
||||
"loc": 717
|
||||
},
|
||||
"client/src/input/inputs.rs": {
|
||||
"clippy_warnings": 40,
|
||||
@ -61,14 +61,14 @@
|
||||
"loc": 178
|
||||
},
|
||||
"client/src/launcher/device_test.rs": {
|
||||
"clippy_warnings": 67,
|
||||
"doc_debt": 40,
|
||||
"loc": 1148
|
||||
"clippy_warnings": 75,
|
||||
"doc_debt": 43,
|
||||
"loc": 1219
|
||||
},
|
||||
"client/src/launcher/devices.rs": {
|
||||
"clippy_warnings": 6,
|
||||
"doc_debt": 14,
|
||||
"loc": 400
|
||||
"clippy_warnings": 25,
|
||||
"doc_debt": 19,
|
||||
"loc": 564
|
||||
},
|
||||
"client/src/launcher/diagnostics.rs": {
|
||||
"clippy_warnings": 92,
|
||||
@ -78,7 +78,7 @@
|
||||
"client/src/launcher/mod.rs": {
|
||||
"clippy_warnings": 8,
|
||||
"doc_debt": 8,
|
||||
"loc": 480
|
||||
"loc": 497
|
||||
},
|
||||
"client/src/launcher/power.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -91,24 +91,24 @@
|
||||
"loc": 2216
|
||||
},
|
||||
"client/src/launcher/state.rs": {
|
||||
"clippy_warnings": 168,
|
||||
"doc_debt": 57,
|
||||
"loc": 1478
|
||||
"clippy_warnings": 172,
|
||||
"doc_debt": 58,
|
||||
"loc": 1562
|
||||
},
|
||||
"client/src/launcher/ui.rs": {
|
||||
"clippy_warnings": 68,
|
||||
"clippy_warnings": 70,
|
||||
"doc_debt": 23,
|
||||
"loc": 2524
|
||||
"loc": 2619
|
||||
},
|
||||
"client/src/launcher/ui_components.rs": {
|
||||
"clippy_warnings": 22,
|
||||
"doc_debt": 17,
|
||||
"loc": 1497
|
||||
"doc_debt": 18,
|
||||
"loc": 1515
|
||||
},
|
||||
"client/src/launcher/ui_runtime.rs": {
|
||||
"clippy_warnings": 74,
|
||||
"doc_debt": 44,
|
||||
"loc": 1790
|
||||
"loc": 1802
|
||||
},
|
||||
"client/src/layout.rs": {
|
||||
"clippy_warnings": 6,
|
||||
@ -157,8 +157,8 @@
|
||||
},
|
||||
"client/src/video_support.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 1,
|
||||
"loc": 45
|
||||
"doc_debt": 2,
|
||||
"loc": 48
|
||||
},
|
||||
"common/src/bin/cli.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -196,9 +196,9 @@
|
||||
"loc": 105
|
||||
},
|
||||
"server/src/audio.rs": {
|
||||
"clippy_warnings": 43,
|
||||
"doc_debt": 13,
|
||||
"loc": 680
|
||||
"clippy_warnings": 47,
|
||||
"doc_debt": 15,
|
||||
"loc": 737
|
||||
},
|
||||
"server/src/bin/lesavka-uvc.real.inc": {
|
||||
"clippy_warnings": 33,
|
||||
@ -211,14 +211,14 @@
|
||||
"loc": 712
|
||||
},
|
||||
"server/src/camera.rs": {
|
||||
"clippy_warnings": 12,
|
||||
"doc_debt": 11,
|
||||
"loc": 392
|
||||
"clippy_warnings": 18,
|
||||
"doc_debt": 19,
|
||||
"loc": 623
|
||||
},
|
||||
"server/src/camera_runtime.rs": {
|
||||
"clippy_warnings": 10,
|
||||
"doc_debt": 5,
|
||||
"loc": 200
|
||||
"loc": 204
|
||||
},
|
||||
"server/src/capture_power.rs": {
|
||||
"clippy_warnings": 12,
|
||||
@ -276,9 +276,9 @@
|
||||
"loc": 844
|
||||
},
|
||||
"server/src/video_sinks.rs": {
|
||||
"clippy_warnings": 78,
|
||||
"doc_debt": 11,
|
||||
"loc": 574
|
||||
"clippy_warnings": 80,
|
||||
"doc_debt": 15,
|
||||
"loc": 679
|
||||
},
|
||||
"server/src/video_support.rs": {
|
||||
"clippy_warnings": 8,
|
||||
|
||||
@ -2,14 +2,14 @@
|
||||
"files": {
|
||||
"client/src/app.rs": {
|
||||
"line_percent": 97.4,
|
||||
"loc": 808
|
||||
"loc": 816
|
||||
},
|
||||
"client/src/app_support.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 132
|
||||
},
|
||||
"client/src/bin/lesavka-relayctl.rs": {
|
||||
"line_percent": 0.0,
|
||||
"line_percent": 25.24,
|
||||
"loc": 140
|
||||
},
|
||||
"client/src/handshake.rs": {
|
||||
@ -17,8 +17,8 @@
|
||||
"loc": 381
|
||||
},
|
||||
"client/src/input/camera.rs": {
|
||||
"line_percent": 95.24,
|
||||
"loc": 719
|
||||
"line_percent": 96.51,
|
||||
"loc": 717
|
||||
},
|
||||
"client/src/input/inputs.rs": {
|
||||
"line_percent": 96.39,
|
||||
@ -45,24 +45,24 @@
|
||||
"loc": 178
|
||||
},
|
||||
"client/src/launcher/devices.rs": {
|
||||
"line_percent": 95.93,
|
||||
"loc": 400
|
||||
"line_percent": 96.0,
|
||||
"loc": 564
|
||||
},
|
||||
"client/src/launcher/diagnostics.rs": {
|
||||
"line_percent": 84.3,
|
||||
"loc": 1021
|
||||
},
|
||||
"client/src/launcher/mod.rs": {
|
||||
"line_percent": 84.2,
|
||||
"loc": 480
|
||||
"line_percent": 84.85,
|
||||
"loc": 497
|
||||
},
|
||||
"client/src/launcher/state.rs": {
|
||||
"line_percent": 85.06,
|
||||
"loc": 1478
|
||||
"line_percent": 86.04,
|
||||
"loc": 1562
|
||||
},
|
||||
"client/src/launcher/ui.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 2524
|
||||
"loc": 2619
|
||||
},
|
||||
"client/src/layout.rs": {
|
||||
"line_percent": 97.73,
|
||||
@ -73,7 +73,7 @@
|
||||
"loc": 100
|
||||
},
|
||||
"client/src/output/audio.rs": {
|
||||
"line_percent": 76.92,
|
||||
"line_percent": 89.42,
|
||||
"loc": 371
|
||||
},
|
||||
"client/src/output/display.rs": {
|
||||
@ -93,8 +93,8 @@
|
||||
"loc": 82
|
||||
},
|
||||
"client/src/video_support.rs": {
|
||||
"line_percent": 0.0,
|
||||
"loc": 45
|
||||
"line_percent": 83.87,
|
||||
"loc": 48
|
||||
},
|
||||
"common/src/bin/cli.rs": {
|
||||
"line_percent": 100.0,
|
||||
@ -125,20 +125,20 @@
|
||||
"loc": 105
|
||||
},
|
||||
"server/src/audio.rs": {
|
||||
"line_percent": 98.97,
|
||||
"loc": 680
|
||||
"line_percent": 96.88,
|
||||
"loc": 737
|
||||
},
|
||||
"server/src/bin/lesavka-uvc.rs": {
|
||||
"line_percent": 95.92,
|
||||
"loc": 712
|
||||
},
|
||||
"server/src/camera.rs": {
|
||||
"line_percent": 99.1,
|
||||
"loc": 392
|
||||
"line_percent": 96.6,
|
||||
"loc": 623
|
||||
},
|
||||
"server/src/camera_runtime.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 200
|
||||
"loc": 204
|
||||
},
|
||||
"server/src/capture_power.rs": {
|
||||
"line_percent": 100.0,
|
||||
@ -173,8 +173,8 @@
|
||||
"loc": 844
|
||||
},
|
||||
"server/src/video_sinks.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 574
|
||||
"line_percent": 95.8,
|
||||
"loc": 679
|
||||
},
|
||||
"server/src/video_support.rs": {
|
||||
"line_percent": 97.62,
|
||||
|
||||
@ -27,8 +27,14 @@ build_url=${BUILD_URL:-}
|
||||
start_seconds=$(date +%s)
|
||||
status=0
|
||||
set +e
|
||||
cargo test --workspace --all-targets --color never 2>&1 | tee "${TEST_LOG}"
|
||||
status=${PIPESTATUS[0]}
|
||||
cargo build --workspace --bins --color never 2>&1 | tee "${TEST_LOG}"
|
||||
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
|
||||
end_seconds=$(date +%s)
|
||||
duration_seconds=$((end_seconds - start_seconds))
|
||||
|
||||
@ -423,7 +423,15 @@ fi
|
||||
echo "# Edit only for local hardware overrides; rerunning the installer refreshes defaults."
|
||||
if [[ -n $HDMI_CONNECTOR ]]; then
|
||||
printf 'LESAVKA_HDMI_CONNECTOR=%s\n' "$HDMI_CONNECTOR"
|
||||
printf 'LESAVKA_CAM_OUTPUT=%s\n' "${LESAVKA_CAM_OUTPUT:-hdmi}"
|
||||
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_UAC_DEV=%s\n' "${LESAVKA_UAC_DEV:-hw:UAC2Gadget,0}"
|
||||
printf 'LESAVKA_ALSA_DEV=%s\n' "${LESAVKA_ALSA_DEV:-hw:UAC2Gadget,0}"
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.48"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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 */
|
||||
/*───────────────────────────────────────────────────────────────────────────*/
|
||||
@ -104,35 +155,6 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
|
||||
let source_health = Arc::new(AudioSourceHealth::new());
|
||||
|
||||
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 ────────────*/
|
||||
sink.set_callbacks(
|
||||
@ -183,9 +205,8 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
|
||||
.build(),
|
||||
);
|
||||
|
||||
pipeline
|
||||
.set_state(gst::State::Playing)
|
||||
.context("starting audio pipeline")?;
|
||||
start_pipeline_or_reset(&pipeline, "starting audio pipeline")?;
|
||||
spawn_pipeline_bus_logger(bus, "audio", "🎶 audio pipeline PLAYING");
|
||||
|
||||
spawn_audio_source_watchdog(
|
||||
pipeline.clone(),
|
||||
@ -465,6 +486,12 @@ pub struct Voice {
|
||||
tap: ClipTap,
|
||||
}
|
||||
|
||||
impl Drop for Voice {
|
||||
fn drop(&mut self) {
|
||||
let _ = self._pipe.set_state(gst::State::Null);
|
||||
}
|
||||
}
|
||||
|
||||
fn voice_input_caps() -> gst::Caps {
|
||||
gst::Caps::builder("audio/mpeg")
|
||||
.field("mpegversion", 4i32)
|
||||
@ -494,7 +521,7 @@ impl Voice {
|
||||
.context("make fakesink")?;
|
||||
pipeline.add_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 {
|
||||
appsrc,
|
||||
@ -582,31 +609,6 @@ impl Voice {
|
||||
});
|
||||
|
||||
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
|
||||
// let _id = alsa_sink.connect("underrun", false, |_| {
|
||||
@ -614,7 +616,8 @@ impl Voice {
|
||||
// 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 {
|
||||
appsrc,
|
||||
|
||||
@ -193,13 +193,24 @@ fn parse_camera_output(raw: &str) -> Option<CameraOutput> {
|
||||
|
||||
fn select_hdmi_config(hdmi: Option<HdmiConnector>) -> CameraConfig {
|
||||
let hw_decode = has_hw_h264_decode();
|
||||
let (width, height) = if hw_decode { (1920, 1080) } else { (1280, 720) };
|
||||
let fps = 30;
|
||||
let (default_width, default_height) = if hw_decode { (1920, 1080) } else { (1280, 720) };
|
||||
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))]
|
||||
if !hw_decode {
|
||||
warn!(
|
||||
"📷 HDMI output: hardware H264 decoder not detected; requesting 720p30 camera uplink"
|
||||
);
|
||||
if width == default_width && height == default_height {
|
||||
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 {
|
||||
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]
|
||||
fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() {
|
||||
assert_eq!(
|
||||
|
||||
@ -3,6 +3,8 @@ use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer_app as gst_app;
|
||||
use lesavka_common::lesavka::VideoPacket;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use tracing::warn;
|
||||
|
||||
@ -423,12 +425,20 @@ fn build_hdmi_sink(_cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
|
||||
#[cfg(not(coverage))]
|
||||
fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
|
||||
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()
|
||||
.context("building HDMI sink");
|
||||
.context("building HDMI sink")?;
|
||||
disable_sink_clock_sync(&sink);
|
||||
return Ok(sink);
|
||||
}
|
||||
|
||||
if gst::ElementFactory::find("kmssink").is_some() {
|
||||
unblank_framebuffer(&hdmi_fbdev_device());
|
||||
let sink = gst::ElementFactory::make("kmssink").build()?;
|
||||
if sink.has_property("driver-name", None) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
let sink = gst::ElementFactory::make("autovideosink")
|
||||
.build()
|
||||
.context("building HDMI sink")?;
|
||||
let _ = sink.set_property("sync", &false);
|
||||
disable_sink_clock_sync(&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 {
|
||||
Uvc(WebcamSink),
|
||||
Hdmi(HdmiSink),
|
||||
|
||||
@ -66,6 +66,14 @@ fn main() {
|
||||
.join("client/src/output/video.rs")
|
||||
.canonicalize()
|
||||
.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
|
||||
.join("common/src/bin/cli.rs")
|
||||
.canonicalize()
|
||||
@ -131,6 +139,14 @@ fn main() {
|
||||
"cargo:rustc-env=LESAVKA_CLIENT_OUTPUT_VIDEO_SRC={}",
|
||||
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!(
|
||||
"cargo:rustc-env=LESAVKA_COMMON_CLI_BIN_SRC={}",
|
||||
common_cli.display()
|
||||
|
||||
@ -228,6 +228,8 @@ mod tests {
|
||||
use temp_env::with_var;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
const APP_SRC: &str = include_str!("../../client/src/app.rs");
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn run_headless_reaches_pending_reactor_branch() {
|
||||
@ -372,4 +374,12 @@ mod tests {
|
||||
"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;"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,15 +30,15 @@ fn source_index(needle: &str) -> usize {
|
||||
#[test]
|
||||
fn launcher_default_size_stays_inside_1080p() {
|
||||
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_HEIGHT") <= 1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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_HEIGHT"), 258);
|
||||
assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 568);
|
||||
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 320);
|
||||
assert!(
|
||||
UI_SRC.contains("caption_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]
|
||||
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("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("gtk::CheckButton::with_label(\"Webcam\")"));
|
||||
assert!(!UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));"));
|
||||
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(\"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(µphone_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("speaker_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("let audio_gain_scale ="));
|
||||
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("mic_gain_scale.set_draw_value(false);"));
|
||||
assert!(UI_SRC.contains("mic_gain_value.set_width_chars(5);"));
|
||||
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("power_shell.append(&audio_gain_row);"));
|
||||
assert!(UI_SRC.contains("power_shell.append(&mic_gain_row);"));
|
||||
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("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(&mic_gain_row);"));
|
||||
assert_eq!(
|
||||
UI_SRC
|
||||
.matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));")
|
||||
@ -149,14 +178,10 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() {
|
||||
);
|
||||
assert!(
|
||||
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!(
|
||||
source_index("power_shell.append(&audio_gain_row);")
|
||||
< source_index("power_shell.append(&mic_gain_row);")
|
||||
);
|
||||
assert!(
|
||||
source_index("power_shell.append(&mic_gain_row);")
|
||||
source_index("power_shell.append(&power_row);")
|
||||
< source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));")
|
||||
);
|
||||
assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);"));
|
||||
|
||||
@ -38,6 +38,10 @@ fn relay_address_entry_is_locked_while_relay_is_live() {
|
||||
".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(
|
||||
".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);"
|
||||
)
|
||||
);
|
||||
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.mouse_combo.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("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\""));
|
||||
}
|
||||
|
||||
@ -231,4 +231,49 @@ exit 0
|
||||
fs::write(&path, "bad nonce\n").expect("write invalid gain");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
49
testing/tests/client_relayctl_binary_contract.rs
Normal file
49
testing/tests/client_relayctl_binary_contract.rs
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
65
testing/tests/client_relayctl_process_contract.rs
Normal file
65
testing/tests/client_relayctl_process_contract.rs
Normal 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"));
|
||||
}
|
||||
31
testing/tests/client_video_support_include_contract.rs
Normal file
31
testing/tests/client_video_support_include_contract.rs
Normal 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());
|
||||
});
|
||||
}
|
||||
@ -11,12 +11,47 @@
|
||||
mod server_audio_contract;
|
||||
|
||||
mod tests {
|
||||
use super::server_audio_contract::{ClipTap, Voice, ear};
|
||||
use super::server_audio_contract::{ClipTap, Voice, ear, start_pipeline_or_reset};
|
||||
#[cfg(coverage)]
|
||||
use futures_util::StreamExt;
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
use lesavka_common::lesavka::AudioPacket;
|
||||
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]
|
||||
#[serial]
|
||||
fn ear_rejects_malformed_pipeline_device_string() {
|
||||
|
||||
@ -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]
|
||||
#[serial]
|
||||
fn camera_config_output_override_is_case_insensitive() {
|
||||
|
||||
33
testing/tests/server_install_script_contract.rs
Normal file
33
testing/tests/server_install_script_contract.rs
Normal 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}"));
|
||||
}
|
||||
@ -1,16 +1,26 @@
|
||||
//! Integration coverage for server process startup behavior.
|
||||
//!
|
||||
//! Scope: launch the real `lesavka-server` binary and assert startup reaches a
|
||||
//! terminal state quickly in this non-gadget test environment.
|
||||
//! Scope: launch the real `lesavka-server` binary and assert startup stays
|
||||
//! resilient in this non-gadget test environment.
|
||||
//! Targets: `server/src/main.rs`.
|
||||
//! Why: process-level boot behavior should remain deterministic when required
|
||||
//! gadget endpoints are unavailable.
|
||||
//! Why: missing gadget endpoints should not crash the relay; the server keeps
|
||||
//! running and opens HID lazily when the device nodes appear.
|
||||
|
||||
use serial_test::serial;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
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> {
|
||||
let exe = std::env::current_exe().expect("current exe path");
|
||||
let mut dirs = Vec::new();
|
||||
@ -26,18 +36,51 @@ fn candidate_dirs() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn find_binary(name: &str) -> Option<PathBuf> {
|
||||
candidate_dirs()
|
||||
.into_iter()
|
||||
.map(|dir| dir.join(name))
|
||||
.find(|path| path.exists() && path.is_file())
|
||||
cargo_binary(name).or_else(|| {
|
||||
candidate_dirs()
|
||||
.into_iter()
|
||||
.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]
|
||||
#[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 {
|
||||
return;
|
||||
};
|
||||
let log_path = PathBuf::from("/tmp/lesavka-server.log");
|
||||
let _ = fs::remove_file(&log_path);
|
||||
|
||||
let mut child = Command::new(bin)
|
||||
.env("LESAVKA_DISABLE_UVC", "1")
|
||||
@ -45,18 +88,22 @@ fn server_binary_exits_quickly_without_hid_nodes() {
|
||||
.expect("spawn lesavka-server");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().expect("poll child") {
|
||||
assert!(
|
||||
!status.success(),
|
||||
"server unexpectedly succeeded in test environment"
|
||||
);
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
panic!("server did not terminate within startup timeout");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
if let Some(version) = server_package_version() {
|
||||
let _ = wait_for_log(
|
||||
&log_path,
|
||||
&format!("lesavka_server v{version} starting up"),
|
||||
deadline,
|
||||
);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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]
|
||||
#[serial]
|
||||
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_DRIVER", Some("vc4"), || {
|
||||
let sink = build_hdmi_sink(&hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264))
|
||||
.expect("kmssink should build");
|
||||
with_var("LESAVKA_HDMI_RESTORE_CRTC", None::<&str>, || {
|
||||
let sink = build_hdmi_sink(&hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264))
|
||||
.expect("kmssink should build");
|
||||
|
||||
if sink.has_property("force-modesetting", None) {
|
||||
assert!(
|
||||
sink.property::<bool>("force-modesetting"),
|
||||
"kmssink must drive the HDMI mode instead of relying on desktop state"
|
||||
);
|
||||
}
|
||||
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");
|
||||
}
|
||||
if sink.has_property("force-modesetting", None) {
|
||||
assert!(
|
||||
sink.property::<bool>("force-modesetting"),
|
||||
"kmssink must drive the HDMI mode instead of relying on desktop state"
|
||||
);
|
||||
}
|
||||
if sink.has_property("restore-crtc", None) {
|
||||
assert!(
|
||||
!sink.property::<bool>("restore-crtc"),
|
||||
"dedicated HDMI capture output should not restore the console CRTC"
|
||||
);
|
||||
}
|
||||
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]
|
||||
#[serial]
|
||||
fn camera_sink_dispatch_is_stable_for_hdmi_variant() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user