Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 110 additions & 12 deletions crates/obsbot-device/src/family/exchange.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use crate::error::{Error, Result};
use crate::family::frame::{Answer, Sequence, parse_answer, query_frame};
use crate::transport::uvc::Device;
use crate::transport::uvc::{Device, XU_SIZE};
use std::sync::OnceLock;
use std::thread::sleep;
use std::time::Duration;
Expand Down Expand Up @@ -63,18 +63,116 @@ impl Camera {

/// Polls selector 2 for the answer to the query with `seq`.
pub(crate) fn answer(&self, command: u16, seq: u16) -> Result<Vec<u8>> {
for _ in 0..REPLY_ATTEMPTS {
sleep(REPLY_GAP);
// a read before the answer is ready can fail; just poll again
if let Ok(reply) = self.dev.xu_get(2) {
match parse_answer(&reply, seq, command) {
Some(Answer::Value(data)) => return Ok(data.to_vec()),
Some(Answer::Empty) => return Ok(Vec::new()),
Some(Answer::Rejected(code)) => return Err(Error::Rejected(command, code)),
None => {}
}
poll_answer(command, seq, REPLY_GAP, || self.dev.xu_get(2))
}
}

/// The poll of [`Camera::answer`] on any source of selector-2 reads, so a test can script the
/// replies. Waits `gap` before each of `REPLY_ATTEMPTS` reads.
pub(crate) fn poll_answer(
command: u16,
seq: u16,
gap: Duration,
mut read: impl FnMut() -> Result<[u8; XU_SIZE]>,
) -> Result<Vec<u8>> {
for _ in 0..REPLY_ATTEMPTS {
sleep(gap);
// a read before the answer is ready can fail; just poll again
if let Ok(reply) = read() {
match parse_answer(&reply, seq, command) {
Some(Answer::Value(data)) => return Ok(data.to_vec()),
Some(Answer::Empty) => return Ok(Vec::new()),
Some(Answer::Rejected(code)) => return Err(Error::Rejected(command, code)),
None => {}
}
}
Err(Error::NoAnswer(command))
}
Err(Error::NoAnswer(command))
}

#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;

const COMMAND: u16 = 0x1344;
const SEQ: u16 = 7;

/// A selector-2 reply of the given type for `SEQ`, as `parse_answer` reads it: header
/// length 16, an optional error code in the header extension, an optional data section.
fn reply(kind: u8, command: u16, code: Option<i16>, data: &[u8]) -> [u8; XU_SIZE] {
let mut r = [0u8; XU_SIZE];
r[0] = 0xaa;
r[1] = kind;
r[2..4].copy_from_slice(&SEQ.to_le_bytes());
r[4..6].copy_from_slice(&16u16.to_le_bytes());
r[10..12].copy_from_slice(&command.to_le_bytes());
if let Some(code) = code {
r[12..14].copy_from_slice(&[0x00, 0x02]);
r[14..16].copy_from_slice(&code.to_le_bytes());
} else {
r[12..14].copy_from_slice(&(data.len() as u16).to_le_bytes());
}
r[16..16 + data.len()].copy_from_slice(data);
r
}

/// Polls with scripted reads, no waiting; returns the result and the number of reads taken.
fn poll(script: impl Fn(usize) -> Result<[u8; XU_SIZE]>) -> (Result<Vec<u8>>, usize) {
let reads = Cell::new(0);
let result = poll_answer(COMMAND, SEQ, Duration::ZERO, || {
let n = reads.get();
reads.set(n + 1);
script(n)
});
(result, reads.get())
}

#[test]
fn a_rejected_answer_returns_the_cameras_code_at_the_first_poll() {
// a refused command must return the camera's code at once, not poll on until "no answer"
let (result, reads) = poll(|_| Ok(reply(0x89, COMMAND, Some(-3), &[])));
assert!(
matches!(result, Err(Error::Rejected(COMMAND, -3))),
"{result:?}"
);
assert_eq!(reads, 1);
}

#[test]
fn an_empty_answer_is_an_empty_list_not_a_missing_one() {
let (result, reads) = poll(|_| Ok(reply(0x09, COMMAND, None, &[])));
assert_eq!(result.unwrap(), Vec::<u8>::new());
assert_eq!(reads, 1);
}

#[test]
fn without_a_matching_answer_the_poll_gives_up_after_all_attempts() {
// stale replies: the answer to another query (sequence) never matches
let (result, reads) = poll(|_| {
let mut stale = reply(0x29, COMMAND, None, &[1]);
stale[2..4].copy_from_slice(&(SEQ + 1).to_le_bytes());
Ok(stale)
});
assert!(
matches!(result, Err(Error::NoAnswer(COMMAND))),
"{result:?}"
);
// 20 reads at 50 ms: the one second a caller waits for a camera that does not answer
assert_eq!(reads, 20);
}

#[test]
fn a_failed_read_is_polled_again_and_a_later_value_is_returned() {
// the camera is not ready for the first reads; the value that follows is the answer
let (result, reads) = poll(|n| {
if n < 2 {
Err(Error::Invalid("not ready".into()))
} else {
Ok(reply(0x29, COMMAND, None, &[4, 5, 6]))
}
});
assert_eq!(result.unwrap(), vec![4, 5, 6]);
assert_eq!(reads, 3);
}
}
99 changes: 99 additions & 0 deletions crates/obsbot-device/src/legacy/full_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,102 @@ mod full_status_tests {
assert!(v["status"].is_object() && v["capabilities"].is_array());
}
}

