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, pub camera_modes: BTreeMap>, pub microphones: Vec, pub speakers: Vec, pub keyboards: Vec, pub mice: Vec, } #[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 { 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()) } pub fn is_empty(&self) -> bool { self.cameras.is_empty() && self.microphones.is_empty() && self.speakers.is_empty() && self.keyboards.is_empty() && self.mice.is_empty() } fn discover_with_camera_override(override_dir: Option) -> 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, mice, } } } fn discover_microphone_devices() -> Vec { let mut set = BTreeSet::new(); for source in discover_pactl_devices("sources") { if !source.ends_with(".monitor") { set.insert(source); } } for source in discover_pipewire_audio_nodes("Audio/Source") { if !source.ends_with(".monitor") { set.insert(source); } } set.into_iter().collect() } fn dedupe_camera_devices(names: impl IntoIterator) -> Vec { let mut by_physical_device: BTreeMap = BTreeMap::new(); for name in names { let key = camera_physical_key(&name); match by_physical_device.get(&key) { Some(existing) if camera_device_rank(existing) <= camera_device_rank(&name) => {} _ => { by_physical_device.insert(key, name); } } } by_physical_device.into_values().collect() } fn camera_physical_key(name: &str) -> String { let trimmed = name.trim(); trimmed .strip_prefix("usb-") .unwrap_or(trimmed) .split("-video-index") .next() .unwrap_or(trimmed) .to_ascii_lowercase() } fn camera_device_rank(name: &str) -> u8 { if name.contains("-video-index0") { 0 } else if name.contains("-video-index") { 1 } else { 2 } } fn discover_speaker_devices() -> Vec { let mut set = BTreeSet::new(); for sink in discover_pactl_devices("sinks") { set.insert(sink); } for sink in discover_pipewire_audio_nodes("Audio/Sink") { set.insert(sink); } set.into_iter().collect() } fn discover_camera_devices(override_dir: Option) -> Vec { let dir = override_dir.unwrap_or_else(|| "/dev/v4l/by-id".to_string()); let Ok(iter) = std::fs::read_dir(dir) else { return Vec::new(); }; let mut set = BTreeSet::new(); for entry in iter.flatten() { let path = entry.path(); if let Some(name) = path.file_name() { set.insert(name.to_string_lossy().to_string()); } } dedupe_camera_devices(set) } fn discover_camera_modes(cameras: &[String]) -> BTreeMap> { 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 { 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 { vec![ CameraMode::new(1920, 1080, 30), CameraMode::new(1280, 720, 30), ] } pub fn parse_supported_camera_modes(stdout: &str) -> Vec { 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 { let (_, rest) = line.split_once('(')?; let (fps, _) = rest.split_once(" fps")?; let rounded = fps.parse::().ok()?.round() as u32; (rounded > 0).then_some(rounded) } fn discover_pactl_devices(kind: &str) -> Vec { let output = std::process::Command::new("pactl") .args(["list", "short", kind]) .output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } parse_pactl_short(&String::from_utf8_lossy(&output.stdout)) } pub fn parse_pactl_short(stdout: &str) -> Vec { let mut set = BTreeSet::new(); for line in stdout.lines() { let mut cols = line.split_whitespace(); let _id = cols.next(); if let Some(name) = cols.next() { set.insert(name.to_string()); } } set.into_iter().collect() } fn discover_pipewire_audio_nodes(media_class: &str) -> Vec { let output = std::process::Command::new("pw-dump").output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } parse_pipewire_audio_nodes(&output.stdout, media_class) } pub fn parse_pipewire_audio_nodes(stdout: &[u8], media_class: &str) -> Vec { let Ok(Value::Array(objects)) = serde_json::from_slice::(stdout) else { return Vec::new(); }; let mut set = BTreeSet::new(); for object in objects { let Some(props) = object .get("info") .and_then(|info| info.get("props")) .and_then(Value::as_object) else { continue; }; if props.get("media.class").and_then(Value::as_str) != Some(media_class) { continue; } let Some(name) = props .get("node.name") .or_else(|| props.get("node.nick")) .or_else(|| props.get("device.name")) .and_then(Value::as_str) else { continue; }; if !name.trim().is_empty() { set.insert(name.to_string()); } } set.into_iter().collect() } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputDeviceKind { Keyboard, Mouse, } fn discover_input_devices(kind: InputDeviceKind) -> Vec { let Ok(iter) = std::fs::read_dir("/dev/input") else { return Vec::new(); }; let mut set = BTreeSet::new(); for entry in iter.flatten() { let path = entry.path(); if !path .file_name() .is_some_and(|name| name.to_string_lossy().starts_with("event")) { continue; } let Ok(device) = Device::open(&path) else { continue; }; if classify_input_device(&device) == Some(kind) { set.insert(path.to_string_lossy().to_string()); } } set.into_iter().collect() } fn classify_input_device(device: &Device) -> Option { let events = device.supported_events(); if events.contains(EventType::KEY) && device .supported_keys() .is_some_and(|keys| keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER)) { return Some(InputDeviceKind::Keyboard); } if events.contains(EventType::RELATIVE) && let (Some(rel), Some(keys)) = (device.supported_relative_axes(), device.supported_keys()) && rel.contains(RelativeAxisCode::REL_X) && rel.contains(RelativeAxisCode::REL_Y) && (keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT)) { return Some(InputDeviceKind::Mouse); } if events.contains(EventType::ABSOLUTE) && let (Some(abs), Some(keys)) = (device.supported_absolute_axes(), device.supported_keys()) && ((abs.contains(AbsoluteAxisCode::ABS_X) && abs.contains(AbsoluteAxisCode::ABS_Y)) || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X) && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y))) && (keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT)) { return Some(InputDeviceKind::Mouse); } None } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn mk_temp_dir(prefix: &str) -> PathBuf { let mut path = std::env::temp_dir(); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); path.push(format!("lesavka-{prefix}-{}-{nanos}", std::process::id())); std::fs::create_dir_all(&path).expect("create temp dir"); path } #[test] fn parse_pactl_short_collects_second_column_and_sorts_unique() { let input = "0 alsa_input.usb.test module-x\n1 alsa_input.usb.test module-x\n2 alsa_input.pci module-y\n"; let parsed = parse_pactl_short(input); assert_eq!( parsed, vec![ "alsa_input.pci".to_string(), "alsa_input.usb.test".to_string(), ] ); } #[test] fn parse_pactl_short_ignores_blank_or_short_lines() { let input = "\nweird\n3\n4 sink.a\tmodule\n"; let parsed = parse_pactl_short(input); assert_eq!(parsed, vec!["sink.a".to_string()]); } #[test] fn camera_discovery_reads_entry_names_from_override_dir() { let tmp = mk_temp_dir("camera-discovery"); std::fs::write(tmp.join("usb-cam-a"), "").expect("write"); std::fs::write(tmp.join("usb-cam-b"), "").expect("write"); let devices = discover_camera_devices(Some(tmp.to_string_lossy().to_string())); assert_eq!( devices, vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()] ); let _ = std::fs::remove_dir_all(tmp); } #[test] fn camera_discovery_prefers_one_endpoint_per_physical_webcam() { let devices = dedupe_camera_devices([ "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index1".to_string(), "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(), "usb-Logitech_C920-video-index0".to_string(), ]); assert_eq!( devices, vec![ "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(), "usb-Logitech_C920-video-index0".to_string(), ] ); } #[test] fn camera_discovery_returns_empty_when_directory_missing() { let devices = discover_camera_devices(Some("/tmp/does-not-exist-lesavka".to_string())); assert!(devices.is_empty()); } #[test] fn camera_discovery_default_path_is_stable_without_overrides() { 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"); std::fs::write(tmp.join("cam"), "").expect("write"); let catalog = DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string())); assert_eq!(catalog.cameras, vec!["cam".to_string()]); let _ = std::fs::remove_dir_all(tmp); } #[test] fn discover_is_stable_with_process_environment_defaults() { let _ = DeviceCatalog::discover(); } #[test] fn catalog_empty_reflects_collections() { let mut catalog = DeviceCatalog::default(); assert!(catalog.is_empty()); catalog.speakers.push("sink-1".to_string()); assert!(!catalog.is_empty()); } #[test] fn parse_pipewire_audio_nodes_collects_named_audio_nodes() { let sample = br#" [ { "info": { "props": { "media.class": "Audio/Source", "node.name": "alsa_input.usb-TestMic-00.mono-fallback" } } }, { "info": { "props": { "media.class": "Audio/Source", "node.name": "bluez_input.80:C3:BA:76:26:AB" } } }, { "info": { "props": { "media.class": "Audio/Sink", "node.name": "alsa_output.pci-0000_00_1f.3.analog-stereo" } } } ] "#; assert_eq!( parse_pipewire_audio_nodes(sample, "Audio/Source"), vec![ "alsa_input.usb-TestMic-00.mono-fallback".to_string(), "bluez_input.80:C3:BA:76:26:AB".to_string(), ] ); } #[test] fn parse_pipewire_audio_nodes_ignores_invalid_payloads() { assert!(parse_pipewire_audio_nodes(b"not json", "Audio/Source").is_empty()); } }