probe: harden mirrored camera selection

This commit is contained in:
Brad Stein 2026-05-01 11:01:50 -03:00
parent 08d424b023
commit 0d9121f921
10 changed files with 55 additions and 14 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.16.14" version = "0.16.15"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.16.14" version = "0.16.15"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.16.14" version = "0.16.15"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.16.14" version = "0.16.15"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -22,7 +22,8 @@ impl CameraCapture {
true, true,
), ),
Some(fragment) => { Some(fragment) => {
let dev = Self::find_device(fragment).unwrap_or_else(|| "/dev/video0".into()); let dev = Self::find_device(fragment)
.with_context(|| format!("requested camera '{fragment}' was not found"))?;
(format!("v4l2src device={dev} do-timestamp=true"), dev, true) (format!("v4l2src device={dev} do-timestamp=true"), dev, true)
} }
None => { None => {

View File

@ -2,13 +2,13 @@ impl CameraCapture {
/// Fuzzymatch devices under `/dev/v4l/by-id`, preferring capture nodes /// Fuzzymatch devices under `/dev/v4l/by-id`, preferring capture nodes
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn find_device(substr: &str) -> Option<String> { fn find_device(substr: &str) -> Option<String> {
let wanted = substr.to_ascii_lowercase(); let wanted = normalize_device_fragment(substr);
let mut matches: Vec<_> = std::fs::read_dir("/dev/v4l/by-id") let mut matches: Vec<_> = std::fs::read_dir("/dev/v4l/by-id")
.ok()? .ok()?
.flatten() .flatten()
.filter_map(|e| { .filter_map(|e| {
let p = e.path(); let p = e.path();
let name = p.file_name()?.to_string_lossy().to_ascii_lowercase(); let name = normalize_device_fragment(&p.file_name()?.to_string_lossy());
if name.contains(&wanted) { if name.contains(&wanted) {
Some(p) Some(p)
} else { } else {
@ -33,7 +33,7 @@ impl CameraCapture {
#[cfg(coverage)] #[cfg(coverage)]
fn find_device(substr: &str) -> Option<String> { fn find_device(substr: &str) -> Option<String> {
let wanted = substr.to_ascii_lowercase(); let wanted = normalize_device_fragment(substr);
let by_id_dir = let by_id_dir =
std::env::var("LESAVKA_CAM_BY_ID_DIR").unwrap_or_else(|_| "/dev/v4l/by-id".to_string()); std::env::var("LESAVKA_CAM_BY_ID_DIR").unwrap_or_else(|_| "/dev/v4l/by-id".to_string());
let dev_root = std::env::var("LESAVKA_CAM_DEV_ROOT").unwrap_or_else(|_| "/dev".to_string()); let dev_root = std::env::var("LESAVKA_CAM_DEV_ROOT").unwrap_or_else(|_| "/dev".to_string());
@ -42,7 +42,7 @@ impl CameraCapture {
.flatten() .flatten()
.filter_map(|e| { .filter_map(|e| {
let p = e.path(); let p = e.path();
let name = p.file_name()?.to_string_lossy().to_ascii_lowercase(); let name = normalize_device_fragment(&p.file_name()?.to_string_lossy());
if name.contains(&wanted) { if name.contains(&wanted) {
Some(p) Some(p)
} else { } else {
@ -100,3 +100,11 @@ impl CameraCapture {
} }
} }
fn normalize_device_fragment(value: &str) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.16.14" version = "0.16.15"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -293,8 +293,9 @@ class ProbeHandler(http.server.BaseHTTPRequestHandler):
pass pass
class ReusableTcpServer(socketserver.TCPServer): class ReusableTcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
allow_reuse_address = True allow_reuse_address = True
daemon_threads = True
def main() -> None: def main() -> None:

View File

@ -241,8 +241,9 @@ class StimulusHandler(http.server.BaseHTTPRequestHandler):
pass pass
class ReusableTcpServer(socketserver.TCPServer): class ReusableTcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
allow_reuse_address = True allow_reuse_address = True
daemon_threads = True
def main() -> None: def main() -> None:

View File

@ -128,7 +128,12 @@ while true; do
done done
echo "==> triggering browser recording" echo "==> triggering browser recording"
ssh ${SSH_OPTS} "${TETHYS_HOST}" "curl -fsS -X POST http://127.0.0.1:${BROWSER_PORT}/start >/dev/null" if ! ssh ${SSH_OPTS} "${TETHYS_HOST}" "curl --max-time 10 -fsS -X POST http://127.0.0.1:${BROWSER_PORT}/start >/dev/null"; then
status_json=$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_STATUS}' && cat '${REMOTE_STATUS}'" || true)
echo "browser consumer start request failed or timed out" >&2
[[ -n "${status_json:-}" ]] && echo "last status: ${status_json}" >&2
exit 1
fi
sleep 1 sleep 1

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.16.14" version = "0.16.15"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -128,6 +128,31 @@ mod camera_include_contract {
)); ));
} }
#[test]
#[cfg(coverage)]
#[serial]
fn find_device_normalizes_spaces_underscores_and_case() {
let dir = tempdir().expect("tempdir");
let by_id = dir.path().join("by-id");
std::fs::create_dir_all(&by_id).expect("create by-id");
symlink(
"../video42",
by_id.join("usb-046d_Logitech_BRIO-video-index0"),
)
.expect("create camera symlink");
with_var(
"LESAVKA_CAM_BY_ID_DIR",
Some(by_id.to_string_lossy().to_string()),
|| {
with_var("LESAVKA_CAM_DEV_ROOT", Some("/dev".to_string()), || {
let found = CameraCapture::find_device("Logitech BRIO");
assert_eq!(found.as_deref(), Some("/dev/video42"));
});
},
);
}
#[test] #[test]
#[serial] #[serial]
fn find_device_honors_override_roots_and_handles_non_capture_targets() { fn find_device_honors_override_roots_and_handles_non_capture_targets() {