/// `full_status_with` on a scripted camera (no device is opened).
#[cfg(test)]
mod scripted_camera_tests {
use super::*;
use crate::family::exchange::Camera;
use crate::family::frame::Sequence;
use crate::scripted::{Answer, Scripted};
use crate::transport::uvc::Device;
use std::sync::OnceLock;

/// A camera that is awake, with HDR on and the LED in mode 2; it answers the zoom factor
/// (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 {
let mut status = [0u8; 60];
status[0x06] = 1;
status[0x21] = 2;
let mut script = Scripted::new(status);
script.answers.insert(0x0104, tracking);
script
.answers
.insert(0x6804, Answer::Value(2.0f32.to_le_bytes().to_vec()));
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());
script.answers.insert(0x0043, Answer::Value(pose));
script
.answers
.insert(0x80c2, Answer::Value(55i16.to_le_bytes().to_vec()));
script.ctrls.insert(ZOOM_ABSOLUTE, 37);
static SEQ: OnceLock<Sequence> = OnceLock::new();
Camera {
dev: Device::scripted(script),
seq: SEQ.get_or_init(Sequence::new),
}
}

/// The zone readbacks and the identity the daemon keeps, so the test asks only for the rest.
fn known() -> (Option<ZoneInfo>, Option<Identity>) {
(
Some(ZoneInfo {
zone: None,
hand: None,
startup: None,
framing: None,
presets: None,
}),
Some(Identity {
firmware: Some("6.2.8.1".into()),
model_info: None,
}),
)
}

#[test]
fn a_rejected_tracking_query_gives_tracking_null_and_the_rest_of_the_status() {
// the camera refuses 0x0104: status and the daemon's broadcast must still show
// everything else, with `tracking: null`, not fail as a whole
let (zones, identity) = known();
let full = camera(Answer::Rejected(-3))
.full_status_with(zones, identity)
.expect("a rejected tracking query does not fail the read");
assert!(full.tracking.is_none());
assert!(full.status.hdr);
assert_eq!(full.status.led, 2);
assert_eq!(full.zoom.factor, Some(2.0));
assert_eq!(full.zoom.absolute, Some(37));
let pose = full
.pose
.expect("the live pose is read after the tracking query");
assert_eq!((pose.pan, pose.tilt), (25.0, 10.0));
assert_eq!(full.audio_volume, Some(55));
assert_eq!(full.firmware.as_deref(), Some("6.2.8.1"));
assert_eq!(full.model, Some("Tiny 2 Lite"));
}

#[test]
fn an_answered_tracking_query_is_in_the_status() {
// the same script with a real answer: tracking is filled, so the test above sees the
// arm and not a camera that never reports tracking
let mut answer = vec![0u8; 12];
answer[0] = 1; // group
answer[10] = 1; // zone tracking
let (zones, identity) = known();
let full = camera(Answer::Value(answer))
.full_status_with(zones, identity)
.unwrap();
assert!(full.tracking.is_some_and(|t| t.zone_tracking));
}

