lesavka/client/src/launcher/devices.rs

565 lines
17 KiB
Rust
Raw Normal View History

2026-04-22 00:56:03 -03:00
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())
}
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<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,
mice,
}
}
}
fn discover_microphone_devices() -> Vec<String> {
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()
}
2026-04-22 00:56:03 -03:00
fn dedupe_camera_devices(names: impl IntoIterator<Item = String>) -> Vec<String> {
let mut by_physical_device: BTreeMap<String, String> = 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<String> {
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<String>) -> Vec<String> {
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());
}
}
2026-04-22 00:56:03 -03:00
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])
.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<String> {
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<String> {
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<String> {
let Ok(Value::Array(objects)) = serde_json::from_slice::<Value>(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<String> {
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<InputDeviceKind> {
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);
}
2026-04-22 00:56:03 -03:00
#[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());
}
}