diff --git a/crates/obsbot-device/src/family/exchange.rs b/crates/obsbot-device/src/family/exchange.rs index 9da70a0..971bef9 100644 --- a/crates/obsbot-device/src/family/exchange.rs +++ b/crates/obsbot-device/src/family/exchange.rs @@ -175,4 +175,38 @@ mod tests { assert_eq!(result.unwrap(), vec![4, 5, 6]); assert_eq!(reads, 3); } + + use crate::scripted::{self, Scripted}; + use std::time::Instant; + + #[test] + fn all_cameras_of_a_process_share_one_sequence() { + // separate handles must never repeat a number, so there is one counter + assert!(std::ptr::eq(sequence(), sequence())); + } + + fn frame_of(command: u16) -> [u8; 60] { + let mut f = [0u8; 60]; + f[10..12].copy_from_slice(&command.to_le_bytes()); + f + } + + #[test] + fn a_pair_is_sent_with_a_gap_between_its_frames() { + // back to back the camera applies neither frame + let camera = scripted::camera(Scripted::new([0; XU_SIZE])); + let start = Instant::now(); + camera.pair([frame_of(1), frame_of(2)]).unwrap(); + assert!(start.elapsed() >= PAIR_GAP); + } + + #[test] + fn a_pair_fails_when_either_frame_cannot_be_sent() { + for failing in [1, 2] { + let mut script = Scripted::new([0; XU_SIZE]); + script.answers.insert(failing, scripted::Answer::SendFails); + let result = scripted::camera(script).pair([frame_of(1), frame_of(2)]); + assert!(matches!(result, Err(Error::Io(_))), "{failing}: {result:?}"); + } + } } diff --git a/crates/obsbot-device/src/family/frame.rs b/crates/obsbot-device/src/family/frame.rs index 6b7f8bf..03b4a12 100644 --- a/crates/obsbot-device/src/family/frame.rs +++ b/crates/obsbot-device/src/family/frame.rs @@ -268,4 +268,48 @@ mod tests { prev = cur; } } + + #[test] + fn a_frame_holds_exactly_44_bytes_of_data() { + // 60 bytes in all, 16 of them headers: 44 bytes of data fill the frame to its end + assert_eq!(MAX_DATA, 44); + let f = frame(0x04, 1, &[0xab; 44], 0); + assert!(f[16..].iter().all(|b| *b == 0xab)); + } + + #[test] + #[should_panic(expected = "must not exceed 44 bytes")] + fn one_byte_past_the_data_section_is_refused() { + frame(0x04, 1, &[0u8; 45], 0); + } + + #[test] + fn a_rejection_needs_a_16_byte_header_and_the_extension_tag() { + // layout: header length at 4..6, the extension `00 02` at 12..14, the i16 code at 14..16 + let rejected = |header: u16, extension: [u8; 2]| { + let mut r = [0u8; 60]; + r[0] = 0xaa; + r[1] = 0x89; + r[2..4].copy_from_slice(&7u16.to_le_bytes()); + r[4..6].copy_from_slice(&header.to_le_bytes()); + r[12..14].copy_from_slice(&extension); + r[14..16].copy_from_slice(&(-3i16).to_le_bytes()); + parse_answer(&r, 7, 0).map(|a| matches!(a, Answer::Rejected(-3))) + }; + assert_eq!(rejected(16, [0x00, 0x02]), Some(true)); + assert_eq!(rejected(12, [0x00, 0x02]), Some(false), "12-byte header"); + assert_eq!(rejected(16, [0x01, 0x02]), Some(false), "another extension"); + } + + #[test] + fn a_reply_must_hold_both_headers_to_be_an_answer() { + let full = answer(0x29, 7, &[]); + assert_eq!(parse_answer(&full[..12], 7, 0), None, "12 bytes"); + assert_eq!(parse_answer(&full[..15], 7, 0), None, "one byte short"); + assert_eq!( + parse_answer(&full[..16], 7, 0), + Some(Answer::Value(&[])), + "both headers and no data" + ); + } } diff --git a/crates/obsbot-device/src/legacy/full_status.rs b/crates/obsbot-device/src/legacy/full_status.rs index 031489b..94fcd64 100644 --- a/crates/obsbot-device/src/legacy/full_status.rs +++ b/crates/obsbot-device/src/legacy/full_status.rs @@ -431,11 +431,22 @@ mod scripted_camera_tests { /// (2.0), the live pose (pan 25, tilt 10), the microphone volume (55) and the absolute zoom /// control (37). The tracking query gets `tracking`; every other query is rejected. fn camera(tracking: Answer) -> Camera { + camera_in_mode(tracking, (0, 0), Vec::new()) + } + + /// The same camera in the AI mode of the status bytes 0x18 and 0x1c (0, 0 is Static), with + /// `target` as the answer to the target query when it is not empty. + fn camera_in_mode(tracking: Answer, ai_mode: (u8, u8), target: Vec) -> Camera { let mut status = [0u8; 60]; status[0x06] = 1; + status[0x18] = ai_mode.0; + status[0x1c] = ai_mode.1; status[0x21] = 2; let mut script = Scripted::new(status); script.answers.insert(0x0104, tracking); + if !target.is_empty() { + script.answers.insert(0x01c4, Answer::Value(target)); + } script .answers .insert(0x6804, Answer::Value(2.0f32.to_le_bytes().to_vec())); @@ -514,4 +525,25 @@ mod scripted_camera_tests { let result = camera(Answer::SendFails).full_status_with(zones, identity); assert!(matches!(result, Err(Error::Io(_))), "{result:?}"); } + + #[test] + fn the_target_is_asked_only_while_ai_tracking_is_on() { + // `01 01 01 00 00` and four f32; a camera in Static would answer a single 00, + // so it is not asked at all + let mut answer = vec![1, 1, 1, 1, 0, 0]; + for v in [0.25f32, 0.5, 0.75, 1.0] { + answer.extend(v.to_le_bytes()); + } + let target = |ai_mode| { + let (zones, identity) = known(); + camera_in_mode(Answer::Value(vec![0; 12]), ai_mode, answer.clone()) + .full_status_with(zones, identity) + .unwrap() + .target + }; + assert_eq!(target((0, 0)), None, "Static"); + let followed = target((2, 0)).expect("Normal tracking asks for the target"); + assert!(followed.detected); + assert_eq!(followed.coords, [0.25, 0.5, 0.75, 1.0]); + } } diff --git a/crates/obsbot-device/src/models/tiny2lite/camera.rs b/crates/obsbot-device/src/models/tiny2lite/camera.rs index 2fcd722..d4c3bab 100644 --- a/crates/obsbot-device/src/models/tiny2lite/camera.rs +++ b/crates/obsbot-device/src/models/tiny2lite/camera.rs @@ -1175,6 +1175,41 @@ mod angle_conversion_tests { assert_eq!(hand_enabled(&hand_answer(&[])[..23]), (Some(true), None)); assert_eq!(hand_enabled(&hand_answer(&[])[..22]), (None, None)); } + + #[test] + fn a_zero_pitch_minimum_reads_as_a_positive_zero_tilt_maximum() { + // the other end of `a_zero_pitch_limit_reads_as_positive_zero`: no "-0" in the output + let (_, tilt_max) = pitch_to_tilt_range((0.0, 30.0)); + assert_eq!(tilt_max.to_bits(), 0); + } + + #[test] + fn hand_area_pitch_limits_of_zero_read_as_positive_zero_tilts() { + // pitch min and max 0 (words 2 and 3 as f32): both tilts are +0.0, not -0.0 + let d = hand_answer(&[ + (8, 0), + (9, 0), + (10, 0), + (11, 0), + (12, 0), + (13, 0), + (14, 0), + (15, 0), + ]); + let area = HandArea::decode(&d).unwrap(); + assert_eq!(area.tilt_min.to_bits(), 0); + assert_eq!(area.tilt_max.to_bits(), 0); + } + + #[test] + fn a_live_pose_at_pitch_zero_has_a_positive_zero_tilt() { + // pitch word 0, yaw +20.00: tilt reads as +0.0, not -0.0 (the JSON would show "-0.0") + let mut d = [0u8; 32]; + d[10..12].copy_from_slice(&2000i16.to_le_bytes()); + let p = LivePose::decode(&d).unwrap(); + assert_eq!(p.pan, 20.0); + assert_eq!(p.tilt.to_bits(), 0); + } } #[cfg(test)] @@ -1234,3 +1269,359 @@ mod picture_and_sound_tests { assert!(log.borrow().is_empty(), "no switch, no value"); } } + +/// The reads and the checks of `Camera` on a scripted camera (no device is opened). +#[cfg(test)] +mod scripted_camera_tests { + use super::*; + use crate::scripted::{self, Answer, Scripted}; + use std::cell::RefCell; + use std::rc::Rc; + use std::time::Instant; + + // V4L2 control ids + const BRIGHTNESS: u32 = 0x0098_0900; + const GAIN: u32 = 0x0098_0913; + const EXPOSURE_TIME: u32 = 0x009a_0902; + + type Log = Rc>>; + + fn floats(values: &[f32]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() + } + + fn script() -> Scripted { + Scripted::new([0u8; 60]) + } + + fn answer(script: &mut Scripted, command: u16, data: Vec) { + script.answers.insert(command, Answer::Value(data)); + } + + /// A pose answer (`0x4ec4`, `0x2504`, `0x1444`): the leading u32, then pan, pitch, one word + /// that is not read, and zoom as f32. + fn pose_answer(lead: u32, pan: f32, pitch: f32, zoom: f32) -> Vec { + let mut d = lead.to_le_bytes().to_vec(); + d.extend(floats(&[pan, pitch, 0.0, zoom])); + d + } + + /// A camera whose controls are `ctrls`, and the log of its `set_ctrl` calls. + fn with_ctrls(ctrls: &[(u32, i32)]) -> (Camera, Log) { + let mut script = script(); + script.ctrls.extend(ctrls.iter().copied()); + let log = script.set_ctrls.clone(); + (scripted::camera(script), log) + } + + #[test] + fn image_controls_lists_the_controls_the_camera_has_with_their_values() { + let (camera, _) = with_ctrls(&[(BRIGHTNESS, 12)]); + let controls = camera.image_controls().unwrap(); + assert_eq!(controls.len(), 1); + assert_eq!(controls["brightness"].value, 12); + } + + #[test] + fn image_names_map_to_their_v4l2_ids() { + assert_eq!(Camera::image_id("brightness").unwrap(), BRIGHTNESS); + assert_eq!(Camera::image_id("gain").unwrap(), GAIN); + assert!(matches!( + Camera::image_id("sparkle"), + Err(Error::Invalid(_)) + )); + } + + #[test] + fn an_image_value_must_be_inside_the_range_of_the_control() { + // the scripted range is 0 to 100 + let (camera, _) = with_ctrls(&[(BRIGHTNESS, 50)]); + assert_eq!( + camera.image_id_in_range("brightness", 0).unwrap(), + BRIGHTNESS + ); + assert_eq!( + camera.image_id_in_range("brightness", 100).unwrap(), + BRIGHTNESS + ); + for value in [-1, 101] { + let result = camera.image_id_in_range("brightness", value); + assert!(matches!(result, Err(Error::Invalid(_))), "{value}"); + } + let result = camera.image_id_in_range("gain", 5); + assert!( + matches!(result, Err(Error::Unsupported(_))), + "the camera has no gain" + ); + } + + #[test] + fn a_shutter_time_needs_manual_exposure() { + // sent while the exposure is automatic it would freeze the automatic exposure + let (camera, log) = + with_ctrls(&[(EXPOSURE_TIME, 10), (EXPOSURE_AUTO, 0), (BRIGHTNESS, 50)]); + let result = camera.set_image("exposure_time", 20); + assert!(matches!(result, Err(Error::Invalid(_))), "{result:?}"); + assert!(log.borrow().is_empty(), "nothing was set"); + // the other controls do not care + camera.set_image("brightness", 20).unwrap(); + assert_eq!(*log.borrow(), [(BRIGHTNESS, 20)]); + let (camera, log) = with_ctrls(&[(EXPOSURE_TIME, 10), (EXPOSURE_AUTO, 1)]); + camera.set_image("exposure_time", 20).unwrap(); + assert_eq!(*log.borrow(), [(EXPOSURE_TIME, 20)]); + } + + #[test] + fn manual_exposure_switches_first_and_then_sets_time_and_gain() { + let (camera, log) = with_ctrls(&[(EXPOSURE_AUTO, 1), (EXPOSURE_TIME, 10), (GAIN, 0)]); + camera.set_manual_exposure(Some(100), Some(7)).unwrap(); + assert_eq!( + *log.borrow(), + [(EXPOSURE_AUTO, 1), (EXPOSURE_TIME, 100), (GAIN, 7)] + ); + } + + #[test] + fn manual_focus_turns_autofocus_off_and_stays_in_range() { + let (camera, log) = with_ctrls(&[(FOCUS_ABSOLUTE, 50)]); + for value in [0, 100] { + log.borrow_mut().clear(); + camera.set_focus(value).unwrap(); + assert_eq!(*log.borrow(), [(FOCUS_AUTO, 0), (FOCUS_ABSOLUTE, value)]); + } + for value in [-1, 101] { + log.borrow_mut().clear(); + let result = camera.set_focus(value); + assert!(matches!(result, Err(Error::Invalid(_))), "{value}"); + assert!(log.borrow().is_empty(), "{value} changed something"); + } + let (camera, _) = with_ctrls(&[]); + assert!(matches!(camera.set_focus(10), Err(Error::Unsupported(_)))); + } + + #[test] + fn autofocus_is_the_continuous_autofocus_control() { + let (camera, log) = with_ctrls(&[]); + camera.set_autofocus(true).unwrap(); + camera.set_autofocus(false).unwrap(); + assert_eq!(*log.borrow(), [(FOCUS_AUTO, 1), (FOCUS_AUTO, 0)]); + } + + #[test] + fn zoom_maps_the_factor_onto_the_range_of_the_zoom_control() { + // a range that does not start at 0 and whose width differs from its end, so that a + // mixed-up minimum, maximum or width shows: factor 1.0 is the minimum, 4.0 the maximum, + // 2.5 the middle + let mut script = script(); + script.ctrls.insert(ZOOM_ABSOLUTE, 100); + script.ranges.insert(ZOOM_ABSOLUTE, (100, 500)); + let log = script.set_ctrls.clone(); + let camera = scripted::camera(script); + for (factor, value) in [(1.0, 100), (2.5, 300), (4.0, 500)] { + camera.set_zoom(factor).unwrap(); + assert_eq!( + log.borrow().last(), + Some(&(ZOOM_ABSOLUTE, value)), + "{factor}" + ); + } + for factor in [0.9, 4.1] { + let before = log.borrow().len(); + let result = camera.set_zoom(factor); + assert!(matches!(result, Err(Error::Invalid(_))), "{factor}"); + assert_eq!(log.borrow().len(), before, "{factor} changed something"); + } + } + + #[test] + fn the_wake_up_zoom_is_reported_as_the_percent_that_was_stored() { + // factor 1 + 3 * percent / 100 + let (camera, _) = with_ctrls(&[]); + assert_eq!(camera.set_wake_zoom(2.5).unwrap(), 50); + assert_eq!(camera.set_wake_zoom(4.0).unwrap(), 100); + assert!(camera.set_wake_zoom(4.5).is_err()); + } + + #[test] + fn the_framing_offset_needs_an_axis() { + let (camera, _) = with_ctrls(&[]); + assert!(matches!( + camera.set_framing_offset(None, None), + Err(Error::Invalid(_)) + )); + camera.set_framing_offset(Some(0.1), None).unwrap(); + camera.set_framing_offset(None, Some(-0.1)).unwrap(); + } + + #[test] + fn two_offset_frames_are_a_gap_apart_and_one_frame_needs_no_gap() { + // back to back the camera applies neither frame; a single frame has nothing to wait for + let (camera, _) = with_ctrls(&[]); + let start = Instant::now(); + camera.set_framing_offset(Some(0.1), Some(-0.1)).unwrap(); + assert!(start.elapsed() >= PAIR_GAP); + let start = Instant::now(); + camera.set_framing_offset(Some(0.1), None).unwrap(); + assert!(start.elapsed() < PAIR_GAP, "{:?}", start.elapsed()); + } + + #[test] + fn the_zone_is_read_from_its_queries_in_obsbot4linux_angles() { + let zone = |follow: bool| { + let mut script = script(); + answer(&mut script, 0x4c44, floats(&[-45.0])); + answer(&mut script, 0x4cc4, floats(&[45.0])); + // camera pitch -10..30 (positive down) is tilt -30..10 + answer(&mut script, 0x4d44, floats(&[-10.0])); + answer(&mut script, 0x4dc4, floats(&[30.0])); + answer(&mut script, 0x4e44, vec![follow as u8]); + answer(&mut script, 0x4ec4, pose_answer(0, 12.5, -3.25, 1.5)); + answer(&mut script, 0x1344, vec![2, 0, 0, 0]); + answer(&mut script, 0x1504, b"left".to_vec()); + answer(&mut script, 0x1444, pose_answer(0, 20.0, -5.0, 2.0)); + scripted::camera(script).zone().unwrap() + }; + let expected = |follow| Zone { + pan_min: -45.0, + pan_max: 45.0, + tilt_min: -30.0, + tilt_max: 10.0, + follow, + return_pose: Pose { + pan: 12.5, + tilt: 3.25, + zoom: 1.5, + }, + views: Some(vec![ZoneView { + id: 2, + name: "left".into(), + pose: Pose { + pan: 20.0, + tilt: 5.0, + zoom: 2.0, + }, + }]), + }; + assert_eq!(zone(true), expected(true)); + assert_eq!(zone(false), expected(false)); + } + + /// The `0x2004` answer: yaw -45..45 and pitch -30..30 as f32, six more bytes, then the pan + /// and tilt flags in bytes 22 and 23. + fn hand_area_answer() -> Vec { + let mut d = floats(&[-45.0, 45.0, -30.0, 30.0]); + d.extend([0, 0, 0, 0, 0, 0, 1, 1]); + d + } + + #[test] + fn the_hand_tracking_is_read_from_its_queries() { + let mut script = script(); + answer(&mut script, 0x2004, hand_area_answer()); + // leading word -1: no return position set + answer(&mut script, 0x2504, pose_answer(u32::MAX, 0.0, 0.0, 1.0)); + answer(&mut script, 0x2484, floats(&[2.5])); + let hand = scripted::camera(script).hand().unwrap(); + assert_eq!( + hand.area, + HandArea { + pan_min: -45.0, + pan_max: 45.0, + tilt_min: -30.0, + tilt_max: 30.0 + } + ); + assert_eq!(hand.return_pose, None); + assert_eq!( + (hand.pan_enabled, hand.tilt_enabled), + (Some(true), Some(true)) + ); + assert_eq!(hand.return_after, Some(2.5)); + } + + #[test] + fn a_hand_return_time_is_zero_or_more_else_it_is_not_shown() { + for (seconds, shown) in [(0.0, Some(0.0)), (-1.0, None)] { + let mut script = script(); + answer(&mut script, 0x2004, hand_area_answer()); + answer(&mut script, 0x2504, pose_answer(u32::MAX, 0.0, 0.0, 1.0)); + answer(&mut script, 0x2484, floats(&[seconds])); + let hand = scripted::camera(script).hand().unwrap(); + assert_eq!(hand.return_after, shown, "{seconds}"); + } + } + + #[test] + fn the_framing_options_are_read_from_their_queries() { + // speed mode 3 fast, size 2 medium shot, auto zoom speed 30 is medium (a camera that was + // never set reports the speed itself), part mode 1 head removal + let framing = |auto_zoom: u8| { + let mut script = script(); + answer(&mut script, 0x0984, vec![3]); + answer(&mut script, 0x0a84, vec![auto_zoom]); + answer(&mut script, 0x0b04, vec![2]); + answer(&mut script, 0x0b84, vec![30]); + answer(&mut script, 0x0c04, floats(&[0.05])); + answer(&mut script, 0x0c84, floats(&[-0.1])); + answer(&mut script, 0x15c4, vec![1]); + scripted::camera(script).framing().unwrap() + }; + let on = framing(1); + assert_eq!( + on, + Framing { + speed_mode: SpeedMode::Fast, + auto_zoom: true, + size: FramingSize::MediumShot, + zoom_speed: AutoZoomSpeed::Medium, + offset: Offset { x: 0.05, y: -0.1 }, + part_mode: PartMode::HeadRemoval, + } + ); + assert!(!framing(0).auto_zoom); + } + + #[test] + fn the_tracking_target_is_read_from_query_0x01c4() { + // `01 01 01 00 00` and four f32 + let mut d = vec![1, 1, 1, 1, 0, 0]; + d.extend(floats(&[0.25, 0.5, 0.75, 1.0])); + let mut script = script(); + answer(&mut script, 0x01c4, d); + let target = scripted::camera(script).target().unwrap(); + assert_eq!( + target, + Some(Target { + detected: true, + coords: [0.25, 0.5, 0.75, 1.0] + }) + ); + } + + #[test] + fn the_initial_state_ai_byte_is_the_first_byte_of_0x3e84() { + let mut script = script(); + answer(&mut script, 0x3e84, vec![0xfe, 1, 2]); + assert_eq!(scripted::camera(script).startup_ai_byte().unwrap(), 0xfe); + } + + #[test] + fn the_current_pose_is_the_live_pose_and_the_zoom_with_the_zoom_kept_in_range() { + // pitch -10.00 (up) and yaw 25.00; a zoom reading above the range is held at the maximum + let mut script = script(); + let mut pose = vec![0u8; 12]; + pose[8..10].copy_from_slice(&(-1000i16).to_le_bytes()); + pose[10..12].copy_from_slice(&2500i16.to_le_bytes()); + answer(&mut script, 0x0043, pose); + answer(&mut script, 0x6804, floats(&[5.0])); + let pose = scripted::camera(script).live_pose_and_zoom().unwrap(); + assert_eq!( + pose, + Pose { + pan: 25.0, + tilt: 10.0, + zoom: MAX_ZOOM + } + ); + } +} diff --git a/crates/obsbot-device/src/models/tiny2lite/commands.rs b/crates/obsbot-device/src/models/tiny2lite/commands.rs index 30be001..bfeaf55 100644 --- a/crates/obsbot-device/src/models/tiny2lite/commands.rs +++ b/crates/obsbot-device/src/models/tiny2lite/commands.rs @@ -1089,4 +1089,35 @@ mod tests { assert_eq!(auto_sleep_off(32767).unwrap(), auto_sleep(-32767)); assert!(auto_sleep_off(32768).is_err()); } + + #[test] + fn a_documented_limit_is_accepted_and_one_past_it_is_not() { + assert!(led(3).is_ok(), "LED brightness is 0-3"); + assert!(wake_zoom(100).is_ok(), "wake-up zoom is 0-100 percent"); + assert!(audio_volume(100, 0).is_ok(), "microphone volume is 0-100"); + assert!(zoom_to(1, 2.0, 0).is_ok(), "zoom speed must be positive"); + assert!(zoom_to(0, 2.0, 0).is_err()); + assert!( + hand_return_after(0.0, 0).is_ok(), + "hand return time is 0 seconds or more" + ); + } + + #[test] + fn an_area_needs_its_minimum_below_its_maximum() { + // equal limits are no area ("min must be below max") + assert!(zone_area((10.0, 10.0), (-30.0, 30.0), [0; 4]).is_err()); + assert!(zone_area((-100.0, 100.0), (5.0, 5.0), [0; 4]).is_err()); + assert!(hand_area((10.0, 10.0), (-30.0, 30.0), [0; 4]).is_err()); + } + + #[test] + fn a_rate_of_zero_sends_twelve_zero_bytes_whatever_mirror_and_invert_are() { + // Center releasing the joystick (capture 13, seq 525): 0x6484 with the data all zero. + // A negated zero would put the sign bit into the frame. + for (mirror, invert) in [(false, false), (false, true), (true, false), (true, true)] { + let f = gimbal_rate(0.0, 0.0, mirror, invert, 525).unwrap(); + assert_eq!(f[16..28], [0u8; 12], "mirror {mirror}, invert {invert}"); + } + } } diff --git a/crates/obsbot-device/src/models/tiny2lite/presets.rs b/crates/obsbot-device/src/models/tiny2lite/presets.rs index 831d60d..3e3e7f3 100644 --- a/crates/obsbot-device/src/models/tiny2lite/presets.rs +++ b/crates/obsbot-device/src/models/tiny2lite/presets.rs @@ -926,4 +926,81 @@ mod tests { assert_eq!(parse_slots(&[0x02, 0x00, 0x02]), vec![0, 2]); assert_eq!(parse_slots(&[0x02, 0x00, 0x07]), vec![0]); } + + #[test] + fn startup_ai_mode_names_the_documented_bytes() { + // byte 0 of the initial-state block; 7 and 8 are both hand tracking, 0xfe is not set + let table = [ + (0xff, Some(AiMode::Static)), + (0, Some(AiMode::Normal)), + (1, Some(AiMode::UpperBody)), + (2, Some(AiMode::CloseUp)), + (3, Some(AiMode::Headless)), + (4, Some(AiMode::LowerBody)), + (7, Some(AiMode::Hand)), + (8, Some(AiMode::Hand)), + (9, Some(AiMode::Group)), + (STARTUP_AI_NOT_SET, None), + (5, None), + ]; + for (byte, mode) in table { + assert_eq!(startup_ai_mode(byte), mode, "byte {byte:#04x}"); + } + } + + #[test] + fn startup_actions_need_the_24_bytes_up_to_the_hue() { + // hue is byte 23, the last byte read + let mut d = [0xffu8; 24]; + d[23] = 7; + assert_eq!(StartupActions::decode(&d).unwrap().hue, Some(7)); + assert!(StartupActions::decode(&d[..23]).is_none()); + } + + #[test] + fn a_slot_index_outside_the_slots_is_not_listed() { + // the slots are 0 to SLOTS - 1; the index SLOTS itself is out + assert_eq!(parse_slots(&[0x02, 0x00, SLOTS]), vec![0]); + assert_eq!(parse_slots(&[0x02, SLOTS - 1, 0x00]), vec![SLOTS - 1, 0]); + } + + #[test] + fn an_entry_reads_each_pose_word_from_its_own_bytes() { + // layout: status, slot, 2 pad bytes, pitch i16, yaw i16, zoom u16 (hundredths); every + // byte of the three words differs, so a word read from the wrong place shows + let mut block = [0u8; 60]; + block[1] = 2; + block[4..6].copy_from_slice(&291i16.to_le_bytes()); + block[6..8].copy_from_slice(&1110i16.to_le_bytes()); + block[8..10].copy_from_slice(&376u16.to_le_bytes()); + let p = parse_entry(&block).unwrap(); + assert_eq!(p.slot, 2); + assert_eq!( + p.pose, + Pose { + pan: 11.1, + tilt: -2.91, + zoom: 3.76 + } + ); + } + + #[test] + fn a_preset_at_tilt_zero_reads_as_positive_zero() { + // no -0 in the output: pitch 0 is tilt 0, not -0.0 + let mut block = [0u8; 60]; + block[1] = 1; + let p = parse_entry(&block).unwrap(); + assert_eq!(p.pose.tilt.to_bits(), 0); + } + + #[test] + fn base64_names_use_the_standard_alphabet() { + // RFC 4648 test vectors and the characters after the letters: digits, `+`, `/` + assert_eq!(decode_base64(b"Zm9vYmFy").unwrap(), b"foobar"); + assert_eq!(decode_base64(b"Zm9vYg==").unwrap(), b"foob"); + assert_eq!(decode_base64(b"0123").unwrap(), [0xd3, 0x5d, 0xb7]); + assert_eq!(decode_base64(b"+/+/").unwrap(), [0xfb, 0xff, 0xbf]); + assert_eq!(decode_base64(b"Zm9v!"), None, "not in the alphabet"); + } } diff --git a/crates/obsbot-device/src/models/tiny2lite/status.rs b/crates/obsbot-device/src/models/tiny2lite/status.rs index 62146bc..8f17cab 100644 --- a/crates/obsbot-device/src/models/tiny2lite/status.rs +++ b/crates/obsbot-device/src/models/tiny2lite/status.rs @@ -639,4 +639,33 @@ mod tests { nan[6..10].copy_from_slice(&f32::NAN.to_le_bytes()); assert_eq!(Target::decode(&nan), None); } + + #[test] + fn field_of_view_reading_names_every_value_the_camera_reports() { + // status byte 0x11: 3 is a factor set by hand, reported as `custom` + let all = [ + FovReading::Wide, + FovReading::Medium, + FovReading::Narrow, + FovReading::Custom, + ]; + for (byte, reading) in all.into_iter().enumerate() { + assert_eq!(FovReading::from_u8(byte as u8), Some(reading)); + } + assert_eq!(FovReading::from_u8(4), None); + } + + #[test] + fn anti_flicker_names_every_value_of_power_line_frequency() { + let all = [ + AntiFlicker::Off, + AntiFlicker::Hz50, + AntiFlicker::Hz60, + AntiFlicker::Auto, + ]; + for (byte, reading) in all.into_iter().enumerate() { + assert_eq!(AntiFlicker::from_u8(byte as u8), Some(reading)); + } + assert_eq!(AntiFlicker::from_u8(4), None); + } } diff --git a/crates/obsbot-device/src/scripted.rs b/crates/obsbot-device/src/scripted.rs index d733cbe..b8b2ac7 100644 --- a/crates/obsbot-device/src/scripted.rs +++ b/crates/obsbot-device/src/scripted.rs @@ -2,13 +2,17 @@ //! A scripted camera for tests: a [`Device`](crate::transport::uvc::Device) that answers from a //! table instead of ioctls, so the flows of `Camera` run without a camera. It sits behind the -//! same four methods the real device uses (`xu_get`, `xu_set`, `get_ctrl`, `ctrl_range`) and is -//! compiled in tests only. +//! same five methods the real device uses (`xu_get`, `xu_set`, `get_ctrl`, `set_ctrl`, +//! `ctrl_range`) and is compiled in tests only. use crate::error::{Error, Result}; -use crate::transport::uvc::{CtrlRange, XU_SIZE}; +use crate::family::exchange::Camera; +use crate::family::frame::Sequence; +use crate::transport::uvc::{CtrlRange, Device, XU_SIZE}; use std::cell::RefCell; use std::collections::BTreeMap; +use std::rc::Rc; +use std::sync::OnceLock; /// How the scripted camera treats a framed query. pub(crate) enum Answer { @@ -20,14 +24,21 @@ pub(crate) enum Answer { SendFails, } -/// The table behind a scripted [`Device`]. A query that is not in `answers` is rejected (type -/// 0x89, code -1), so a test never waits out the poll. +/// The table behind a scripted [`Device`]. For a query that is not in `answers` the double +/// itself answers with type 0x89 and code -1, so that a test does not wait out the poll; this is +/// a choice of the double, not a recording of what a camera does. pub(crate) struct Scripted { /// What a read of selector 6 (the status buffer) returns. pub status: [u8; XU_SIZE], pub answers: BTreeMap, /// V4L2 controls the camera has, with their current values. pub ctrls: BTreeMap, + /// (minimum, maximum) of a control that has a range of its own; the others have 0 to 100. + pub ranges: BTreeMap, + /// Every `set_ctrl` call so far, as (control id, value) in call order; a test keeps a clone + /// to read it after the table has moved into a device. The double does not change `ctrls` + /// for them. + pub set_ctrls: Rc>>, /// The last frame written to selector 2, which the next read of selector 2 answers. last: RefCell<[u8; XU_SIZE]>, } @@ -38,6 +49,8 @@ impl Scripted { status, answers: BTreeMap::new(), ctrls: BTreeMap::new(), + ranges: BTreeMap::new(), + set_ctrls: Rc::default(), last: RefCell::new([0; XU_SIZE]), } } @@ -80,16 +93,31 @@ impl Scripted { .ok_or_else(|| Error::Invalid(format!("control {id:#x} is not scripted"))) } + pub fn set_ctrl(&self, id: u32, value: i32) -> Result<()> { + self.set_ctrls.borrow_mut().push((id, value)); + Ok(()) + } + pub fn ctrl_range(&self, id: u32) -> Result> { + let (min, max) = self.ranges.get(&id).copied().unwrap_or((0, 100)); Ok(self.ctrls.contains_key(&id).then_some(CtrlRange { - min: 0, - max: 100, + min, + max, step: 1, default: 50, })) } } +/// A [`Camera`] on a scripted device, with one sequence counter for all of them. +pub(crate) fn camera(script: Scripted) -> Camera { + static SEQ: OnceLock = OnceLock::new(); + Camera { + dev: Device::scripted(script), + seq: SEQ.get_or_init(Sequence::new), + } +} + /// An answer to the query frame `query`: same sequence number, header length 16, then either /// the error code of a rejection or the data section, as `parse_answer` reads it. fn answer(kind: u8, query: &[u8; XU_SIZE], code: Option, data: &[u8]) -> [u8; XU_SIZE] { diff --git a/crates/obsbot-device/src/transport/uvc.rs b/crates/obsbot-device/src/transport/uvc.rs index 206b713..cfef97f 100644 --- a/crates/obsbot-device/src/transport/uvc.rs +++ b/crates/obsbot-device/src/transport/uvc.rs @@ -206,6 +206,10 @@ impl Device { } pub fn set_ctrl(&self, id: u32, value: i32) -> Result<()> { + #[cfg(test)] + if let Some(script) = &self.script { + return script.set_ctrl(id, value); + } let mut c = V4l2Control { id, value }; unsafe { vidioc_s_ctrl(self.file.as_raw_fd(), &mut c) }?; Ok(()) @@ -270,4 +274,48 @@ mod tests { assert!(matches!(check_len(2, 32), Err(Error::Unsupported(_)))); assert!(matches!(check_len(2, 64), Err(Error::Unsupported(_)))); } + + /// A device on a plain temporary file: no device node is opened, and the kernel refuses + /// every extension-unit and V4L2 ioctl on it, so what these tests see is how the code + /// handles a refusal. + fn on_a_plain_file() -> Device { + use std::sync::atomic::{AtomicU32, Ordering}; + static N: AtomicU32 = AtomicU32::new(0); + let path = std::env::temp_dir().join(format!( + "o4l-plain-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let file = lock_exclusive(File::create(&path).unwrap(), LOCK_WAIT).unwrap(); + fs::remove_file(&path).unwrap(); + Device { + file, + detected: Detected { + model: &crate::registry::MODELS[0], + device: "/nonexistent/obsbot-test-video0".into(), + }, + script: None, + } + } + + #[test] + fn a_payload_longer_than_the_buffer_is_refused_before_any_ioctl() { + let device = on_a_plain_file(); + let refused = device.xu_set(2, &[0u8; XU_SIZE + 1]); + assert!(matches!(refused, Err(Error::Invalid(_))), "{refused:?}"); + // up to the buffer size it gets as far as the ioctl, which the plain file refuses + for len in [XU_SIZE - 1, XU_SIZE] { + let result = device.xu_set(2, &vec![0u8; len]); + assert!(matches!(result, Err(Error::Ioctl(_))), "{len}: {result:?}"); + } + } + + #[test] + fn a_refused_ioctl_is_an_error_not_a_default_value() { + let device = on_a_plain_file(); + assert!(matches!(device.xu_get(2), Err(Error::Ioctl(_)))); + assert!(matches!(device.get_ctrl(1), Err(Error::Ioctl(_)))); + assert!(matches!(device.set_ctrl(1, 0), Err(Error::Ioctl(_)))); + assert!(matches!(device.ctrl_range(1), Err(Error::Ioctl(_)))); + } } diff --git a/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md b/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md index 5fabf51..c36c7a1 100644 --- a/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md +++ b/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md @@ -2599,6 +2599,12 @@ Expected on top of 66287b5: `847`; per file: `enumeration.rs` 16, `family/exchan `models/tiny2lite/presets.rs` 169, `models/tiny2lite/status.rs` 81, `registry.rs` 5, `transport/uvc.rs` 36 (other numbers if `main` added code; the run records what it found). +`cargo mutants` lists no mutant inside `#[cfg(test)]` items: `scripted.rs` (the scripted device) +and `Device::scripted` are not mutated and not counted. The `#[cfg(test)]` hooks inside the +bodies of `Device::xu_get`, `xu_set`, `get_ctrl`, `set_ctrl` and `ctrl_range` are part of those +production functions, so a mutant that replaces such a function replaces its hook too; the +tests run against that mutated function like against any other. + - [ ] **Step 3: Run the pass** (in place, one mutant at a time, builds with three jobs and low priority: in-place mutation needs a clean tree and touches only this worktree; about 20 to 30 minutes) @@ -2638,7 +2644,8 @@ Each line of `missed.txt` gets exactly one: - **L, 0.13 only:** a write of the remembered state (`State { … }.save()`) inside a flow; not carried (spec 3.2). - **E, equivalent:** no input can tell the mutant from the code; one sentence why. -- **M, waits for main:** the named mutants of Step 5 while their test seam is not on `main`. +- **M, waits for a seam:** the named mutants of Step 5 while their test seam is not on the + branch the pass runs on (`rebuild` carries P3, so none is expected). A survivor in a function that does no I/O (a builder, decoder, conversion or parser) may only be K or E. @@ -2648,7 +2655,7 @@ be K or E. ```bash F=crates/obsbot-device/src grep 'delete match arm 0x89 in parse_answer' "$SCRATCH/mutants/mutants.out/caught.txt" -grep -E 'delete - in (tilt_to_pitch_range|pitch_to_tilt_range|HandArea::decode|LivePose::decode|Camera::gimbal)' \ +grep -E 'delete - in (tilt_to_pitch|tilt_to_pitch_range|pitch_to_tilt_range|HandArea::decode|LivePose::decode)' \ "$SCRATCH/mutants/mutants.out/caught.txt" "$SCRATCH/mutants/mutants.out/missed.txt" # a rejected answer handled as stale (hand-made: cargo-mutants cannot delete that arm) sed -i 's/Some(Answer::Rejected(code)) => return Err(Error::Rejected(command, code)),/Some(Answer::Rejected(_)) => {}/' $F/family/exchange.rs @@ -2661,13 +2668,13 @@ git checkout -- $F/legacy/full_status.rs git status --short ``` -Expected on top of 66287b5: the `0x89` arm deletion is in `caught.txt`; every `delete -` in -the four conversion functions is caught, and `delete - in Camera::gimbal` (the tilt negation -of the gimbal move) is in `missed.txt`; both hand-made mutants compile and give five -`test result: ok` lines (survived); `git status` prints nothing. The three survivors get disposition M and -are listed in the pull request as open items of the stage acceptance (see "After the tasks"). -Once `main` has the seams of "Before Task 1", P3, and `rebuild` is rebased, this step is -repeated and must show all of them caught (a hand-made mutant then gives a `test result: FAILED` line). +Expected on `rebuild` with the seams of P3 in: the `0x89` arm deletion is in `caught.txt`; every +`delete -` in the five conversion functions is caught (the tilt negation of the gimbal move is +`tilt_to_pitch` since #153, not a line of `Camera::gimbal`); both hand-made mutants compile and +make a test fail (a `test result: FAILED` line; add `--no-fail-fast` to see every test binary); +`git status` prints nothing. A named mutant whose seam is missing (survives, or gives only +`test result: ok` lines) gets disposition M and is listed in the pull request as an open item of +the stage acceptance (see "After the tasks"); on `rebuild` with P3 in, none is. - [ ] **Step 6: Gate and guards** @@ -2707,7 +2714,10 @@ tracking issue of the stage, and the task ends there. Not tasks of this plan; listed so that nobody releases early. -1. The named mutants of Task 8, Step 5 are all caught (needs P3 on `main` and a rebase). +1. The named mutants of Task 8, Step 5 are all caught (needs P3 on the branch the pass runs on; + it is on `rebuild`). One open item from that pass stays: the call site of `tilt_to_pitch` in + `Camera::gimbal` (the mutant `let tilt_wire = tilt;` survives, because no test calls + `Camera::gimbal`); it needs a seam for the state path or the replay transport of Track A. 2. The camera smoke test CS-0 (spec 10.2, CAM-30) on the real Tiny 2 Lite with a build of the tip of `rebuild`: `status`, one setting set and read back, one gimbal move, `preset list`. 3. A release pull request into `rebuild`: workspace version 0.14.0, `Cargo.lock`, PKGBUILD