A facial recognition login service for Linux.
2

Configure Feed

Select the types of activity you want to include in your feed.

core: Add per-device IR calibration model and store

Calibration records how a given IR camera delivers illuminated frames so
capture can be optimized per device. It is purely an optimization and
capability layer: `capture_ir_face` already works without it (capture a
burst, keep the brightest), so a missing/unmatched/corrupt record just
falls back to the safe mode-agnostic burst — never an error. That's why
this store, unlike the enrollment store, needs no HMAC.

# Device identity (card + bus)

Records are keyed by `CameraIdentity { card, bus }`. Neither field alone
is a usable key: `/dev/videoN` renumbers across reboots, the card name
isn't unique across identical models, and the bus (port topology)
changes when a device is replugged elsewhere. `resolve_calibration`
matches leniently, returning a typed `CalibrationMatch`:

- `Exact` — card + bus both match.
- `MovedPort` — card matches, bus differs, and exactly one connected
camera has that card name, so the device just moved ports: safe to
adopt and refresh the stored bus.
- `Ambiguous` — card matches but several connected cameras share that
name, so we can't guess which: the caller should ask the user.
- `None` — no record; fall back to mode-agnostic capture.

This mirrors the decisions-in-core / rendering-in-CLI shape of
`camera::select::IrCameraResolution`.

# Store

`CalibrationStore` persists all records in one small CBOR file (the set
is tiny and the matching API wants it whole). Writes are atomic
(temp-file + fsync + rename), matching the enrollment store's crash
discipline. `upsert` replaces by exact identity; `replace_by_card` drops
a stale-bus record when a device moved ports.

# CameraInfo.bus

`CameraInfo` gains a `bus: Option<String>`, filled by `enumerate()` from
the V4L2 bus info and left `None` for mock/synthetic sources and the
by-path `open()` path. This is the input `CameraIdentity::from_info`
reads. Calibration shares the `v4l2` feature gate since it depends on the
backend's `IrCaptureMode`.

Isaac Corbrey (May 29, 2026, 7:34 AM EDT) 0f1ec2a8 0179dc5c

