diff --git a/contrib/hw/30c9-0120.toml b/contrib/hw/30c9-0120.toml new file mode 100644 index 0000000..eb21b2f --- /dev/null +++ b/contrib/hw/30c9-0120.toml @@ -0,0 +1,19 @@ +[device] +vendor_id = 0x30C9 +product_id = 0x0120 +name = "HP OmniBook X Flip IR Camera (Luxvisions 30c9:0120)" + +[emitter] +# Discovered via UVC XU probe: IR function extension unit 14, +# GUID 0f3f95dc-2632-4c4e-92c9-a04782f43bc8 (Windows Hello IR control). +# selector 6, len 9. cur/def=[1,3,1] (off), max=[1,3,3] (on). +unit = 14 +selector = 6 +control_bytes = [1, 3, 3, 0, 0, 0, 0, 0, 0] +# This camera rejects an all-zero "off" payload (ERANGE) and would stay lit; +# its real off/default value is [1,3,1,...]. +off_bytes = [1, 3, 1, 0, 0, 0, 0, 0, 0] +# This camera resets the XU control the instant the controlling fd closes and +# only re-illuminates on a fresh open->set edge, so the emitter holds one fd +# open for the duration of each capture (open in activate, close in deactivate). +reset_on_close = true diff --git a/contrib/hw/README.md b/contrib/hw/README.md index cf673f9..e135c08 100644 --- a/contrib/hw/README.md +++ b/contrib/hw/README.md @@ -28,6 +28,8 @@ control_bytes = [1, 3, 3, 0, 0, 0, 0, 0, 0] | `[emitter]` | `unit` | u8 | UVC extension unit ID | | `[emitter]` | `selector` | u8 | UVC control selector | | `[emitter]` | `control_bytes` | byte array | Payload to activate the emitter. Zeros of the same length deactivate it. | +| `[emitter]` | `off_bytes` | byte array | Optional. Explicit payload to deactivate the emitter. Needed for cameras that reject an all-zero "off" payload (e.g. with `ERANGE`). Defaults to zeros of `control_bytes` length when omitted. | +| `[emitter]` | `reset_on_close` | bool | Optional. Set `true` for cameras that reset the control when the controlling fd closes and only re-illuminate on a fresh open→set edge; the emitter then holds one fd open for the duration of each capture. Defaults to `false`. | The `control_bytes` values are found via `linux-enable-ir-emitter configure` or UVC descriptor analysis. diff --git a/crates/visage-hw/src/ir_emitter.rs b/crates/visage-hw/src/ir_emitter.rs index d887d63..b913c50 100644 --- a/crates/visage-hw/src/ir_emitter.rs +++ b/crates/visage-hw/src/ir_emitter.rs @@ -5,6 +5,8 @@ //! `linux-enable-ir-emitter` dependency. use crate::quirks::{get_usb_ids, lookup_quirk, CameraQuirk}; +use std::cell::RefCell; +use std::fs::{File, OpenOptions}; use std::os::unix::io::AsRawFd; use thiserror::Error; @@ -40,6 +42,9 @@ const _SIZE_ASSERT: () = assert!( pub struct IrEmitter { device_path: String, quirk: &'static CameraQuirk, + + /// Additional options for cameras with special file descriptor (fd) rules + active_fd: RefCell>, } #[derive(Debug, Error)] @@ -62,6 +67,7 @@ impl IrEmitter { Some(Self { device_path: device_path.to_string(), quirk, + active_fd: RefCell::new(None), }) } @@ -69,13 +75,42 @@ impl IrEmitter { pub fn activate(&self) -> Result<(), EmitterError> { tracing::debug!(device = %self.device_path, "activating IR emitter"); let mut payload = self.quirk.emitter.control_bytes.clone(); + + // reset_on_close devices forget the control the moment the fd closes, + // so open a fresh fd, set it, and hold it open until deactivate(). + if self.quirk.emitter.reset_on_close { + self.active_fd.borrow_mut().take(); // drop any stale fd first + let file = OpenOptions::new() + .read(true) + .write(true) + .open(&self.device_path) + .map_err(EmitterError::Open)?; + let result = Self::send_via_fd(&file, self.quirk, &mut payload); + *self.active_fd.borrow_mut() = Some(file); + return result; + } + + // Default: open, set the control, close. self.send_uvc_control(&mut payload) } - /// Deactivate the IR emitter by sending zeros of the same length. + /// Deactivate the IR emitter after a capture. pub fn deactivate(&self) -> Result<(), EmitterError> { tracing::debug!(device = %self.device_path, "deactivating IR emitter"); - let mut payload = vec![0u8; self.quirk.emitter.control_bytes.len()]; + let mut payload = self.off_payload(); + + // reset_on_close devices reset the control when the fd closes, so send + // "off" through the held fd, then close it to return control to default. + if self.quirk.emitter.reset_on_close { + let result = match self.active_fd.borrow().as_ref() { + Some(file) => Self::send_via_fd(file, self.quirk, &mut payload), + None => Ok(()), + }; + self.active_fd.borrow_mut().take(); + return result; + } + + // Default: open, send "off", close. self.send_uvc_control(&mut payload) } @@ -89,18 +124,35 @@ impl IrEmitter { &self.quirk.device.name } + /// Deactivate IR emitter by sending zeros of `control_bytes` length or + /// send explicit `off_bytes` when provided for cameras that require them. + fn off_payload(&self) -> Vec { + match &self.quirk.emitter.off_bytes { + Some(off) if !off.is_empty() => off.clone(), + _ => vec![0u8; self.quirk.emitter.control_bytes.len()], + } + } + + /// Open a second fd here rather than requiring `AsRawFd` on `Camera`. + /// Open with read+write, send one control, close (default) fn send_uvc_control(&self, payload: &mut [u8]) -> Result<(), EmitterError> { - // Open the device with read+write access — needed for UVC ioctls. - // We open a second fd here rather than requiring AsRawFd on Camera. - let file = std::fs::OpenOptions::new() + let file = OpenOptions::new() .read(true) .write(true) .open(&self.device_path) .map_err(EmitterError::Open)?; + Self::send_via_fd(&file, self.quirk, payload) + } + /// Send one UVC `SET_CUR` control over an already-open fd. + fn send_via_fd( + file: &File, + quirk: &CameraQuirk, + payload: &mut [u8], + ) -> Result<(), EmitterError> { let mut query = UvcXuControlQuery { - unit: self.quirk.emitter.unit, - selector: self.quirk.emitter.selector, + unit: quirk.emitter.unit, + selector: quirk.emitter.selector, query: UVC_SET_CUR, _pad0: 0, size: payload.len() as u16, diff --git a/crates/visage-hw/src/quirks.rs b/crates/visage-hw/src/quirks.rs index 866b049..e17ac12 100644 --- a/crates/visage-hw/src/quirks.rs +++ b/crates/visage-hw/src/quirks.rs @@ -13,6 +13,8 @@ const QUIRK_04F2_B6D9: &str = include_str!("../../../contrib/hw/04f2-b6d9.toml") const QUIRK_174F_2454: &str = include_str!("../../../contrib/hw/174f-2454.toml"); /// Compile-time embedded quirk for the Lenovo ThinkBook 14 MP2PQAZG IR camera. const QUIRK_30C9_00C2: &str = include_str!("../../../contrib/hw/30c9-00c2.toml"); +/// Compile-time embedded quirk for the HP OmniBook X Flip IR camera (Luxvisions 30c9:0120). +const QUIRK_30C9_0120: &str = include_str!("../../../contrib/hw/30c9-0120.toml"); static QUIRK_DB: OnceLock> = OnceLock::new(); @@ -39,6 +41,14 @@ pub struct EmitterInfo { /// Payload bytes sent to activate the emitter. /// Zeros of the same length deactivate it. pub control_bytes: Vec, + /// Payload bytes sent to deactivate the emitter. + /// Defaults to zeros of `control_bytes` length. + #[serde(default)] + pub off_bytes: Option>, + /// Flag `true` for cameras that reset the XU control when the controlling fd closes, + /// making `IrEmitter` hold an fd open for the duration of each capture. + #[serde(default)] + pub reset_on_close: bool, } /// Public alias used by `IrEmitter`. @@ -47,7 +57,12 @@ pub type CameraQuirk = QuirkFile; fn quirk_db() -> &'static Vec { QUIRK_DB.get_or_init(|| { let mut db = Vec::new(); - for src in [QUIRK_04F2_B6D9, QUIRK_174F_2454, QUIRK_30C9_00C2] { + for src in [ + QUIRK_04F2_B6D9, + QUIRK_174F_2454, + QUIRK_30C9_00C2, + QUIRK_30C9_0120, + ] { match toml::from_str::(src) { Ok(q) => db.push(q), Err(e) => eprintln!("visage-hw: bad quirk TOML: {e}"),