lesavka/client/src/launcher/devices.rs

155 lines
4.7 KiB
Rust

use std::collections::BTreeSet;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeviceCatalog {
pub cameras: Vec<String>,
pub microphones: Vec<String>,
pub speakers: Vec<String>,
}
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()
}
fn discover_with_camera_override(override_dir: Option<String>) -> Self {
let cameras = discover_camera_devices(override_dir);
let microphones = discover_pactl_devices("sources");
let speakers = discover_pactl_devices("sinks");
Self {
cameras,
microphones,
speakers,
}
}
}
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());
}
}
set.into_iter().collect()
}
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()
}
#[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_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 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());
}
}