+578
+2
crates/cli/src/commands/cameras.rs
··· 92 92 name: name.into(), 93 93 path: Some(PathBuf::from(path)), 94 94 kind: CameraKind::Rgb, 95 + bus: None, 95 96 } 96 97 } 97 98 ··· 100 101 name: name.into(), 101 102 path: Some(PathBuf::from(path)), 102 103 kind: CameraKind::Ir, 104 + bus: None, 103 105 } 104 106 } 105 107
+16
crates/core/src/camera.rs
··· 19 19 #[cfg(feature = "v4l2")] 20 20 pub mod v4l2; 21 21 22 + // Calibration references IR-mode types from the v4l2 backend, and is 23 + // only meaningful when camera capture is available, so it shares the 24 + // `v4l2` feature gate. 25 + #[cfg(feature = "v4l2")] 26 + pub mod calibration; 27 + 22 28 /// What kind of camera produced (or is expected to produce) a frame. 23 29 /// 24 30 /// IR and RGB cameras differ enough — single-channel vs three-channel, very ··· 80 86 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 81 87 pub struct CameraInfo { 82 88 /// Human-readable name, surfaced in CLI listings and log messages. 89 + /// For V4L2 devices this is the driver-reported card name. 83 90 pub name: String, 84 91 /// Device path (e.g. `/dev/video0`) if backed by a real device. 85 92 pub path: Option<PathBuf>, 86 93 /// What kind of camera this is. 87 94 pub kind: CameraKind, 95 + /// V4L2 bus info (e.g. `usb-0000:00:14.0-5`), identifying the 96 + /// physical port/topology the device is attached to. `None` for 97 + /// mock/synthetic sources and for devices where it couldn't be read. 98 + /// 99 + /// Together with [`Self::name`] this forms the device identity used 100 + /// to key per-device calibration: the card name survives `/dev/videoN` 101 + /// renumbering, while the bus distinguishes two identical-model 102 + /// cameras on different ports. See `camera::calibration`. 103 + pub bus: Option<String>, 88 104 } 89 105 90 106 /// A single captured frame.
+245
crates/core/src/camera/calibration/mod.rs
··· 1 + //! Per-device IR camera calibration: data model and identity matching. 2 + //! 3 + //! IR cameras with an active illuminator vary in how they deliver 4 + //! illuminated frames (see [`IrCaptureMode`]). The capture layer can 5 + //! always pick a usable frame *without* knowing the mode (capture a 6 + //! burst, keep the brightest — see [`select_brightest`]), so calibration 7 + //! is purely an **optimization and capability** layer, never required 8 + //! for correctness: 9 + //! 10 + //! - Knowing a device's mode lets capture size its burst minimally (one 11 + //! frame for [`IrCaptureMode::Constant`], `period` for 12 + //! [`IrCaptureMode::Strobed`]) instead of always over-capturing. 13 + //! - A `Strobed` classification additionally records that the sensor 14 + //! hands us matched lit/non-lit frame pairs, the prerequisite for any 15 + //! future ambient-subtraction / liveness work. 16 + //! 17 + //! Because capture degrades gracefully without it, calibration is 18 + //! best-effort: a missing or unmatched record just means "fall back to a 19 + //! safe mode-agnostic burst", never an error. 20 + //! 21 + //! # Device identity 22 + //! 23 + //! A calibration record is keyed by [`CameraIdentity`] — the V4L2 card 24 + //! name plus bus info. The two together resolve the awkward reality that 25 + //! neither alone is a stable key: 26 + //! 27 + //! - `/dev/videoN` renumbers across reboots and replugging, so the path 28 + //! is useless as a key. 29 + //! - The card name is stable but not unique: two identical-model cameras 30 + //! report the same name. 31 + //! - The bus info pins the physical port, distinguishing duplicates, but 32 + //! *changes* when the device is moved to another port. 33 + //! 34 + //! [`resolve_calibration`] therefore matches leniently (see 35 + //! [`CalibrationMatch`]): an exact card+bus match wins; failing that, a 36 + //! card-name match whose bus moved is adopted *if it's unambiguous* 37 + //! (exactly one connected camera has that card name), and flagged as 38 + //! ambiguous if several do so the caller can ask the user. 39 + 40 + use serde::{Deserialize, Serialize}; 41 + 42 + use crate::camera::v4l2::IrCaptureMode; 43 + use crate::camera::CameraInfo; 44 + 45 + pub mod store; 46 + 47 + pub use store::{CalibrationStore, CalibrationStoreError}; 48 + 49 + /// Stable identity of a physical camera, used to key calibration. 50 + /// 51 + /// `card` is the V4L2 card name; `bus` is the V4L2 bus info (port 52 + /// topology) when known. See the module docs for why both are needed. 53 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 54 + pub struct CameraIdentity { 55 + /// V4L2 card name (e.g. `Integrated IR Camera`). 56 + pub card: String, 57 + /// V4L2 bus info (e.g. `usb-0000:00:14.0-5`), if known. 58 + pub bus: Option<String>, 59 + } 60 + 61 + impl CameraIdentity { 62 + /// Build an identity from a [`CameraInfo`]. 63 + pub fn from_info(info: &CameraInfo) -> Self { 64 + Self { 65 + card: info.name.clone(), 66 + bus: info.bus.clone(), 67 + } 68 + } 69 + } 70 + 71 + /// A persisted calibration record for one camera. 72 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 73 + pub struct IrCalibration { 74 + /// Which physical camera this record describes. 75 + pub identity: CameraIdentity, 76 + /// The detected IR emission mode. 77 + pub mode: IrCaptureMode, 78 + /// Unix-epoch seconds when calibration was performed, for staleness 79 + /// reporting. Not used for matching. 80 + #[serde(default)] 81 + pub calibrated_at_unix: u64, 82 + } 83 + 84 + /// Result of resolving a connected camera against a set of stored 85 + /// calibration records. Mirrors the decisions-in-core / 86 + /// rendering-in-CLI shape of `camera::select::IrCameraResolution`. 87 + #[derive(Debug, Clone, PartialEq, Eq)] 88 + pub enum CalibrationMatch { 89 + /// Exact card+bus match — the same device on the same port. 90 + Exact(IrCalibration), 91 + /// Card name matched but the bus differs, and exactly one connected 92 + /// camera carries that card name, so the device almost certainly 93 + /// just moved ports. Safe to adopt (the caller should refresh the 94 + /// stored bus). Carries the record and the new bus observed. 95 + MovedPort { 96 + /// The stored record (with its old bus) to adopt. 97 + record: IrCalibration, 98 + /// The bus the device is now on, for refreshing the record. 99 + new_bus: Option<String>, 100 + }, 101 + /// Card name matched a record but several connected cameras share 102 + /// that name on different buses — we can't safely guess which the 103 + /// record refers to. The caller should ask the user. Carries the 104 + /// candidate buses for that card name. 105 + Ambiguous { 106 + /// The card name in question. 107 + card: String, 108 + /// Buses of the connected cameras sharing this card name. 109 + candidate_buses: Vec<Option<String>>, 110 + }, 111 + /// No record matches this camera; capture must fall back to a 112 + /// mode-agnostic burst. 113 + None, 114 + } 115 + 116 + /// Resolve calibration for the camera at `target` against `records`, 117 + /// using `connected` (all currently-enumerated cameras) to decide 118 + /// whether an imperfect match is a safe port move or a genuine 119 + /// ambiguity. See [`CalibrationMatch`]. 120 + pub fn resolve_calibration( 121 + target: &CameraInfo, 122 + records: &[IrCalibration], 123 + connected: &[CameraInfo], 124 + ) -> CalibrationMatch { 125 + let id = CameraIdentity::from_info(target); 126 + 127 + // 1. Exact card+bus match wins outright. 128 + if let Some(rec) = records 129 + .iter() 130 + .find(|r| r.identity.card == id.card && r.identity.bus == id.bus) 131 + { 132 + return CalibrationMatch::Exact(rec.clone()); 133 + } 134 + 135 + // 2. Card-name match with a different bus. Only safe to adopt if the 136 + // card name is unambiguous among connected cameras — i.e. there 137 + // is exactly one connected camera with this card name (the device 138 + // we're resolving), so the record can only refer to it. 139 + let same_card_record = records.iter().find(|r| r.identity.card == id.card); 140 + if let Some(rec) = same_card_record { 141 + let connected_with_card: Vec<&CameraInfo> = 142 + connected.iter().filter(|c| c.name == id.card).collect(); 143 + if connected_with_card.len() <= 1 { 144 + return CalibrationMatch::MovedPort { 145 + record: rec.clone(), 146 + new_bus: id.bus.clone(), 147 + }; 148 + } 149 + return CalibrationMatch::Ambiguous { 150 + card: id.card.clone(), 151 + candidate_buses: connected_with_card 152 + .iter() 153 + .map(|c| c.bus.clone()) 154 + .collect(), 155 + }; 156 + } 157 + 158 + CalibrationMatch::None 159 + } 160 + 161 + #[cfg(test)] 162 + mod tests { 163 + use super::*; 164 + use crate::camera::CameraKind; 165 + 166 + // ----- calibration matching ---------------------------------------------- 167 + 168 + fn cam(card: &str, bus: Option<&str>) -> CameraInfo { 169 + CameraInfo { 170 + name: card.to_string(), 171 + path: None, 172 + kind: CameraKind::Ir, 173 + bus: bus.map(str::to_string), 174 + } 175 + } 176 + 177 + fn record(card: &str, bus: Option<&str>, mode: IrCaptureMode) -> IrCalibration { 178 + IrCalibration { 179 + identity: CameraIdentity { 180 + card: card.to_string(), 181 + bus: bus.map(str::to_string), 182 + }, 183 + mode, 184 + calibrated_at_unix: 0, 185 + } 186 + } 187 + 188 + #[test] 189 + fn exact_card_and_bus_match_is_exact() { 190 + let target = cam("IR Cam", Some("usb-1")); 191 + let records = vec![record("IR Cam", Some("usb-1"), IrCaptureMode::Constant)]; 192 + let connected = vec![target.clone()]; 193 + assert!(matches!( 194 + resolve_calibration(&target, &records, &connected), 195 + CalibrationMatch::Exact(_), 196 + )); 197 + } 198 + 199 + #[test] 200 + fn moved_port_when_card_matches_bus_differs_and_card_is_unique() { 201 + // Same camera, now on usb-2. Only one connected camera with this 202 + // card name → safe to adopt. 203 + let target = cam("IR Cam", Some("usb-2")); 204 + let records = vec![record("IR Cam", Some("usb-1"), IrCaptureMode::Strobed { period: 2 })]; 205 + let connected = vec![target.clone()]; 206 + match resolve_calibration(&target, &records, &connected) { 207 + CalibrationMatch::MovedPort { record, new_bus } => { 208 + assert_eq!(record.identity.bus.as_deref(), Some("usb-1")); 209 + assert_eq!(new_bus.as_deref(), Some("usb-2")); 210 + } 211 + other => panic!("expected MovedPort, got {other:?}"), 212 + } 213 + } 214 + 215 + #[test] 216 + fn ambiguous_when_two_connected_cameras_share_card_name() { 217 + // Two identical-model cameras connected; the record's bus matches 218 + // neither → can't guess which it meant. 219 + let target = cam("IR Cam", Some("usb-9")); 220 + let other = cam("IR Cam", Some("usb-8")); 221 + let records = vec![record("IR Cam", Some("usb-1"), IrCaptureMode::Constant)]; 222 + let connected = vec![target.clone(), other]; 223 + match resolve_calibration(&target, &records, &connected) { 224 + CalibrationMatch::Ambiguous { 225 + card, 226 + candidate_buses, 227 + } => { 228 + assert_eq!(card, "IR Cam"); 229 + assert_eq!(candidate_buses.len(), 2); 230 + } 231 + other => panic!("expected Ambiguous, got {other:?}"), 232 + } 233 + } 234 + 235 + #[test] 236 + fn no_match_when_card_name_unknown() { 237 + let target = cam("Unknown Cam", Some("usb-1")); 238 + let records = vec![record("IR Cam", Some("usb-1"), IrCaptureMode::Constant)]; 239 + let connected = vec![target.clone()]; 240 + assert_eq!( 241 + resolve_calibration(&target, &records, &connected), 242 + CalibrationMatch::None, 243 + ); 244 + } 245 + }
+291
crates/core/src/camera/calibration/store.rs
··· 1 + //! On-disk calibration store: a single CBOR file holding all per-device 2 + //! [`IrCalibration`] records. 3 + //! 4 + //! Unlike the enrollment store, calibration is **not** HMAC-protected. 5 + //! Calibration records hold no biometric data — only "this camera 6 + //! strobes its IR emitter with period 2" — and capture degrades 7 + //! gracefully to a safe mode-agnostic burst if the file is missing, 8 + //! corrupt, or tampered with. So the integrity machinery the enrollment 9 + //! store needs would be pure overhead here. We do still write 10 + //! atomically (temp-file + rename) so a crash never leaves a torn file. 11 + //! 12 + //! All records live in one file rather than one-file-per-device: the set 13 + //! is tiny (one entry per camera ever calibrated) and the matching API 14 + //! ([`super::resolve_calibration`]) wants the whole set at once anyway. 15 + 16 + use std::fs; 17 + use std::io; 18 + use std::path::{Path, PathBuf}; 19 + 20 + use serde::{Deserialize, Serialize}; 21 + 22 + use super::IrCalibration; 23 + 24 + /// Default filename for the calibration store within its parent 25 + /// directory (typically alongside the enrollment store root). 26 + pub const CALIBRATION_FILENAME: &str = "calibration.cbor"; 27 + 28 + /// On-disk container. Versioned so the schema can evolve; unknown future 29 + /// versions are refused rather than silently misparsed. 30 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 31 + struct CalibrationFile { 32 + version: u8, 33 + records: Vec<IrCalibration>, 34 + } 35 + 36 + const VERSION: u8 = 1; 37 + 38 + /// Calibration storage backed by a single CBOR file. 39 + #[derive(Debug, Clone)] 40 + pub struct CalibrationStore { 41 + path: PathBuf, 42 + } 43 + 44 + impl CalibrationStore { 45 + /// Open a store at an explicit file path. 46 + pub fn at_path(path: impl Into<PathBuf>) -> Self { 47 + Self { path: path.into() } 48 + } 49 + 50 + /// Open a store at `CALIBRATION_FILENAME` inside `dir` (e.g. the 51 + /// enrollment store root). 52 + pub fn in_dir(dir: impl AsRef<Path>) -> Self { 53 + Self { 54 + path: dir.as_ref().join(CALIBRATION_FILENAME), 55 + } 56 + } 57 + 58 + /// The backing file path. Public for logs / diagnostics. 59 + pub fn path(&self) -> &Path { 60 + &self.path 61 + } 62 + 63 + /// Load all calibration records. Returns an empty vec if the file 64 + /// doesn't exist yet (uncalibrated system) — a normal, non-error 65 + /// state. 66 + pub fn load(&self) -> Result<Vec<IrCalibration>, CalibrationStoreError> { 67 + let bytes = match fs::read(&self.path) { 68 + Ok(b) => b, 69 + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), 70 + Err(e) => return Err(e.into()), 71 + }; 72 + let file: CalibrationFile = 73 + ciborium::from_reader(&bytes[..]).map_err(|e| CalibrationStoreError::Decode(e.to_string()))?; 74 + if file.version != VERSION { 75 + return Err(CalibrationStoreError::UnsupportedVersion { 76 + got: file.version, 77 + max: VERSION, 78 + }); 79 + } 80 + Ok(file.records) 81 + } 82 + 83 + /// Insert or replace the record for one camera identity, then 84 + /// persist the whole set atomically. A record matching the same 85 + /// [`super::CameraIdentity`] (card + bus) is replaced; otherwise the 86 + /// record is appended. 87 + pub fn upsert(&self, record: IrCalibration) -> Result<(), CalibrationStoreError> { 88 + let mut records = self.load()?; 89 + match records 90 + .iter_mut() 91 + .find(|r| r.identity == record.identity) 92 + { 93 + Some(existing) => *existing = record, 94 + None => records.push(record), 95 + } 96 + self.save_all(&records) 97 + } 98 + 99 + /// Replace the record set on a card-name basis: removes any record 100 + /// whose card name matches `record.identity.card` (regardless of 101 + /// bus) before inserting. Used when a device moved ports — we don't 102 + /// want to leave the stale-bus record alongside the fresh one for 103 + /// the same physical camera. 104 + pub fn replace_by_card(&self, record: IrCalibration) -> Result<(), CalibrationStoreError> { 105 + let mut records = self.load()?; 106 + records.retain(|r| r.identity.card != record.identity.card); 107 + records.push(record); 108 + self.save_all(&records) 109 + } 110 + 111 + /// Persist an explicit record set atomically. 112 + pub fn save_all(&self, records: &[IrCalibration]) -> Result<(), CalibrationStoreError> { 113 + let file = CalibrationFile { 114 + version: VERSION, 115 + records: records.to_vec(), 116 + }; 117 + let mut bytes = Vec::new(); 118 + ciborium::into_writer(&file, &mut bytes) 119 + .map_err(|e| CalibrationStoreError::Encode(e.to_string()))?; 120 + write_atomic(&self.path, &bytes)?; 121 + Ok(()) 122 + } 123 + } 124 + 125 + /// Write `bytes` to `path` atomically: temp file in the same directory, 126 + /// fsync, rename over the target, fsync the directory. Same discipline 127 + /// as the enrollment store, minus the 0600 mode (calibration is not 128 + /// secret) — it inherits the umask default. 129 + fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), CalibrationStoreError> { 130 + use std::io::Write; 131 + 132 + let dir = path.parent().unwrap_or_else(|| Path::new(".")); 133 + fs::create_dir_all(dir)?; 134 + let target_name = path 135 + .file_name() 136 + .and_then(|n| n.to_str()) 137 + .unwrap_or("calibration.cbor"); 138 + let nanos = std::time::SystemTime::now() 139 + .duration_since(std::time::UNIX_EPOCH) 140 + .map(|d| d.as_nanos()) 141 + .unwrap_or(0); 142 + let temp_path = dir.join(format!(".{target_name}.tmp.{}.{nanos}", std::process::id())); 143 + 144 + let result: Result<(), CalibrationStoreError> = (|| { 145 + let mut f = fs::OpenOptions::new() 146 + .write(true) 147 + .create_new(true) 148 + .open(&temp_path)?; 149 + f.write_all(bytes)?; 150 + f.sync_all()?; 151 + drop(f); 152 + fs::rename(&temp_path, path)?; 153 + if let Ok(dir_handle) = fs::File::open(dir) { 154 + let _ = dir_handle.sync_all(); 155 + } 156 + Ok(()) 157 + })(); 158 + 159 + if result.is_err() { 160 + let _ = fs::remove_file(&temp_path); 161 + } 162 + result 163 + } 164 + 165 + #[derive(Debug, thiserror::Error)] 166 + pub enum CalibrationStoreError { 167 + #[error("I/O error: {0}")] 168 + Io(#[from] io::Error), 169 + 170 + #[error("calibration encode failed: {0}")] 171 + Encode(String), 172 + 173 + #[error("calibration decode failed: {0}")] 174 + Decode(String), 175 + 176 + #[error("unsupported calibration file version: {got}, supported up to {max}")] 177 + UnsupportedVersion { got: u8, max: u8 }, 178 + } 179 + 180 + #[cfg(test)] 181 + mod tests { 182 + use super::*; 183 + use crate::camera::calibration::{CameraIdentity, IrCalibration}; 184 + use crate::camera::v4l2::IrCaptureMode; 185 + 186 + fn temp_dir() -> PathBuf { 187 + let id: u64 = std::time::SystemTime::now() 188 + .duration_since(std::time::UNIX_EPOCH) 189 + .unwrap() 190 + .as_nanos() as u64 191 + ^ std::process::id() as u64; 192 + let p = std::env::temp_dir().join(format!("pareidolia-calib-test-{id}")); 193 + fs::create_dir_all(&p).unwrap(); 194 + p 195 + } 196 + 197 + fn rec(card: &str, bus: Option<&str>, mode: IrCaptureMode) -> IrCalibration { 198 + IrCalibration { 199 + identity: CameraIdentity { 200 + card: card.to_string(), 201 + bus: bus.map(str::to_string), 202 + }, 203 + mode, 204 + calibrated_at_unix: 42, 205 + } 206 + } 207 + 208 + #[test] 209 + fn load_missing_file_is_empty_not_error() { 210 + let dir = temp_dir(); 211 + let store = CalibrationStore::in_dir(&dir); 212 + assert!(store.load().unwrap().is_empty()); 213 + fs::remove_dir_all(&dir).ok(); 214 + } 215 + 216 + #[test] 217 + fn upsert_then_load_roundtrips() { 218 + let dir = temp_dir(); 219 + let store = CalibrationStore::in_dir(&dir); 220 + let r = rec("IR Cam", Some("usb-1"), IrCaptureMode::Strobed { period: 2 }); 221 + store.upsert(r.clone()).unwrap(); 222 + assert_eq!(store.load().unwrap(), vec![r]); 223 + fs::remove_dir_all(&dir).ok(); 224 + } 225 + 226 + #[test] 227 + fn upsert_replaces_record_with_same_identity() { 228 + let dir = temp_dir(); 229 + let store = CalibrationStore::in_dir(&dir); 230 + store 231 + .upsert(rec("IR Cam", Some("usb-1"), IrCaptureMode::Constant)) 232 + .unwrap(); 233 + store 234 + .upsert(rec("IR Cam", Some("usb-1"), IrCaptureMode::Strobed { period: 2 })) 235 + .unwrap(); 236 + let loaded = store.load().unwrap(); 237 + assert_eq!(loaded.len(), 1); 238 + assert_eq!(loaded[0].mode, IrCaptureMode::Strobed { period: 2 }); 239 + fs::remove_dir_all(&dir).ok(); 240 + } 241 + 242 + #[test] 243 + fn upsert_appends_distinct_identities() { 244 + let dir = temp_dir(); 245 + let store = CalibrationStore::in_dir(&dir); 246 + store 247 + .upsert(rec("IR Cam", Some("usb-1"), IrCaptureMode::Constant)) 248 + .unwrap(); 249 + store 250 + .upsert(rec("Other Cam", Some("usb-2"), IrCaptureMode::Constant)) 251 + .unwrap(); 252 + assert_eq!(store.load().unwrap().len(), 2); 253 + fs::remove_dir_all(&dir).ok(); 254 + } 255 + 256 + #[test] 257 + fn replace_by_card_drops_stale_bus_record() { 258 + let dir = temp_dir(); 259 + let store = CalibrationStore::in_dir(&dir); 260 + // Same camera, old port. 261 + store 262 + .upsert(rec("IR Cam", Some("usb-1"), IrCaptureMode::Constant)) 263 + .unwrap(); 264 + // Moved to a new port: replace by card name, not identity. 265 + store 266 + .replace_by_card(rec("IR Cam", Some("usb-2"), IrCaptureMode::Strobed { period: 2 })) 267 + .unwrap(); 268 + let loaded = store.load().unwrap(); 269 + assert_eq!(loaded.len(), 1, "stale-bus record must be dropped"); 270 + assert_eq!(loaded[0].identity.bus.as_deref(), Some("usb-2")); 271 + fs::remove_dir_all(&dir).ok(); 272 + } 273 + 274 + #[test] 275 + fn save_leaves_no_temp_files() { 276 + let dir = temp_dir(); 277 + let store = CalibrationStore::in_dir(&dir); 278 + for i in 0..5 { 279 + store 280 + .upsert(rec(&format!("Cam{i}"), Some("usb-1"), IrCaptureMode::Constant)) 281 + .unwrap(); 282 + } 283 + let leaked: Vec<_> = fs::read_dir(&dir) 284 + .unwrap() 285 + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) 286 + .filter(|n| n.contains(".tmp.")) 287 + .collect(); 288 + assert!(leaked.is_empty(), "temp files leaked: {leaked:?}"); 289 + fs::remove_dir_all(&dir).ok(); 290 + } 291 + }
+1
crates/core/src/camera/mock.rs
··· 33 33 name: "mock-ir".into(), 34 34 path: None, 35 35 kind: CameraKind::Ir, 36 + bus: None, 36 37 } 37 38 } 38 39
+1
crates/core/src/camera/mock/failing_source.rs
··· 64 64 name: "mock-rgb".into(), 65 65 path: None, 66 66 kind: CameraKind::Rgb, 67 + bus: None, 67 68 } 68 69 } 69 70
+1
crates/core/src/camera/mock/scripted_source.rs
··· 114 114 name: "mock-rgb".into(), 115 115 path: None, 116 116 kind: CameraKind::Rgb, 117 + bus: None, 117 118 } 118 119 } 119 120
+2
crates/core/src/camera/mock/static_source.rs
··· 65 65 name: "mock-rgb".into(), 66 66 path: None, 67 67 kind: CameraKind::Rgb, 68 + bus: None, 68 69 } 69 70 } 70 71 ··· 73 74 name: "mock-ir".into(), 74 75 path: None, 75 76 kind: CameraKind::Ir, 77 + bus: None, 76 78 } 77 79 } 78 80
+3
crates/core/src/camera/select.rs
··· 153 153 name: name.into(), 154 154 path: Some(PathBuf::from(path)), 155 155 kind: CameraKind::Ir, 156 + bus: None, 156 157 } 157 158 } 158 159 ··· 161 162 name: name.into(), 162 163 path: Some(PathBuf::from(path)), 163 164 kind: CameraKind::Rgb, 165 + bus: None, 164 166 } 165 167 } 166 168 ··· 256 258 name: "weird IR".into(), 257 259 path: None, 258 260 kind: CameraKind::Ir, 261 + bus: None, 259 262 }]; 260 263 let r = resolve_ir_camera(None, false, &cams); 261 264 assert!(matches!(r, IrCameraResolution::Ambiguous(_)));
+14
crates/core/src/camera/v4l2.rs
··· 338 338 name: config.name, 339 339 path: Some(config.path), 340 340 kind: config.kind, 341 + // open() targets a specific device by path and doesn't need 342 + // bus info; it's enumerate() that fills bus for calibration 343 + // matching. 344 + bus: None, 341 345 }; 342 346 343 347 Ok(Self { ··· 468 472 469 473 let kind = guess_camera_kind(&dev); 470 474 475 + let bus = { 476 + let trimmed = caps.bus.trim_end_matches('\0').trim().to_string(); 477 + if trimmed.is_empty() { 478 + None 479 + } else { 480 + Some(trimmed) 481 + } 482 + }; 483 + 471 484 out.push(CameraInfo { 472 485 name: card_name, 473 486 path: Some(path), 474 487 kind, 488 + bus, 475 489 }); 476 490 } 477 491 out
+2
crates/integration-tests/tests/integration/camera.rs
··· 16 16 name: "smoke-rgb".into(), 17 17 path: None, 18 18 kind: CameraKind::Rgb, 19 + bus: None, 19 20 } 20 21 } 21 22 ··· 59 60 name: "smoke-ir".into(), 60 61 path: None, 61 62 kind: CameraKind::Ir, 63 + bus: None, 62 64 }, 63 65 2, 64 66 1,