#[test]
fn any_other_tracking_error_fails_the_whole_read() {
// only "no answer" and "rejected" are tolerated; a failed write is a broken camera
let (zones, identity) = known();
let result = camera(Answer::SendFails).full_status_with(zones, identity);
assert!(matches!(result, Err(Error::Io(_))), "{result:?}");
}
}
2 changes: 2 additions & 0 deletions crates/obsbot-device/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ pub mod family;
pub mod legacy;
pub mod models;
pub mod registry;
#[cfg(test)]
pub(crate) mod scripted;
pub mod transport;
55 changes: 54 additions & 1 deletion crates/obsbot-device/src/models/tiny2lite/camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ fn tilt_to_pitch_range(tilt: (f32, f32)) -> (f32, f32) {
(-tilt.1, -tilt.0)
}

/// A tilt (obsbot4linux's angle, positive up) as the pose commands take it (pitch, positive
/// down). Adding 0.0 turns -0.0 into 0.0, so a tilt of 0 is sent as +0.0.
fn tilt_to_pitch(tilt: Option<f32>) -> Option<f32> {
tilt.map(|t| -t + 0.0)
}

/// A pitch range (min, max) as the camera reports it (positive down) in obsbot4linux's angles
/// (positive up): min and max swap and flip.
fn pitch_to_tilt_range(pitch: (f32, f32)) -> (f32, f32) {
Expand Down Expand Up @@ -805,7 +811,7 @@ impl Camera {
pub fn gimbal(&self, pan: Option<f32>, tilt: Option<f32>, speed: Option<f32>) -> Result<()> {
// build first so invalid input fails before anything is sent
let seq = self.seq.next();
let tilt_wire = tilt.map(|t| -t + 0.0);
let tilt_wire = tilt_to_pitch(tilt);
let frame = match speed {
Some(s) => cmd::gimbal_pose_speed(pan, tilt_wire, s, seq)?,
None => cmd::gimbal_pose(pan, tilt_wire, seq)?,
Expand Down Expand Up @@ -1034,6 +1040,53 @@ mod live_pose_tests {
}
}

#[cfg(test)]
mod gimbal_tilt_tests {
use super::*;
use crate::family::frame::FRAME_SIZE;

/// A frame built with the captured sequence number must equal the captured bytes.
fn assert_captured(built: [u8; FRAME_SIZE], captured: &str) {
let captured = hex::decode(captured).unwrap();
assert_eq!(built[..captured.len()], captured[..]);
assert!(built[captured.len()..].iter().all(|b| *b == 0));
}

#[test]
fn tilt_up_is_sent_as_the_negated_pitch() {
// `gimbal --tilt 20` has to tilt the camera up; the camera's pitch is positive down.
assert_eq!(tilt_to_pitch(Some(20.0)), Some(-20.0));
assert_eq!(tilt_to_pitch(Some(-20.0)), Some(20.0));
assert_eq!(tilt_to_pitch(Some(-70.0)), Some(70.0));
}

#[test]
fn a_tilt_of_zero_is_sent_as_positive_zero() {
// no -0.0 on the wire: it would put the sign bit into the frame
assert_eq!(tilt_to_pitch(Some(0.0)).unwrap().to_bits(), 0);
assert_eq!(tilt_to_pitch(Some(-0.0)).unwrap().to_bits(), 0);
}

#[test]
fn no_tilt_keeps_the_axis() {
assert_eq!(tilt_to_pitch(None), None);
}

#[test]
fn the_converted_tilt_builds_the_captured_pose_frames() {
// capture 18: the pitch word of the first pose is -30.0 and that of the second +0.0, so
// a tilt of 30 (up) and a tilt of 0 give these frames; the unconverted value would not.
assert_captured(
cmd::gimbal_pose(None, tilt_to_pitch(Some(30.0)), 28).unwrap(),
"aa251c000c000b250a0444640c000f1600007ac40000f0c100007ac4",
);
assert_captured(
cmd::gimbal_pose(Some(0.0), tilt_to_pitch(Some(0.0)), 33).unwrap(),
"aa2521000c009a200a0444640c0004c400007ac40000000000000000",
);
}
}

#[cfg(test)]
mod angle_conversion_tests {
use super::*;
Expand Down
Loading