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(1920, 1080, 20), CameraMode::new(1280, 720, 30), CameraMode::new(1280, 720, 20), ] } 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)] #[path = "tests/devices.rs"] mod tests;