use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const BIND_POLL_SECONDS: f64 = 20.0;
pub const BIND_POLL_INTERVAL_SECONDS: f64 = 0.5;
pub const MIN_SNAPSHOT_BYTES: usize = 100;
#[derive(Debug, Clone)]
pub struct CameraProbePaths {
pub dev_dir: PathBuf,
pub sys_video4linux_dir: PathBuf,
pub camera_conf: PathBuf,
pub camera_probation: PathBuf,
}
impl Default for CameraProbePaths {
fn default() -> Self {
Self {
dev_dir: PathBuf::from("/dev"),
sys_video4linux_dir: PathBuf::from("/sys/class/video4linux"),
camera_conf: PathBuf::from("/etc/ados/camera.conf"),
camera_probation: PathBuf::from("/etc/ados/camera.probation"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CameraProbeOutcome {
NoProbation,
Confirmed { evidence: String },
Reverted { boot_config_restored: bool },
}
pub fn parse_marker(path: &Path) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let Ok(text) = std::fs::read_to_string(path) else {
return out;
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
out.insert(k.trim().to_string(), v.trim().to_string());
}
}
out
}
fn is_video_entry(name: &str) -> bool {
name.strip_prefix("video")
.map(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
.unwrap_or(false)
}
fn sensor_tokens(sensor: &str) -> Vec<String> {
sensor
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| w.len() >= 3)
.map(|w| w.to_ascii_lowercase())
.collect()
}
fn sensor_nodes_by_name(sys_video4linux_dir: &Path, sensor: &str) -> Vec<String> {
let tokens = sensor_tokens(sensor);
if tokens.is_empty() {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(sys_video4linux_dir) else {
return Vec::new();
};
let mut matched: Vec<String> = Vec::new();
for entry in entries.flatten() {
let node = entry.file_name().to_string_lossy().to_string();
if !is_video_entry(&node) {
continue;
}
let Ok(reported) = std::fs::read_to_string(entry.path().join("name")) else {
continue;
};
let reported = reported.trim().to_ascii_lowercase();
if tokens.iter().any(|t| reported.contains(t.as_str())) {
matched.push(node);
}
}
matched.sort();
matched
}
pub fn sensor_node_by_name(sys_video4linux_dir: &Path, sensor: &str) -> Option<String> {
sensor_nodes_by_name(sys_video4linux_dir, sensor)
.into_iter()
.next()
}
pub fn camera_present(
dev_dir: &Path,
sys_video4linux_dir: &Path,
expected_node: &str,
sensor: &str,
) -> Option<String> {
let matched = sensor_nodes_by_name(sys_video4linux_dir, sensor);
let expected = expected_node.trim();
let base = Path::new(expected)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
if !base.is_empty() && matched.contains(&base) && dev_dir.join(&base).exists() {
return Some(expected.to_string());
}
matched
.into_iter()
.next()
.map(|node| format!("{node} ({sensor})"))
}
fn confirmed_conf(existing: &BTreeMap<String, String>, evidence: &str) -> String {
let mut body = String::from(
"# Written by the camera probe after the CSI sensor was CONFIRMED present\n\
# on the boot following the overlay apply. The overlay is retained.\n",
);
let mut merged = existing.clone();
merged.insert("camera_present".to_string(), "true".to_string());
merged.insert("overlay_state".to_string(), "confirmed".to_string());
merged.insert("camera_evidence".to_string(), evidence.to_string());
for (k, v) in &merged {
body.push_str(k);
body.push('=');
body.push_str(v);
body.push('\n');
}
body
}
pub fn confirm(paths: &CameraProbePaths, evidence: &str) -> std::io::Result<()> {
if let Some(parent) = paths.camera_conf.parent() {
std::fs::create_dir_all(parent)?;
}
let existing = parse_marker(&paths.camera_conf);
std::fs::write(
&paths.camera_conf,
confirmed_conf(&existing, evidence).as_bytes(),
)?;
let _ = std::fs::remove_file(&paths.camera_probation);
Ok(())
}
pub fn revert(
paths: &CameraProbePaths,
marker: &BTreeMap<String, String>,
) -> std::io::Result<bool> {
let snapshot = marker.get("snapshot").map(|s| s.trim()).unwrap_or("");
let boot_config = marker.get("boot_config").map(|s| s.trim()).unwrap_or("");
let mut restored = false;
if !snapshot.is_empty() && !boot_config.is_empty() {
let snap_path = Path::new(snapshot);
let boot_path = Path::new(boot_config);
if snap_path.is_file() {
let data = std::fs::read(snap_path)?;
if data.len() >= MIN_SNAPSHOT_BYTES {
ados_protocol::sidecar::write_durable(boot_path, &data)?;
restored = true;
} else {
tracing::warn!(
bytes = data.len(),
snapshot = snapshot,
"camera probe snapshot too small to restore"
);
}
}
}
if let Some(parent) = paths.camera_conf.parent() {
std::fs::create_dir_all(parent)?;
}
let camera_id = marker
.get("camera_id")
.map(|s| s.as_str())
.unwrap_or("none");
let board = marker.get("board").map(|s| s.as_str()).unwrap_or("");
let body = format!(
"# Written by the camera probe after an unconfirmed CSI overlay failed to\n\
# bind a sensor. The boot config was restored from the install-time\n\
# snapshot and the camera marked absent.\n\
camera_id={camera_id}\n\
board={board}\n\
camera_present=false\n\
overlay_state=reverted\n"
);
std::fs::write(&paths.camera_conf, body)?;
let _ = std::fs::remove_file(&paths.camera_probation);
Ok(restored)
}
pub fn apply_decision(
paths: &CameraProbePaths,
marker: &BTreeMap<String, String>,
present: Option<String>,
) -> std::io::Result<CameraProbeOutcome> {
match present {
Some(evidence) => {
confirm(paths, &evidence)?;
Ok(CameraProbeOutcome::Confirmed { evidence })
}
None => {
let restored = revert(paths, marker)?;
Ok(CameraProbeOutcome::Reverted {
boot_config_restored: restored,
})
}
}
}
pub fn run(paths: &CameraProbePaths) -> std::io::Result<CameraProbeOutcome> {
run_with_window(paths, std::time::Duration::from_secs_f64(BIND_POLL_SECONDS))
}
pub fn run_with_window(
paths: &CameraProbePaths,
window: std::time::Duration,
) -> std::io::Result<CameraProbeOutcome> {
if !paths.camera_probation.exists() {
tracing::info!("camera probe no-op: no probation marker");
return Ok(CameraProbeOutcome::NoProbation);
}
let marker = parse_marker(&paths.camera_probation);
let expected_node = marker
.get("expected_node")
.map(|s| s.as_str())
.unwrap_or("")
.to_string();
let sensor = marker
.get("sensor")
.map(|s| s.as_str())
.unwrap_or("")
.to_string();
let present = wait_for_sensor(paths, &expected_node, &sensor, window);
let outcome = apply_decision(paths, &marker, present)?;
match &outcome {
CameraProbeOutcome::Confirmed { evidence } => tracing::info!(
evidence = %evidence,
camera = marker.get("camera_id").map(|s| s.as_str()).unwrap_or(""),
"camera overlay confirmed"
),
CameraProbeOutcome::Reverted {
boot_config_restored,
} => tracing::warn!(
boot_config_restored,
camera = marker.get("camera_id").map(|s| s.as_str()).unwrap_or(""),
overlay = marker.get("overlay").map(|s| s.as_str()).unwrap_or(""),
"camera overlay auto-reverted: no sensor bound"
),
CameraProbeOutcome::NoProbation => {}
}
Ok(outcome)
}
fn wait_for_sensor(
paths: &CameraProbePaths,
expected_node: &str,
sensor: &str,
window: std::time::Duration,
) -> Option<String> {
let deadline = std::time::Instant::now() + window;
loop {
if let Some(found) = camera_present(
&paths.dev_dir,
&paths.sys_video4linux_dir,
expected_node,
sensor,
) {
return Some(found);
}
if std::time::Instant::now() >= deadline {
return None;
}
std::thread::sleep(std::time::Duration::from_secs_f64(
BIND_POLL_INTERVAL_SECONDS,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Tree {
_dir: tempfile::TempDir,
paths: CameraProbePaths,
boot_config: PathBuf,
snapshot: PathBuf,
}
const GOOD_BOOT_CONFIG: &str = "# extlinux.conf (known good)\nlabel ados\n kernel /Image\n fdt /dtb\n append root=/dev/mmcblk0p2 rootwait console=ttyS0,115200\n";
fn tree(snapshot_body: &str) -> Tree {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let dev = root.join("dev");
let sys = root.join("sys/class/video4linux");
let etc = root.join("etc/ados");
let boot = root.join("boot");
for d in [&dev, &sys, &etc, &boot] {
std::fs::create_dir_all(d).unwrap();
}
let boot_config = boot.join("extlinux.conf");
let snapshot = boot.join("extlinux.conf.ados-camera.bak");
std::fs::write(
&boot_config,
format!("{GOOD_BOOT_CONFIG} fdtoverlays /dtbo/radxa-camera-13m-214.dtbo\n"),
)
.unwrap();
std::fs::write(&snapshot, snapshot_body).unwrap();
let paths = CameraProbePaths {
dev_dir: dev,
sys_video4linux_dir: sys,
camera_conf: etc.join("camera.conf"),
camera_probation: etc.join("camera.probation"),
};
std::fs::write(
&paths.camera_conf,
"camera_id=csi-imx214\nboard=cubie-a7z\nsensor=Sony IMX214\ncamera_present=true\noverlay_ref=radxa-camera-13m-214\nvendor_isp=true\ndefault_mode=1920x1080@30\noverlay_state=pending_reboot\n",
)
.unwrap();
std::fs::write(
&paths.camera_probation,
format!(
"# install-camera-overlay.sh probation\ncamera_id=csi-imx214\nboard=cubie-a7z\noverlay=radxa-camera-13m-214\nexpected_node=/dev/video0\nsensor=Sony IMX214\nsnapshot={}\nboot_config={}\n",
snapshot.display(),
boot_config.display()
),
)
.unwrap();
Tree {
_dir: dir,
paths,
boot_config,
snapshot,
}
}
#[test]
fn no_probation_marker_is_a_clean_no_op() {
let t = tree(GOOD_BOOT_CONFIG);
std::fs::remove_file(&t.paths.camera_probation).unwrap();
let before = std::fs::read_to_string(&t.paths.camera_conf).unwrap();
assert_eq!(run(&t.paths).unwrap(), CameraProbeOutcome::NoProbation);
assert_eq!(
std::fs::read_to_string(&t.paths.camera_conf).unwrap(),
before
);
assert!(std::fs::read_to_string(&t.boot_config)
.unwrap()
.contains("fdtoverlays"));
}
#[test]
fn a_bound_sensor_confirms_and_keeps_the_overlay() {
let t = tree(GOOD_BOOT_CONFIG);
std::fs::write(t.paths.dev_dir.join("video0"), b"").unwrap();
let n = t.paths.sys_video4linux_dir.join("video0");
std::fs::create_dir_all(&n).unwrap();
std::fs::write(n.join("name"), b"sunxi-vin: imx214_mipi\n").unwrap();
let marker = parse_marker(&t.paths.camera_probation);
let present = camera_present(
&t.paths.dev_dir,
&t.paths.sys_video4linux_dir,
"/dev/video0",
"Sony IMX214",
);
assert_eq!(present.as_deref(), Some("/dev/video0"));
let outcome = apply_decision(&t.paths, &marker, present).unwrap();
assert_eq!(
outcome,
CameraProbeOutcome::Confirmed {
evidence: "/dev/video0".to_string()
}
);
assert!(std::fs::read_to_string(&t.boot_config)
.unwrap()
.contains("fdtoverlays"));
assert!(!t.paths.camera_probation.exists());
let conf = parse_marker(&t.paths.camera_conf);
assert_eq!(
conf.get("overlay_state").map(String::as_str),
Some("confirmed")
);
assert_eq!(conf.get("camera_present").map(String::as_str), Some("true"));
assert_eq!(
conf.get("default_mode").map(String::as_str),
Some("1920x1080@30"),
"the confirm path must merge, not replace"
);
assert_eq!(conf.get("vendor_isp").map(String::as_str), Some("true"));
}
#[test]
fn a_sensor_on_an_unexpected_node_still_confirms_by_its_reported_name() {
let t = tree(GOOD_BOOT_CONFIG);
let n = t.paths.sys_video4linux_dir.join("video3");
std::fs::create_dir_all(&n).unwrap();
std::fs::write(n.join("name"), b"sunxi-vin: imx214_mipi\n").unwrap();
let present = camera_present(
&t.paths.dev_dir,
&t.paths.sys_video4linux_dir,
"/dev/video0",
"Sony IMX214",
);
assert_eq!(present.as_deref(), Some("video3 (Sony IMX214)"));
}
#[test]
fn an_unrelated_webcam_is_not_proof_of_this_camera() {
let t = tree(GOOD_BOOT_CONFIG);
let n = t.paths.sys_video4linux_dir.join("video1");
std::fs::create_dir_all(&n).unwrap();
std::fs::write(n.join("name"), b"HD Pro Webcam C920\n").unwrap();
std::fs::write(t.paths.dev_dir.join("video1"), b"").unwrap();
assert!(camera_present(
&t.paths.dev_dir,
&t.paths.sys_video4linux_dir,
"/dev/video0",
"Sony IMX214",
)
.is_none());
}
#[test]
fn an_unrelated_device_on_the_expected_node_is_not_proof_of_this_camera() {
let t = tree(GOOD_BOOT_CONFIG);
std::fs::write(t.paths.dev_dir.join("video0"), b"").unwrap();
let n = t.paths.sys_video4linux_dir.join("video0");
std::fs::create_dir_all(&n).unwrap();
std::fs::write(n.join("name"), b"cedrus\n").unwrap();
assert!(camera_present(
&t.paths.dev_dir,
&t.paths.sys_video4linux_dir,
"/dev/video0",
"Sony IMX214",
)
.is_none());
}
#[test]
fn an_absent_sensor_auto_reverts_the_boot_config() {
let t = tree(GOOD_BOOT_CONFIG);
let marker = parse_marker(&t.paths.camera_probation);
let outcome = apply_decision(&t.paths, &marker, None).unwrap();
assert_eq!(
outcome,
CameraProbeOutcome::Reverted {
boot_config_restored: true
}
);
assert_eq!(
std::fs::read_to_string(&t.boot_config).unwrap(),
GOOD_BOOT_CONFIG,
"the pre-overlay boot config must be restored verbatim"
);
assert!(!t.paths.camera_probation.exists());
let conf = parse_marker(&t.paths.camera_conf);
assert_eq!(
conf.get("camera_present").map(String::as_str),
Some("false")
);
assert_eq!(
conf.get("overlay_state").map(String::as_str),
Some("reverted")
);
}
#[test]
fn a_truncated_snapshot_is_never_written_over_a_working_boot_config() {
let t = tree("");
let marker = parse_marker(&t.paths.camera_probation);
let live_before = std::fs::read_to_string(&t.boot_config).unwrap();
let outcome = apply_decision(&t.paths, &marker, None).unwrap();
assert_eq!(
outcome,
CameraProbeOutcome::Reverted {
boot_config_restored: false
}
);
assert_eq!(
std::fs::read_to_string(&t.boot_config).unwrap(),
live_before,
"a truncated snapshot must not be restored"
);
assert!(std::fs::metadata(&t.snapshot).unwrap().len() < MIN_SNAPSHOT_BYTES as u64);
}
#[test]
fn a_marker_with_no_snapshot_reverts_state_without_touching_boot() {
let t = tree(GOOD_BOOT_CONFIG);
std::fs::write(
&t.paths.camera_probation,
"camera_id=csi-imx214\nboard=cubie-a7z\nexpected_node=/dev/video0\nsensor=Sony IMX214\n",
)
.unwrap();
let marker = parse_marker(&t.paths.camera_probation);
let outcome = apply_decision(&t.paths, &marker, None).unwrap();
assert_eq!(
outcome,
CameraProbeOutcome::Reverted {
boot_config_restored: false
}
);
assert!(!t.paths.camera_probation.exists());
}
#[test]
fn sensor_tokens_drop_noise_words() {
assert_eq!(sensor_tokens("Sony IMX214"), vec!["sony", "imx214"]);
assert_eq!(sensor_tokens("OV5647"), vec!["ov5647"]);
assert!(sensor_tokens("a b").is_empty());
assert!(sensor_tokens("").is_empty());
}
#[test]
fn an_empty_sensor_declaration_never_matches_by_name() {
let t = tree(GOOD_BOOT_CONFIG);
let n = t.paths.sys_video4linux_dir.join("video0");
std::fs::create_dir_all(&n).unwrap();
std::fs::write(n.join("name"), b"anything at all\n").unwrap();
assert!(sensor_node_by_name(&t.paths.sys_video4linux_dir, "").is_none());
}
#[test]
fn run_reverts_end_to_end_when_the_bind_window_expires() {
let t = tree(GOOD_BOOT_CONFIG);
let outcome = run_with_window(&t.paths, std::time::Duration::ZERO).unwrap();
assert_eq!(
outcome,
CameraProbeOutcome::Reverted {
boot_config_restored: true
}
);
assert_eq!(
std::fs::read_to_string(&t.boot_config).unwrap(),
GOOD_BOOT_CONFIG
);
assert!(!t.paths.camera_probation.exists());
assert_eq!(
run_with_window(&t.paths, std::time::Duration::ZERO).unwrap(),
CameraProbeOutcome::NoProbation
);
}
#[test]
fn run_confirms_end_to_end_when_the_sensor_is_bound() {
let t = tree(GOOD_BOOT_CONFIG);
std::fs::write(t.paths.dev_dir.join("video0"), b"").unwrap();
let n = t.paths.sys_video4linux_dir.join("video0");
std::fs::create_dir_all(&n).unwrap();
std::fs::write(n.join("name"), b"sunxi-vin: imx214_mipi\n").unwrap();
let outcome = run_with_window(&t.paths, std::time::Duration::ZERO).unwrap();
assert_eq!(
outcome,
CameraProbeOutcome::Confirmed {
evidence: "/dev/video0".to_string()
}
);
assert!(std::fs::read_to_string(&t.boot_config)
.unwrap()
.contains("fdtoverlays"));
assert!(!t.paths.camera_probation.exists());
}
}