diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e70117..1a7ed57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: with: persist-credentials: false - run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --component clippy --no-self-update - - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo clippy --workspace --all-targets --locked -- -D warnings test: needs: changes @@ -79,7 +79,7 @@ jobs: with: persist-credentials: false - run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --no-self-update - - run: cargo test --locked + - run: cargo test --workspace --locked # Builds and tests with the oldest Rust that Cargo.toml's rust-version claims. msrv: @@ -94,7 +94,7 @@ jobs: msrv=$(sed -n 's/^rust-version = "\(.*\)"/\1/p' Cargo.toml) test -n "$msrv" rustup toolchain install "$msrv" --profile minimal --no-self-update - cargo "+$msrv" test --locked + cargo "+$msrv" test --workspace --locked # The CHANGELOG section of the Cargo.toml version, read as release.yml's verify job reads it. # Always runs (it is cheap, and a docs-only change can edit CHANGELOG.md), so a release commit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4db498c..f4d928d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ on: - scripts/** - Cargo.toml - Cargo.lock + - crates/*/Cargo.toml - README.md - CHANGELOG.md - LICENSE @@ -115,13 +116,13 @@ jobs: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --component rustfmt --component clippy --no-self-update - run: cargo fmt --check - - run: cargo clippy --all-targets --locked -- -D warnings - - run: cargo test --locked + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + - run: cargo test --workspace --locked - run: | msrv=$(sed -n 's/^rust-version = "\(.*\)"/\1/p' Cargo.toml) test -n "$msrv" rustup toolchain install "$msrv" --profile minimal --no-self-update - cargo "+$msrv" test --locked + cargo "+$msrv" test --workspace --locked # One job per package format and architecture: a failure reruns only that job. binary: @@ -200,7 +201,7 @@ jobs: mkdir dist # no rebuild: package the binary from the binary job (artifacts drop the mode) chmod +x target/release/obsbot4linux - cargo deb --no-build --deb-revision "" -o "dist/obsbot4linux_${ver}_$DEB.deb" + cargo deb -p obsbot4linux --no-build --deb-revision "" -o "dist/obsbot4linux_${ver}_$DEB.deb" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dist-deb-${{ matrix.arch }} @@ -237,7 +238,7 @@ jobs: ver=$VER mkdir dist chmod +x target/release/obsbot4linux - cargo generate-rpm -o "dist/obsbot4linux-$ver-1.$ARCH.rpm" + cargo generate-rpm -p crates/obsbot4linux -o "dist/obsbot4linux-$ver-1.$ARCH.rpm" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dist-rpm-${{ matrix.arch }} @@ -276,10 +277,60 @@ jobs: path: dist/* if-no-files-found: error + # Lists the files of every package and compares the list with packaging/contents.txt, so that + # a change to what a package holds shows up in review. A release needs it too. + contents: + needs: [version, binary, deb, rpm, arch] + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + path: dist + pattern: dist-* + merge-multiple: true + - run: sudo apt-get install -y --no-install-recommends libarchive-tools + # [profile.release] strip = true must reach every package (cargo ignores a member + # manifest's profile, and the file list alone would not show it) + - name: The packaged binaries are stripped + run: | + for pkg in dist/obsbot4linux*; do + case $pkg in + *.deb) bsdtar -xOf "$pkg" 'data.tar*' > payload ;; + *) cp "$pkg" payload ;; + esac + bin=$(bsdtar -tf payload | grep -E '(usr/bin|-linux)/obsbot4linux$') + bsdtar -xOf payload "$bin" > obsbot4linux.bin + info=$(file -b obsbot4linux.bin) + echo "$pkg: $info" + case $info in + *', stripped') ;; + *) echo "::error::$pkg ships an unstripped binary"; exit 1 ;; + esac + done + rm -f payload obsbot4linux.bin + - name: Compare the package contents + env: + VER: ${{ needs.version.outputs.ver }} + run: | + scripts/package-contents.sh dist "$VER" > contents.txt + cat contents.txt + diff -u packaging/contents.txt contents.txt + # the list itself, also when it differs, to update packaging/contents.txt from + - if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package-contents + path: contents.txt + if-no-files-found: error + # Tags only: a pull request builds the packages above and publishes nothing. release: if: startsWith(github.ref, 'refs/tags/') - needs: [binary, deb, rpm, arch] + needs: [binary, deb, rpm, arch, contents] runs-on: ubuntu-22.04 timeout-minutes: 15 permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3217627..d82229f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ All notable changes to OBSBot4Linux. The format follows - The repository moved to github.com/hellwand/obsbot4linux on 2026-11-12; the old address redirects (the link change itself landed in #109). - README and package text now say "most settings OBSBOT Center offers" and link `docs/center-parity.md` for the gaps; they also state the glibc 2.34 floor, how to switch to the socket units while the widget's daemon runs, to restart the daemon after an upgrade (socket users: `systemctl --user restart obsbot4linux.service`; a daemon started by `watch` ends with the shell's unit), that `zoom F --speed S` and `zoom stop` leave the AI mode alone, the `device busy` window of the on-air probe on uvcvideo up to 6.16, and the order of auto and manual image controls (#121). +- The source is a cargo workspace now: `crates/obsbot-device` holds the camera access, + `crates/obsbot4linux` the CLI and the daemon. The program, its output and the packages do not + change. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c54bd13..64fc003 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,8 @@ Every change goes through an issue and a pull request, also the maintainer's: 1. Open an issue: what is wrong or missing, how to reproduce it, which camera and firmware. 2. Open a pull request against `main` whose description starts with `Fixes #`. -3. CI must be green (`cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, - `cargo test`, and `cargo test` again with the `rust-version` from `Cargo.toml`). +3. CI must be green (`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, + `cargo test --workspace`, and `cargo test --workspace` again with the `rust-version` from `Cargo.toml`). Changes that only touch docs (Markdown files, `LICENSE`, `docs/`) skip those four; the `required` check still reports. No changelog entry is enforced per pull request; the release tag needs its `CHANGELOG.md` section. diff --git a/Cargo.lock b/Cargo.lock index 32c3f13..6ec3088 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,7 +165,7 @@ dependencies = [ ] [[package]] -name = "obsbot4linux" +name = "obsbot-device" version = "0.13.2" dependencies = [ "clap", @@ -176,6 +176,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "obsbot4linux" +version = "0.13.2" +dependencies = [ + "clap", + "nix", + "obsbot-device", + "serde", + "serde_json", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" diff --git a/Cargo.toml b/Cargo.toml index a71a6e4..c853df5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,57 +1,25 @@ -[package] -name = "obsbot4linux" +[workspace] +resolver = "3" +members = ["crates/obsbot-device", "crates/obsbot4linux"] + +[workspace.package] version = "0.13.2" edition = "2024" rust-version = "1.88" -description = "Control OBSBOT webcams on Linux from the console and the DMS widget" authors = ["Thomas Kroll"] license = "GPL-3.0-or-later" repository = "https://github.com/hellwand/obsbot4linux" homepage = "https://github.com/hellwand/obsbot4linux" -readme = "README.md" publish = false -[lib] -name = "obsbot4linux" -path = "src/lib.rs" - -[[bin]] -name = "obsbot4linux" -path = "src/main.rs" - -[dependencies] +[workspace.dependencies] +obsbot-device = { path = "crates/obsbot-device" } clap = { version = "4", features = ["derive"] } nix = { version = "0.31", features = ["fs", "ioctl"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" +hex = "0.4" [profile.release] strip = true - -[dev-dependencies] -hex = "0.4" - -[package.metadata.deb] -maintainer = "Thomas Kroll " -section = "video" -extended-description = "Most settings OBSBOT Center offers, from the console and from the DMS bar widget.\nWhat is missing: https://github.com/hellwand/obsbot4linux/blob/main/docs/center-parity.md\nThe obsbot4linux daemon (systemd user socket) owns the camera state." -depends = "$auto" -license-file = ["LICENSE", "0"] -assets = [ - ["target/release/obsbot4linux", "usr/bin/", "755"], - ["packaging/systemd/obsbot4linux.service", "usr/lib/systemd/user/", "644"], - ["packaging/systemd/obsbot4linux.socket", "usr/lib/systemd/user/", "644"], - ["README.md", "usr/share/doc/obsbot4linux/", "644"], - ["CHANGELOG.md", "usr/share/doc/obsbot4linux/", "644"], -] - -[package.metadata.generate-rpm] -assets = [ - { source = "target/release/obsbot4linux", dest = "/usr/bin/obsbot4linux", mode = "755" }, - { source = "packaging/systemd/obsbot4linux.service", dest = "/usr/lib/systemd/user/obsbot4linux.service", mode = "644" }, - { source = "packaging/systemd/obsbot4linux.socket", dest = "/usr/lib/systemd/user/obsbot4linux.socket", mode = "644" }, - { source = "README.md", dest = "/usr/share/doc/obsbot4linux/README.md", mode = "644", doc = true }, - { source = "CHANGELOG.md", dest = "/usr/share/doc/obsbot4linux/CHANGELOG.md", mode = "644", doc = true }, - { source = "LICENSE", dest = "/usr/share/licenses/obsbot4linux/LICENSE", mode = "644" }, -] diff --git a/README.md b/README.md index 05c120d..7f93e6c 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,7 @@ it does not arm the flag but consumes one that a recall or `0x6604` armed earlie goes Static on arrival; afterwards tracking may stay suspended, since the view's actions name no AI mode, as with `startup go`. Not seen so far: tracking stayed on after a `zone view go` in AI normal. The flag part is still to be confirmed. -Adding a model: an entry in `MODELS` in `src/models.rs` (name, USB id, by-id and card name +Adding a model: an entry in `MODELS` in `crates/obsbot-device/src/registry.rs` (name, USB id, by-id and card name patterns) makes it detected. Its commands and status decoding differ per camera and today exist for the Tiny 2 Lite only; the plan is one module per model behind the same commands, once a camera is at hand to capture and test (see [CONTRIBUTING.md](CONTRIBUTING.md)). diff --git a/crates/obsbot-device/Cargo.toml b/crates/obsbot-device/Cargo.toml new file mode 100644 index 0000000..747c50b --- /dev/null +++ b/crates/obsbot-device/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "obsbot-device" +description = "Camera access for obsbot4linux: transport, frame codec and the camera models" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish.workspace = true + +[dependencies] +clap.workspace = true +nix.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +hex.workspace = true diff --git a/crates/obsbot-device/src/enumeration.rs b/crates/obsbot-device/src/enumeration.rs new file mode 100644 index 0000000..2271f0b --- /dev/null +++ b/crates/obsbot-device/src/enumeration.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. + +//! Finding a supported camera without talking to it: `/dev/v4l/by-id` links first, the V4L2 +//! card name as fallback. + +use crate::registry::{self, Model}; +use nix::ioctl_read; +use std::fs::{self, File}; +use std::os::fd::AsRawFd; +use std::path::PathBuf; + +const V4L2_CAP_META_CAPTURE: u32 = 0x0080_0000; + +#[repr(C)] +#[derive(Default)] +struct V4l2Capability { + driver: [u8; 16], + card: [u8; 32], + bus_info: [u8; 32], + version: u32, + capabilities: u32, + device_caps: u32, + reserved: [u32; 3], +} + +ioctl_read!(vidioc_querycap, b'V', 0, V4l2Capability); + +/// A camera found by enumeration, before any command is sent to it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Detected { + pub model: &'static Model, + /// The V4L2 node to stream from: the stable `/dev/v4l/by-id/...-video-index0` link when + /// there is one, else `/dev/videoN`. + pub device: PathBuf, +} + +fn card_name(file: &File) -> Option<(String, u32)> { + let mut cap = V4l2Capability::default(); + unsafe { vidioc_querycap(file.as_raw_fd(), &mut cap) }.ok()?; + let end = cap + .card + .iter() + .position(|b| *b == 0) + .unwrap_or(cap.card.len()); + Some(( + String::from_utf8_lossy(&cap.card[..end]).into_owned(), + cap.device_caps, + )) +} + +/// Finds a supported camera without talking to it: by-id symlink first, V4L2 card name +/// (VIDIOC_QUERYCAP, a read-only ioctl) as fallback. +pub fn detect() -> Option { + let by_id = fs::read_dir("/dev/v4l/by-id") + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .collect(); + if let Some(detected) = first_by_id(by_id) { + return Some(detected); + } + + let mut paths: Vec = fs::read_dir("/dev") + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .is_some_and(|n| n.to_string_lossy().starts_with("video")) + }) + .collect(); + paths.sort(); + for path in paths { + if let Ok(file) = File::open(&path) + && let Some((card, caps)) = card_name(&file) + && caps & V4L2_CAP_META_CAPTURE == 0 + && let Some(model) = registry::from_card(&card) + { + return Some(Detected { + model, + device: path, + }); + } + } + None +} + +/// The first supported camera among the by-id links, sorted: `read_dir` order is unspecified, +/// so with two cameras the controlled one could change between runs. +fn first_by_id(mut links: Vec) -> Option { + links.sort(); + links.into_iter().find_map(|device| { + let model = registry::from_by_id(&device.file_name()?.to_string_lossy())?; + Some(Detected { model, device }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn by_id_detection_does_not_depend_on_directory_order() { + let link = |serial: &str| { + PathBuf::from(format!( + "/dev/v4l/by-id/usb-Remo_Tech_Co.__Ltd._OBSBOT_Tiny_2_Lite_{serial}-video-index0" + )) + }; + let other = PathBuf::from("/dev/v4l/by-id/usb-Logitech_BRIO-video-index0"); + let picked = first_by_id(vec![other.clone(), link("B"), link("A")]).unwrap(); + assert_eq!(picked.device, link("A")); + let picked = first_by_id(vec![link("A"), link("B"), other]).unwrap(); + assert_eq!(picked.device, link("A")); + assert_eq!(first_by_id(Vec::new()), None); + } +} diff --git a/src/error.rs b/crates/obsbot-device/src/error.rs similarity index 100% rename from src/error.rs rename to crates/obsbot-device/src/error.rs diff --git a/crates/obsbot-device/src/family/exchange.rs b/crates/obsbot-device/src/family/exchange.rs new file mode 100644 index 0000000..971bef9 --- /dev/null +++ b/crates/obsbot-device/src/family/exchange.rs @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. + +//! The exchange with the camera's extension unit: short commands, framed commands and the +//! polling for their answers, with one sequence number counter for the whole process. + +use crate::error::{Error, Result}; +use crate::family::frame::{Answer, Sequence, parse_answer, query_frame}; +use crate::transport::uvc::{Device, XU_SIZE}; +use std::sync::OnceLock; +use std::thread::sleep; +use std::time::Duration; + +const REPLY_GAP: Duration = Duration::from_millis(50); +const REPLY_ATTEMPTS: usize = 20; +/// Gap between the two frames of a pair (preset delete): back to back the camera applies neither. +pub(crate) const PAIR_GAP: Duration = Duration::from_millis(100); + +/// One sequence for the whole process, so frames from separate `Camera` handles (the daemon +/// opens the device per operation) never repeat a number. +fn sequence() -> &'static Sequence { + static SEQ: OnceLock = OnceLock::new(); + SEQ.get_or_init(Sequence::new) +} + +pub struct Camera { + pub(crate) dev: Device, + pub(crate) seq: &'static Sequence, +} + +impl Camera { + pub fn open() -> Result { + Ok(Self { + dev: Device::open()?, + seq: sequence(), + }) + } + + pub(crate) fn short(&self, payload: &[u8]) -> Result<()> { + self.dev.xu_set(6, payload) + } + + pub(crate) fn framed(&self, frame: &[u8]) -> Result<()> { + self.dev.xu_set(2, frame) + } + + pub(crate) fn pair(&self, frames: [[u8; 60]; 2]) -> Result<()> { + self.framed(&frames[0])?; + sleep(PAIR_GAP); + self.framed(&frames[1]) + } + + /// Sends a query and polls selector 2 for the answer with the same sequence number. + pub(crate) fn query(&self, command: u16) -> Result> { + self.query_to(0x04, command) + } + + pub(crate) fn query_to(&self, receiver: u8, command: u16) -> Result> { + let seq = self.seq.next(); + self.framed(&query_frame(receiver, command, seq))?; + self.answer(command, seq) + } + + /// Polls selector 2 for the answer to the query with `seq`. + pub(crate) fn answer(&self, command: u16, seq: u16) -> Result> { + 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> { + 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)) +} + +#[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, 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>, 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::::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); + } + + 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/src/frame.rs b/crates/obsbot-device/src/family/frame.rs similarity index 84% rename from src/frame.rs rename to crates/obsbot-device/src/family/frame.rs index 6b7f8bf..03b4a12 100644 --- a/src/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/family/mod.rs b/crates/obsbot-device/src/family/mod.rs new file mode 100644 index 0000000..e2398a7 --- /dev/null +++ b/crates/obsbot-device/src/family/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Building blocks that are not tied to one camera model: the frame codec and the on-air probe. + +pub mod exchange; +pub mod frame; +pub mod streaming; diff --git a/src/streaming.rs b/crates/obsbot-device/src/family/streaming.rs similarity index 100% rename from src/streaming.rs rename to crates/obsbot-device/src/family/streaming.rs diff --git a/crates/obsbot-device/src/legacy/current_pose.rs b/crates/obsbot-device/src/legacy/current_pose.rs new file mode 100644 index 0000000..f30cbec --- /dev/null +++ b/crates/obsbot-device/src/legacy/current_pose.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. + +//! The pose 0.13 saves when none is given: the remembered gimbal pose, else the one V4L2 +//! last set, with the live zoom factor. + +use crate::error::Result; +use crate::family::exchange::Camera; +use crate::legacy::state::State; +use crate::models::tiny2lite::presets::{MAX_ZOOM, Pose}; + +const PAN_ABSOLUTE: u32 = 0x009a_0908; +const TILT_ABSOLUTE: u32 = 0x009a_0909; + +impl Camera { + /// The pose to save: the remembered gimbal pose, else the one V4L2 last set (stale + /// after tracking moved the gimbal), plus the live zoom factor. + pub(crate) fn current_pose(&self) -> Result { + let state = State::load().gimbal; + let v4l2 = |id| self.dev.get_ctrl(id).map(|v| v as f32 / 3600.0); + let pan = match state.and_then(|g| g.pan) { + Some(p) => p, + None => v4l2(PAN_ABSOLUTE)?, + }; + let tilt = match state.and_then(|g| g.tilt) { + Some(t) => t, + None => v4l2(TILT_ABSOLUTE)?, + }; + Ok(Pose { + pan, + tilt, + zoom: self.zoom_factor()?.clamp(1.0, MAX_ZOOM), + }) + } +} diff --git a/crates/obsbot-device/src/legacy/full_status.rs b/crates/obsbot-device/src/legacy/full_status.rs new file mode 100644 index 0000000..94fcd64 --- /dev/null +++ b/crates/obsbot-device/src/legacy/full_status.rs @@ -0,0 +1,549 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. + +//! The status of 0.13 (`status --json` and the daemon's state): the full status, its +//! capability list and limits, and the readbacks the daemon keeps between full reads. + +use crate::error::{Error, Result}; +use crate::family::exchange::Camera; +use crate::family::streaming; +use crate::legacy::state::{Startup, State}; +use crate::models::tiny2lite::camera::{ + Framing, Hand, Identity, ImageControl, LivePose, ZOOM_ABSOLUTE, Zone, +}; +use crate::models::tiny2lite::commands::*; +use crate::models::tiny2lite::presets::{MAX_ZOOM, Preset}; +use crate::models::tiny2lite::status::{Focus, ModelInfo, Status, Target, Tracking}; +use serde::Serialize; +use std::collections::BTreeMap; + +/// Commands obsbot4linux supports, for the `capabilities` list of `status --json`. +pub const CAPABILITIES: &[&str] = &[ + "sleep", + "wake", + "tracking", + "speed", + "hdr", + "exposure", + "zoom", + "gimbal", + "gimbal_speed", + "gimbal_stop", + "zoom_speed", + "speed_mode", + "speed_headroom", + "framing", + "fov", + "led", + "led_blink", + "mirror", + "invert", + "auto_sleep", + "sleep_background", + "sleep_background_mirror", + "audio", + "audio_volume", + "gesture", + "hand", + "zone", + "zone_follow", + "zone_return", + "zone_views", + "hand_return", + "hand_area", + "hand_axes", + "hand_return_after", + "hand_gimbal", + "focus", + "focus_af_mode", + "focus_point", + "wake_zoom", + "wake_mode", + "exposure_values", + "startup", + "startup_ai", + "image", + "preset", + "preset_state", + "factory_reset", + "target", + "live_pose_on_demand", +]; + +#[derive(Debug, Serialize)] +pub struct Zoom { + /// Current zoom factor from query 0x6804 (follows gestures and preset recalls). + pub factor: Option, + /// UVC zoom_absolute, 0-100 = 1x-4x: the value last written over V4L2. It does not follow + /// a field of view change, a gesture or a preset recall; `factor` does. + pub absolute: Option, +} + +#[derive(Debug, Serialize)] +pub struct FullStatus { + /// Human name of the camera model, e.g. "Tiny 2 Lite"; null while no camera is connected. + pub model: Option<&'static str>, + /// V4L2 node the camera streams from (stable by-id path when available); null like `model`. + pub device: Option, + /// Whether an application is streaming from the camera (kernel probe, see `streaming::is_streaming`). + pub streaming: bool, + /// `comm` names of those applications, sorted; empty when none. + pub streaming_apps: Vec, + /// Always true here; the daemon reports false when the camera is gone. + pub available: bool, + /// Firmware version such as "6.2.8.1" (query 0x0408); null when the camera did not answer. + pub firmware: Option, + /// Product and platform codes (query 0x1948); null when the camera did not answer. + pub model_info: Option, + pub status: Status, + /// None when the camera did not answer query 0x0104. + pub tracking: Option, + pub zoom: Zoom, + /// Live gimbal pose from query 0x0043 (gimbal controller), also while AI tracking moves it. + pub pose: Option, + /// Tracking target from query 0x01c4; None while Static or when the camera gave no usable answer. + pub target: Option, + pub image: BTreeMap, + /// Microphone volume 0-100 from query 0x80c2; null while asleep or when the camera did not + /// answer. + pub audio_volume: Option, + /// Autofocus mode, manual position and focus window from query 0x3642; null like + /// `audio_volume`. + pub focus: Option, + pub state: StateView, + /// Zone tracking area, follow and return position read from the camera; None when it did not + /// answer the queries. + pub zone: Option, + /// Hand tracking area and return position read from the camera; None when it did not answer. + pub hand: Option, + /// Tracking speed mode and framing options read from the camera; None when it did not answer. + pub framing: Option, + /// The presets stored in the camera, sorted by slot; null when the camera did not answer. + pub presets: Option>, + pub capabilities: Vec<&'static str>, + /// What `gimbal`, the poses and `zone area` accept, so clients need not hardcode it. + pub limits: Limits, +} + +/// `limits` in the status: [min, max] in obsbot4linux's angles (pan positive = camera left, tilt +/// positive = up) and the zoom factor. +#[derive(Debug, Serialize, PartialEq)] +pub struct Limits { + pub pan: [f32; 2], + pub tilt: [f32; 2], + pub zoom: [f32; 2], +} + +pub const LIMITS: Limits = Limits { + pan: [-GIMBAL_PAN_LIMIT, GIMBAL_PAN_LIMIT], + tilt: [GIMBAL_TILT_MIN, GIMBAL_TILT_MAX], + zoom: [1.0, MAX_ZOOM], +}; + +/// `state` in the status: what obsbot4linux remembers (state file), plus `zone_follow` and +/// `startup`, which are read from the camera (null when it did not answer). Kept under `state` +/// so existing clients keep working. +#[derive(Debug, Serialize)] +pub struct StateView { + #[serde(flatten)] + pub remembered: State, + pub zone_follow: Option, + pub startup: Option, +} + +/// The rarely changing readbacks; the daemon keeps them between full reads. +#[derive(Debug, Clone, PartialEq)] +pub struct ZoneInfo { + pub zone: Option, + pub hand: Option, + pub startup: Option, + pub framing: Option, + pub presets: Option>, +} + +impl Camera { + pub fn zone_info(&self) -> ZoneInfo { + ZoneInfo { + zone: self.zone().ok(), + hand: self.hand().ok(), + startup: self.startup().ok(), + framing: self.framing().ok(), + presets: self.presets().ok(), + } + } + + pub fn full_status(&self) -> Result { + self.full_status_with(None, None) + } + + /// Like [`Camera::full_status`], with the zone readbacks and the identity given instead of + /// asked for again. + pub fn full_status_with( + &self, + known: Option, + identity: Option, + ) -> Result { + let status = self.status()?; + let tracking = match self.tracking() { + Ok(t) => Some(t), + Err(Error::NoAnswer(_) | Error::Rejected(..)) => None, + Err(e) => return Err(e), + }; + let factor = self.zoom_factor().ok(); + let absolute = self.dev.get_ctrl(ZOOM_ABSOLUTE).ok(); + let detected = self.dev.detected(); + let streaming_apps = streaming::apps_using(&detected.device); + // an asleep camera does not answer these; asking would only wait out the polling + let (audio_volume, focus) = if status.asleep { + (None, None) + } else { + (self.audio_volume().ok(), self.focus().ok()) + }; + let target = if status.ai_mode == Some(AiMode::Static) { + None + } else { + self.target().ok().flatten() + }; + let ZoneInfo { + zone, + hand, + startup, + framing, + presets, + } = known.unwrap_or_else(|| self.zone_info()); + let Identity { + firmware, + model_info, + } = identity.unwrap_or_else(|| self.identity()); + Ok(FullStatus { + model: Some(detected.model.name), + device: Some(detected.device.to_string_lossy().into_owned()), + streaming: streaming::is_streaming(&detected.device), + streaming_apps, + available: true, + firmware, + model_info, + status, + tracking, + zoom: Zoom { factor, absolute }, + pose: self.live_pose().ok(), + target, + image: self.image_controls()?, + audio_volume, + focus, + state: StateView { + remembered: State::load(), + zone_follow: zone.as_ref().map(|z| z.follow), + startup, + }, + zone, + hand, + framing, + presets, + capabilities: CAPABILITIES.to_vec(), + limits: LIMITS, + }) + } +} + +#[cfg(test)] +mod full_status_tests { + use crate::models::tiny2lite::camera::{HandArea, Offset}; + use crate::models::tiny2lite::presets::{Pose, ZoneView}; + + #[test] + fn hand_serialises_area_and_a_null_return() { + let mut h = Hand { + area: HandArea { + pan_min: -45.0, + pan_max: 45.0, + tilt_min: -30.0, + tilt_max: 30.0, + }, + return_pose: None, + pan_enabled: Some(true), + tilt_enabled: Some(false), + return_after: Some(2.5), + }; + let v = serde_json::to_value(&h).unwrap(); + assert_eq!(v["area"]["tilt_max"], 30.0); + assert_eq!( + (&v["pan_enabled"], &v["tilt_enabled"], &v["return_after"]), + (&true.into(), &false.into(), &2.5.into()) + ); + assert!(v["return"].is_null(), "no return position set"); + h.return_pose = Some(Pose { + pan: 20.0, + tilt: 5.0, + zoom: 1.0, + }); + assert_eq!( + serde_json::to_value(h).unwrap()["return"], + serde_json::json!({"pan": 20.0, "tilt": 5.0, "zoom": 1.0}) + ); + } + + #[test] + fn zone_serialises_with_return_as_key() { + let z = Zone { + pan_min: -10.0, + pan_max: 10.0, + tilt_min: -5.0, + tilt_max: 5.0, + follow: true, + return_pose: Pose { + pan: 0.0, + tilt: 0.0, + zoom: 1.0, + }, + views: Some(vec![ZoneView { + id: 0, + name: "Bereich1".into(), + pose: Pose { + pan: 0.5, + tilt: 0.0, + zoom: 1.5, + }, + }]), + }; + // the keys of 0.11 keep their place and shape; `views` is the only addition + assert_eq!( + serde_json::to_value(z).unwrap(), + serde_json::json!({"pan_min": -10.0, "pan_max": 10.0, "tilt_min": -5.0, "tilt_max": 5.0, + "follow": true, "return": {"pan": 0.0, "tilt": 0.0, "zoom": 1.0}, + "views": [{"id": 0, "name": "Bereich1", "pose": {"pan": 0.5, "tilt": 0.0, "zoom": 1.5}}]}) + ); + } + + use super::*; + + #[test] + fn model_and_device_are_top_level_keys() { + let full = FullStatus { + model: Some("Tiny 2 Lite"), + device: Some("/dev/v4l/by-id/x-video-index0".into()), + streaming: true, + streaming_apps: vec!["ffmpeg".into()], + available: true, + firmware: Some("6.2.8.1".into()), + model_info: Some(ModelInfo { + product: 8, + platform: 8, + }), + status: Status::decode(&[0; 60]), + tracking: None, + zoom: Zoom { + factor: None, + absolute: None, + }, + pose: None, + target: None, + image: BTreeMap::new(), + audio_volume: Some(10), + focus: None, + state: StateView { + remembered: State::default(), + zone_follow: Some(true), + startup: None, + }, + zone: None, + hand: None, + presets: Some(vec![Preset { + slot: 1, + name: "Desk".into(), + pose: Pose { + pan: 10.0, + tilt: -5.0, + zoom: 2.0, + }, + }]), + framing: Some(Framing { + speed_mode: SpeedMode::VeryFast, + auto_zoom: true, + size: FramingSize::MediumCloseUp, + zoom_speed: AutoZoomSpeed::Fast, + offset: Offset { + x: 0.125, + y: -0.0625, + }, + part_mode: PartMode::HeadRemoval, + }), + capabilities: CAPABILITIES.to_vec(), + limits: LIMITS, + }; + let v = serde_json::to_value(full).unwrap(); + // the widget reads these names; they are the snake_case of the CLI names + assert_eq!( + v["framing"], + serde_json::json!({"speed_mode": "very_fast", "auto_zoom": true, + "size": "medium_close_up", "zoom_speed": "fast", + "offset": {"x": 0.125, "y": -0.0625}, "part_mode": "head_removal"}) + ); + // the widget reads `presets` at the top level; a camera that did not answer gives null + assert_eq!( + v["presets"], + serde_json::json!([{"slot": 1, "name": "Desk", "pose": {"pan": 10.0, "tilt": -5.0, "zoom": 2.0}}]) + ); + assert!(CAPABILITIES.contains(&"preset_state")); + assert_eq!(v["model"], "Tiny 2 Lite"); + assert_eq!(v["device"], "/dev/v4l/by-id/x-video-index0"); + assert_eq!(v["streaming"], true); + assert_eq!(v["streaming_apps"], serde_json::json!(["ffmpeg"])); + assert!(v["target"].is_null()); + assert_eq!(v["firmware"], "6.2.8.1"); + assert_eq!( + v["model_info"], + serde_json::json!({"product": 8, "platform": 8}) + ); + // zone_follow stays under state, next to the remembered values + assert_eq!(v["state"]["zone_follow"], true); + assert!(v["state"]["startup"].is_null() && v["zone"].is_null() && v["hand"].is_null()); + // the widget takes its d-pad and zone slider ranges from here + assert_eq!( + v["limits"], + serde_json::json!({"pan": [-150.0, 150.0], "tilt": [-70.0, 90.0], "zoom": [1.0, 4.0]}) + ); + let t = Target { + detected: true, + coords: [0.25, 0.5, 0.75, 1.0], + }; + assert_eq!( + serde_json::to_value(t).unwrap(), + serde_json::json!({"detected": true, "box": [0.25, 0.5, 0.75, 1.0]}) + ); + // the keys the widget already reads stay where they were + assert_eq!(v["available"], true); + 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 { + 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())); + 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 = 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, Option) { + ( + 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:?}"); + } + + #[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/legacy/mod.rs b/crates/obsbot-device/src/legacy/mod.rs new file mode 100644 index 0000000..16b3990 --- /dev/null +++ b/crates/obsbot-device/src/legacy/mod.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! What 0.13 keeps around the camera and what only its daemon and CLI use. The rebuild does not +//! carry it over (spec section 3.2); the 0.13 daemon and CLI use it until they are replaced. + +pub mod current_pose; +pub mod full_status; +pub mod state; diff --git a/src/state.rs b/crates/obsbot-device/src/legacy/state.rs similarity index 99% rename from src/state.rs rename to crates/obsbot-device/src/legacy/state.rs index 6da4406..cc9fda8 100644 --- a/src/state.rs +++ b/crates/obsbot-device/src/legacy/state.rs @@ -4,8 +4,8 @@ //! exposure mode. The CLI updates it whenever it sets them, so separate calls stay //! consistent. Location: `$XDG_STATE_HOME/obsbot4linux/state.json` (default `~/.local/state`). -use crate::commands::{AiMode, ExposureMode}; use crate::error::{Error, Result}; +use crate::models::tiny2lite::commands::{AiMode, ExposureMode}; use serde::{Deserialize, Serialize}; use std::fs::File; use std::io::Write; diff --git a/crates/obsbot-device/src/lib.rs b/crates/obsbot-device/src/lib.rs new file mode 100644 index 0000000..1421c81 --- /dev/null +++ b/crates/obsbot-device/src/lib.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Control library for the OBSBOT Tiny 2 Lite. + +pub mod enumeration; +pub mod error; +pub mod family; +pub mod legacy; +pub mod models; +pub mod registry; +#[cfg(test)] +pub(crate) mod scripted; +pub mod transport; diff --git a/crates/obsbot-device/src/models/mod.rs b/crates/obsbot-device/src/models/mod.rs new file mode 100644 index 0000000..af6caea --- /dev/null +++ b/crates/obsbot-device/src/models/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The camera models, one module each. + +pub mod tiny2lite; diff --git a/src/camera.rs b/crates/obsbot-device/src/models/tiny2lite/camera.rs similarity index 74% rename from src/camera.rs rename to crates/obsbot-device/src/models/tiny2lite/camera.rs index dd2cb39..d4c3bab 100644 --- a/src/camera.rs +++ b/crates/obsbot-device/src/models/tiny2lite/camera.rs @@ -3,77 +3,20 @@ //! High-level camera operations (one device open per operation). -use crate::commands::{self as cmd, *}; -use crate::device::{CtrlRange, Device}; use crate::error::{Error, Result}; -use crate::frame::{Answer, Sequence, parse_answer, query_frame}; -use crate::presets::{self, MAX_ZOOM, Pose, Preset, StartupActions, StartupAi, ZoneView}; -use crate::state::{Startup, State}; -use crate::status::{Focus, ModelInfo, Status, Target, Tracking, firmware_version}; -use crate::streaming; +use crate::family::exchange::{Camera, PAIR_GAP}; +use crate::legacy::state::{Startup, State}; +use crate::models::tiny2lite::commands::{self as cmd, *}; +use crate::models::tiny2lite::presets::{ + self, MAX_ZOOM, Pose, Preset, StartupActions, StartupAi, ZoneView, +}; +use crate::models::tiny2lite::status::{ + Focus, ModelInfo, Status, Target, Tracking, firmware_version, +}; +use crate::transport::uvc::CtrlRange; use serde::Serialize; use std::collections::BTreeMap; -use std::sync::OnceLock; use std::thread::sleep; -use std::time::Duration; - -const REPLY_GAP: Duration = Duration::from_millis(50); -const REPLY_ATTEMPTS: usize = 20; -/// Gap between the two frames of a pair (preset delete): back to back the camera applies neither. -const PAIR_GAP: Duration = Duration::from_millis(100); - -/// Commands obsbot4linux supports, for the `capabilities` list of `status --json`. -pub const CAPABILITIES: &[&str] = &[ - "sleep", - "wake", - "tracking", - "speed", - "hdr", - "exposure", - "zoom", - "gimbal", - "gimbal_speed", - "gimbal_stop", - "zoom_speed", - "speed_mode", - "speed_headroom", - "framing", - "fov", - "led", - "led_blink", - "mirror", - "invert", - "auto_sleep", - "sleep_background", - "sleep_background_mirror", - "audio", - "audio_volume", - "gesture", - "hand", - "zone", - "zone_follow", - "zone_return", - "zone_views", - "hand_return", - "hand_area", - "hand_axes", - "hand_return_after", - "hand_gimbal", - "focus", - "focus_af_mode", - "focus_point", - "wake_zoom", - "wake_mode", - "exposure_values", - "startup", - "startup_ai", - "image", - "preset", - "preset_state", - "factory_reset", - "target", - "live_pose_on_demand", -]; /// UVC controls for the picture, by CLI name (V4L2 control ids). pub const IMAGE_CONTROLS: &[(&str, u32)] = &[ @@ -97,9 +40,7 @@ const FOCUS_ABSOLUTE: u32 = 0x009a_090a; /// V4L2 auto_exposure (exposure_auto): 0 automatic, 1 manual. const EXPOSURE_AUTO: u32 = 0x009a_0901; const FOCUS_AUTO: u32 = 0x009a_090c; -const ZOOM_ABSOLUTE: u32 = 0x009a_090d; -const PAN_ABSOLUTE: u32 = 0x009a_0908; -const TILT_ABSOLUTE: u32 = 0x009a_0909; +pub(crate) const ZOOM_ABSOLUTE: u32 = 0x009a_090d; #[derive(Debug, Clone, Serialize)] pub struct ImageControl { @@ -108,15 +49,6 @@ pub struct ImageControl { pub range: CtrlRange, } -#[derive(Debug, Serialize)] -pub struct Zoom { - /// Current zoom factor from query 0x6804 (follows gestures and preset recalls). - pub factor: Option, - /// UVC zoom_absolute, 0-100 = 1x-4x: the value last written over V4L2. It does not follow - /// a field of view change, a gesture or a preset recall; `factor` does. - pub absolute: Option, -} - /// Degrees in obsbot4linux's angles: pan positive = camera left, tilt positive = up. #[derive(Debug, Serialize)] pub struct LivePose { @@ -137,78 +69,6 @@ impl LivePose { } } -#[derive(Debug, Serialize)] -pub struct FullStatus { - /// Human name of the camera model, e.g. "Tiny 2 Lite"; null while no camera is connected. - pub model: Option<&'static str>, - /// V4L2 node the camera streams from (stable by-id path when available); null like `model`. - pub device: Option, - /// Whether an application is streaming from the camera (kernel probe, see `streaming::is_streaming`). - pub streaming: bool, - /// `comm` names of those applications, sorted; empty when none. - pub streaming_apps: Vec, - /// Always true here; the daemon reports false when the camera is gone. - pub available: bool, - /// Firmware version such as "6.2.8.1" (query 0x0408); null when the camera did not answer. - pub firmware: Option, - /// Product and platform codes (query 0x1948); null when the camera did not answer. - pub model_info: Option, - pub status: Status, - /// None when the camera did not answer query 0x0104. - pub tracking: Option, - pub zoom: Zoom, - /// Live gimbal pose from query 0x0043 (gimbal controller), also while AI tracking moves it. - pub pose: Option, - /// Tracking target from query 0x01c4; None while Static or when the camera gave no usable answer. - pub target: Option, - pub image: BTreeMap, - /// Microphone volume 0-100 from query 0x80c2; null while asleep or when the camera did not - /// answer. - pub audio_volume: Option, - /// Autofocus mode, manual position and focus window from query 0x3642; null like - /// `audio_volume`. - pub focus: Option, - pub state: StateView, - /// Zone tracking area, follow and return position read from the camera; None when it did not - /// answer the queries. - pub zone: Option, - /// Hand tracking area and return position read from the camera; None when it did not answer. - pub hand: Option, - /// Tracking speed mode and framing options read from the camera; None when it did not answer. - pub framing: Option, - /// The presets stored in the camera, sorted by slot; null when the camera did not answer. - pub presets: Option>, - pub capabilities: Vec<&'static str>, - /// What `gimbal`, the poses and `zone area` accept, so clients need not hardcode it. - pub limits: Limits, -} - -/// `limits` in the status: [min, max] in obsbot4linux's angles (pan positive = camera left, tilt -/// positive = up) and the zoom factor. -#[derive(Debug, Serialize, PartialEq)] -pub struct Limits { - pub pan: [f32; 2], - pub tilt: [f32; 2], - pub zoom: [f32; 2], -} - -pub const LIMITS: Limits = Limits { - pan: [-GIMBAL_PAN_LIMIT, GIMBAL_PAN_LIMIT], - tilt: [GIMBAL_TILT_MIN, GIMBAL_TILT_MAX], - zoom: [1.0, MAX_ZOOM], -}; - -/// `state` in the status: what obsbot4linux remembers (state file), plus `zone_follow` and -/// `startup`, which are read from the camera (null when it did not answer). Kept under `state` -/// so existing clients keep working. -#[derive(Debug, Serialize)] -pub struct StateView { - #[serde(flatten)] - pub remembered: State, - pub zone_follow: Option, - pub startup: Option, -} - /// Zone tracking settings as the camera reports them, in obsbot4linux's angles (pan positive = /// camera left, tilt positive = up; the same as `zone area` takes). #[derive(Debug, Clone, PartialEq, Serialize)] @@ -288,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) -> Option { + 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) { @@ -313,16 +179,6 @@ pub struct Offset { pub y: f32, } -/// The rarely changing readbacks; the daemon keeps them between full reads. -#[derive(Debug, Clone, PartialEq)] -pub struct ZoneInfo { - pub zone: Option, - pub hand: Option, - pub startup: Option, - pub framing: Option, - pub presets: Option>, -} - /// What does not change while the camera stays connected; the daemon reads it once per connect. #[derive(Debug, Clone, PartialEq)] pub struct Identity { @@ -373,68 +229,7 @@ fn auto_zoom_speed_reading(v: u8) -> Option { } } -/// One sequence for the whole process, so frames from separate `Camera` handles (the daemon -/// opens the device per operation) never repeat a number. -fn sequence() -> &'static Sequence { - static SEQ: OnceLock = OnceLock::new(); - SEQ.get_or_init(Sequence::new) -} - -pub struct Camera { - dev: Device, - seq: &'static Sequence, -} - impl Camera { - pub fn open() -> Result { - Ok(Self { - dev: Device::open()?, - seq: sequence(), - }) - } - - fn short(&self, payload: &[u8]) -> Result<()> { - self.dev.xu_set(6, payload) - } - - fn framed(&self, frame: &[u8]) -> Result<()> { - self.dev.xu_set(2, frame) - } - - fn pair(&self, frames: [[u8; 60]; 2]) -> Result<()> { - self.framed(&frames[0])?; - sleep(PAIR_GAP); - self.framed(&frames[1]) - } - - /// Sends a query and polls selector 2 for the answer with the same sequence number. - fn query(&self, command: u16) -> Result> { - self.query_to(0x04, command) - } - - fn query_to(&self, receiver: u8, command: u16) -> Result> { - let seq = self.seq.next(); - self.framed(&query_frame(receiver, command, seq))?; - self.answer(command, seq) - } - - /// Polls selector 2 for the answer to the query with `seq`. - fn answer(&self, command: u16, seq: u16) -> Result> { - 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 => {} - } - } - } - Err(Error::NoAnswer(command)) - } - // --- reading --- pub fn status(&self) -> Result { @@ -623,89 +418,6 @@ impl Camera { }) } - pub fn zone_info(&self) -> ZoneInfo { - ZoneInfo { - zone: self.zone().ok(), - hand: self.hand().ok(), - startup: self.startup().ok(), - framing: self.framing().ok(), - presets: self.presets().ok(), - } - } - - pub fn full_status(&self) -> Result { - self.full_status_with(None, None) - } - - /// Like [`Camera::full_status`], with the zone readbacks and the identity given instead of - /// asked for again. - pub fn full_status_with( - &self, - known: Option, - identity: Option, - ) -> Result { - let status = self.status()?; - let tracking = match self.tracking() { - Ok(t) => Some(t), - Err(Error::NoAnswer(_) | Error::Rejected(..)) => None, - Err(e) => return Err(e), - }; - let factor = self.zoom_factor().ok(); - let absolute = self.dev.get_ctrl(ZOOM_ABSOLUTE).ok(); - let detected = self.dev.detected(); - let streaming_apps = streaming::apps_using(&detected.device); - // an asleep camera does not answer these; asking would only wait out the polling - let (audio_volume, focus) = if status.asleep { - (None, None) - } else { - (self.audio_volume().ok(), self.focus().ok()) - }; - let target = if status.ai_mode == Some(AiMode::Static) { - None - } else { - self.target().ok().flatten() - }; - let ZoneInfo { - zone, - hand, - startup, - framing, - presets, - } = known.unwrap_or_else(|| self.zone_info()); - let Identity { - firmware, - model_info, - } = identity.unwrap_or_else(|| self.identity()); - Ok(FullStatus { - model: Some(detected.model.name), - device: Some(detected.device.to_string_lossy().into_owned()), - streaming: streaming::is_streaming(&detected.device), - streaming_apps, - available: true, - firmware, - model_info, - status, - tracking, - zoom: Zoom { factor, absolute }, - pose: self.live_pose().ok(), - target, - image: self.image_controls()?, - audio_volume, - focus, - state: StateView { - remembered: State::load(), - zone_follow: zone.as_ref().map(|z| z.follow), - startup, - }, - zone, - hand, - framing, - presets, - capabilities: CAPABILITIES.to_vec(), - limits: LIMITS, - }) - } - // --- simple settings --- pub fn sleep(&self, asleep: bool) -> Result<()> { @@ -1099,7 +811,7 @@ impl Camera { pub fn gimbal(&self, pan: Option, tilt: Option, speed: Option) -> 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)?, @@ -1225,26 +937,6 @@ impl Camera { Ok(list) } - /// The pose to save: the remembered gimbal pose, else the one V4L2 last set (stale - /// after tracking moved the gimbal), plus the live zoom factor. - fn current_pose(&self) -> Result { - let state = State::load().gimbal; - let v4l2 = |id| self.dev.get_ctrl(id).map(|v| v as f32 / 3600.0); - let pan = match state.and_then(|g| g.pan) { - Some(p) => p, - None => v4l2(PAN_ABSOLUTE)?, - }; - let tilt = match state.and_then(|g| g.tilt) { - Some(t) => t, - None => v4l2(TILT_ABSOLUTE)?, - }; - Ok(Pose { - pan, - tilt, - zoom: self.zoom_factor()?.clamp(1.0, MAX_ZOOM), - }) - } - /// Saves a preset: creates it, or updates the pose of an existing one. New presets get /// "Preset " as name unless one is given. `pose` overrides the current pose. pub fn save_preset(&self, slot: u8, name: Option<&str>, pose: Option) -> Result { @@ -1297,178 +989,10 @@ impl Camera { } } -#[cfg(test)] -mod full_status_tests { - #[test] - fn hand_serialises_area_and_a_null_return() { - let mut h = Hand { - area: HandArea { - pan_min: -45.0, - pan_max: 45.0, - tilt_min: -30.0, - tilt_max: 30.0, - }, - return_pose: None, - pan_enabled: Some(true), - tilt_enabled: Some(false), - return_after: Some(2.5), - }; - let v = serde_json::to_value(&h).unwrap(); - assert_eq!(v["area"]["tilt_max"], 30.0); - assert_eq!( - (&v["pan_enabled"], &v["tilt_enabled"], &v["return_after"]), - (&true.into(), &false.into(), &2.5.into()) - ); - assert!(v["return"].is_null(), "no return position set"); - h.return_pose = Some(Pose { - pan: 20.0, - tilt: 5.0, - zoom: 1.0, - }); - assert_eq!( - serde_json::to_value(h).unwrap()["return"], - serde_json::json!({"pan": 20.0, "tilt": 5.0, "zoom": 1.0}) - ); - } - - #[test] - fn zone_serialises_with_return_as_key() { - let z = Zone { - pan_min: -10.0, - pan_max: 10.0, - tilt_min: -5.0, - tilt_max: 5.0, - follow: true, - return_pose: Pose { - pan: 0.0, - tilt: 0.0, - zoom: 1.0, - }, - views: Some(vec![ZoneView { - id: 0, - name: "Bereich1".into(), - pose: Pose { - pan: 0.5, - tilt: 0.0, - zoom: 1.5, - }, - }]), - }; - // the keys of 0.11 keep their place and shape; `views` is the only addition - assert_eq!( - serde_json::to_value(z).unwrap(), - serde_json::json!({"pan_min": -10.0, "pan_max": 10.0, "tilt_min": -5.0, "tilt_max": 5.0, - "follow": true, "return": {"pan": 0.0, "tilt": 0.0, "zoom": 1.0}, - "views": [{"id": 0, "name": "Bereich1", "pose": {"pan": 0.5, "tilt": 0.0, "zoom": 1.5}}]}) - ); - } - - use super::*; - - #[test] - fn model_and_device_are_top_level_keys() { - let full = FullStatus { - model: Some("Tiny 2 Lite"), - device: Some("/dev/v4l/by-id/x-video-index0".into()), - streaming: true, - streaming_apps: vec!["ffmpeg".into()], - available: true, - firmware: Some("6.2.8.1".into()), - model_info: Some(ModelInfo { - product: 8, - platform: 8, - }), - status: Status::decode(&[0; 60]), - tracking: None, - zoom: Zoom { - factor: None, - absolute: None, - }, - pose: None, - target: None, - image: BTreeMap::new(), - audio_volume: Some(10), - focus: None, - state: StateView { - remembered: State::default(), - zone_follow: Some(true), - startup: None, - }, - zone: None, - hand: None, - presets: Some(vec![Preset { - slot: 1, - name: "Desk".into(), - pose: Pose { - pan: 10.0, - tilt: -5.0, - zoom: 2.0, - }, - }]), - framing: Some(Framing { - speed_mode: SpeedMode::VeryFast, - auto_zoom: true, - size: FramingSize::MediumCloseUp, - zoom_speed: AutoZoomSpeed::Fast, - offset: Offset { - x: 0.125, - y: -0.0625, - }, - part_mode: PartMode::HeadRemoval, - }), - capabilities: CAPABILITIES.to_vec(), - limits: LIMITS, - }; - let v = serde_json::to_value(full).unwrap(); - // the widget reads these names; they are the snake_case of the CLI names - assert_eq!( - v["framing"], - serde_json::json!({"speed_mode": "very_fast", "auto_zoom": true, - "size": "medium_close_up", "zoom_speed": "fast", - "offset": {"x": 0.125, "y": -0.0625}, "part_mode": "head_removal"}) - ); - // the widget reads `presets` at the top level; a camera that did not answer gives null - assert_eq!( - v["presets"], - serde_json::json!([{"slot": 1, "name": "Desk", "pose": {"pan": 10.0, "tilt": -5.0, "zoom": 2.0}}]) - ); - assert!(CAPABILITIES.contains(&"preset_state")); - assert_eq!(v["model"], "Tiny 2 Lite"); - assert_eq!(v["device"], "/dev/v4l/by-id/x-video-index0"); - assert_eq!(v["streaming"], true); - assert_eq!(v["streaming_apps"], serde_json::json!(["ffmpeg"])); - assert!(v["target"].is_null()); - assert_eq!(v["firmware"], "6.2.8.1"); - assert_eq!( - v["model_info"], - serde_json::json!({"product": 8, "platform": 8}) - ); - // zone_follow stays under state, next to the remembered values - assert_eq!(v["state"]["zone_follow"], true); - assert!(v["state"]["startup"].is_null() && v["zone"].is_null() && v["hand"].is_null()); - // the widget takes its d-pad and zone slider ranges from here - assert_eq!( - v["limits"], - serde_json::json!({"pan": [-150.0, 150.0], "tilt": [-70.0, 90.0], "zoom": [1.0, 4.0]}) - ); - let t = Target { - detected: true, - coords: [0.25, 0.5, 0.75, 1.0], - }; - assert_eq!( - serde_json::to_value(t).unwrap(), - serde_json::json!({"detected": true, "box": [0.25, 0.5, 0.75, 1.0]}) - ); - // the keys the widget already reads stay where they were - assert_eq!(v["available"], true); - assert!(v["status"].is_object() && v["capabilities"].is_array()); - } -} - #[cfg(test)] mod framing_tests { use super::auto_zoom_speed_reading; - use crate::commands::AutoZoomSpeed; + use crate::models::tiny2lite::commands::AutoZoomSpeed; #[test] fn the_zoom_speed_is_read_as_index_or_as_the_default_speed() { @@ -1516,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::*; @@ -1604,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)] @@ -1663,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/src/commands.rs b/crates/obsbot-device/src/models/tiny2lite/commands.rs similarity index 96% rename from src/commands.rs rename to crates/obsbot-device/src/models/tiny2lite/commands.rs index cd517ca..bfeaf55 100644 --- a/src/commands.rs +++ b/crates/obsbot-device/src/models/tiny2lite/commands.rs @@ -4,10 +4,10 @@ //! Byte builders for the documented commands, plus the setting enums. //! //! Short commands go to unit 2 selector 6 (` `), framed commands to -//! selector 2 (see [`crate::frame`]). Only ids from the protocol reference are used. +//! selector 2 (see [`crate::family::frame`]). Only ids from the protocol reference are used. use crate::error::{Error, Result}; -use crate::frame::{FRAME_SIZE, frame}; +use crate::family::frame::{FRAME_SIZE, frame}; use clap::ValueEnum; use serde::{Deserialize, Serialize}; @@ -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/mod.rs b/crates/obsbot-device/src/models/tiny2lite/mod.rs new file mode 100644 index 0000000..56f189e --- /dev/null +++ b/crates/obsbot-device/src/models/tiny2lite/mod.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The OBSBOT Tiny 2 Lite (USB 3564:fef9). + +pub mod camera; +pub mod commands; +pub mod presets; +pub mod status; diff --git a/src/presets.rs b/crates/obsbot-device/src/models/tiny2lite/presets.rs similarity index 92% rename from src/presets.rs rename to crates/obsbot-device/src/models/tiny2lite/presets.rs index 1e0c030..3e3e7f3 100644 --- a/src/presets.rs +++ b/crates/obsbot-device/src/models/tiny2lite/presets.rs @@ -7,9 +7,9 @@ //! degrees positive up. The camera's preset format has tilt positive down, so the sign is //! flipped when writing and reading. -use crate::commands::{AiMode, gimbal_angles_ok}; use crate::error::{Error, Result}; -use crate::frame::{FRAME_SIZE, frame, query_frame_with}; +use crate::family::frame::{FRAME_SIZE, frame, query_frame_with}; +use crate::models::tiny2lite::commands::{AiMode, gimbal_angles_ok}; use clap::ValueEnum; use serde::{Deserialize, Serialize}; @@ -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/src/status.rs b/crates/obsbot-device/src/models/tiny2lite/status.rs similarity index 95% rename from src/status.rs rename to crates/obsbot-device/src/models/tiny2lite/status.rs index 86c0eed..8f17cab 100644 --- a/src/status.rs +++ b/crates/obsbot-device/src/models/tiny2lite/status.rs @@ -5,7 +5,7 @@ //! //! Offsets are those of the Tiny 2 Lite, see the protocol reference `status-block.md`. -use crate::commands::{ +use crate::models::tiny2lite::commands::{ AiMode, AutofocusMode, HandSide, NoiseReduction, PickupDistance, SleepBackground, TrackingSpeed, }; use serde::Serialize; @@ -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/src/models.rs b/crates/obsbot-device/src/registry.rs similarity index 95% rename from src/models.rs rename to crates/obsbot-device/src/registry.rs index d8b19a4..cd4ab66 100644 --- a/src/models.rs +++ b/crates/obsbot-device/src/registry.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! The cameras obsbot4linux supports and how to recognise them. Matching is pure string -//! work so it can be tested without a camera; `device::detect` feeds it from `/dev`. +//! work so it can be tested without a camera; `enumeration::detect` feeds it from `/dev`. /// A supported camera model. #[derive(Debug, PartialEq, Eq)] diff --git a/crates/obsbot-device/src/scripted.rs b/crates/obsbot-device/src/scripted.rs new file mode 100644 index 0000000..b8b2ac7 --- /dev/null +++ b/crates/obsbot-device/src/scripted.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! 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 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::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 { + /// Type 0x29 with this data. + Value(Vec), + /// Type 0x89 with this error code. + Rejected(i16), + /// Sending the query fails (the write to selector 2 returns an error). + SendFails, +} + +/// 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]>, +} + +impl Scripted { + pub fn new(status: [u8; XU_SIZE]) -> Self { + Self { + status, + answers: BTreeMap::new(), + ctrls: BTreeMap::new(), + ranges: BTreeMap::new(), + set_ctrls: Rc::default(), + last: RefCell::new([0; XU_SIZE]), + } + } + + pub fn xu_get(&self, selector: u8) -> Result<[u8; XU_SIZE]> { + match selector { + 6 => Ok(self.status), + 2 => { + let last = *self.last.borrow(); + let command = u16::from_le_bytes([last[10], last[11]]); + Ok(match self.answers.get(&command) { + Some(Answer::Value(data)) => answer(0x29, &last, None, data), + Some(Answer::Rejected(code)) => answer(0x89, &last, Some(*code), &[]), + Some(Answer::SendFails) | None => answer(0x89, &last, Some(-1), &[]), + }) + } + _ => Err(Error::Invalid(format!( + "selector {selector} is not scripted" + ))), + } + } + + pub fn xu_set(&self, selector: u8, payload: &[u8]) -> Result<()> { + if selector == 2 { + let mut frame = [0u8; XU_SIZE]; + frame[..payload.len()].copy_from_slice(payload); + let command = u16::from_le_bytes([frame[10], frame[11]]); + if matches!(self.answers.get(&command), Some(Answer::SendFails)) { + return Err(Error::Io(std::io::Error::other("scripted write failure"))); + } + *self.last.borrow_mut() = frame; + } + Ok(()) + } + + pub fn get_ctrl(&self, id: u32) -> Result { + self.ctrls + .get(&id) + .copied() + .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, + 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] { + let mut r = [0u8; XU_SIZE]; + r[0] = 0xaa; + r[1] = kind; + r[2..4].copy_from_slice(&query[2..4]); + r[4..6].copy_from_slice(&16u16.to_le_bytes()); + r[10..12].copy_from_slice(&query[10..12]); + 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 +} diff --git a/crates/obsbot-device/src/transport/mod.rs b/crates/obsbot-device/src/transport/mod.rs new file mode 100644 index 0000000..c013ef5 --- /dev/null +++ b/crates/obsbot-device/src/transport/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Access to the camera's USB device: extension-unit queries and V4L2 controls. + +pub mod uvc; diff --git a/src/device.rs b/crates/obsbot-device/src/transport/uvc.rs similarity index 66% rename from src/device.rs rename to crates/obsbot-device/src/transport/uvc.rs index 6f427d0..cfef97f 100644 --- a/src/device.rs +++ b/crates/obsbot-device/src/transport/uvc.rs @@ -9,14 +9,13 @@ //! calls) take turns: the vendor unit has one answer buffer, and answers are matched by sequence //! number only. flock is advisory; streaming applications do not take it and are not held up. +use crate::enumeration::{Detected, detect}; use crate::error::{Error, Result}; -use crate::models::{self, Model}; use nix::errno::Errno; use nix::fcntl::{Flock, FlockArg}; -use nix::{ioctl_read, ioctl_readwrite}; -use std::fs::{self, File}; +use nix::ioctl_readwrite; +use std::fs::File; use std::os::fd::AsRawFd; -use std::path::PathBuf; use std::time::{Duration, Instant}; /// How long `Device::open` waits for another obsbot4linux process to release the camera. @@ -29,7 +28,6 @@ const UVC_GET_LEN: u8 = 0x85; /// Size of every vendor buffer on unit 2. pub const XU_SIZE: usize = 60; -const V4L2_CAP_META_CAPTURE: u32 = 0x0080_0000; const V4L2_CTRL_FLAG_DISABLED: u32 = 0x0001; #[repr(C)] @@ -41,18 +39,6 @@ struct UvcXuControlQuery { data: *mut u8, } -#[repr(C)] -#[derive(Default)] -struct V4l2Capability { - driver: [u8; 16], - card: [u8; 32], - bus_info: [u8; 32], - version: u32, - capabilities: u32, - device_caps: u32, - reserved: [u32; 3], -} - #[repr(C)] #[derive(Default)] struct V4l2Control { @@ -75,7 +61,6 @@ struct V4l2QueryCtrl { } ioctl_readwrite!(uvcioc_ctrl_query, b'u', 0x21, UvcXuControlQuery); -ioctl_read!(vidioc_querycap, b'V', 0, V4l2Capability); ioctl_readwrite!(vidioc_g_ctrl, b'V', 27, V4l2Control); ioctl_readwrite!(vidioc_s_ctrl, b'V', 28, V4l2Control); ioctl_readwrite!(vidioc_queryctrl, b'V', 36, V4l2QueryCtrl); @@ -89,18 +74,12 @@ pub struct CtrlRange { pub default: i32, } -/// A camera found by enumeration, before any command is sent to it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Detected { - pub model: &'static Model, - /// The V4L2 node to stream from: the stable `/dev/v4l/by-id/...-video-index0` link when - /// there is one, else `/dev/videoN`. - pub device: PathBuf, -} - pub struct Device { file: Flock, detected: Detected, + /// Tests only: answers from a table instead of the ioctls. + #[cfg(test)] + script: Option, } /// Takes an exclusive flock on `file`, retrying until `wait` has passed. @@ -119,68 +98,6 @@ fn lock_exclusive(mut file: File, wait: Duration) -> Result> { } } -fn card_name(file: &File) -> Option<(String, u32)> { - let mut cap = V4l2Capability::default(); - unsafe { vidioc_querycap(file.as_raw_fd(), &mut cap) }.ok()?; - let end = cap - .card - .iter() - .position(|b| *b == 0) - .unwrap_or(cap.card.len()); - Some(( - String::from_utf8_lossy(&cap.card[..end]).into_owned(), - cap.device_caps, - )) -} - -/// Finds a supported camera without talking to it: by-id symlink first, V4L2 card name -/// (VIDIOC_QUERYCAP, a read-only ioctl) as fallback. -pub fn detect() -> Option { - let by_id = fs::read_dir("/dev/v4l/by-id") - .into_iter() - .flatten() - .flatten() - .map(|e| e.path()) - .collect(); - if let Some(detected) = first_by_id(by_id) { - return Some(detected); - } - - let mut paths: Vec = fs::read_dir("/dev") - .ok()? - .flatten() - .map(|e| e.path()) - .filter(|p| { - p.file_name() - .is_some_and(|n| n.to_string_lossy().starts_with("video")) - }) - .collect(); - paths.sort(); - for path in paths { - if let Ok(file) = File::open(&path) - && let Some((card, caps)) = card_name(&file) - && caps & V4L2_CAP_META_CAPTURE == 0 - && let Some(model) = models::from_card(&card) - { - return Some(Detected { - model, - device: path, - }); - } - } - None -} - -/// The first supported camera among the by-id links, sorted: `read_dir` order is unspecified, -/// so with two cameras the controlled one could change between runs. -fn first_by_id(mut links: Vec) -> Option { - links.sort(); - links.into_iter().find_map(|device| { - let model = models::from_by_id(&device.file_name()?.to_string_lossy())?; - Some(Detected { model, device }) - }) -} - /// The kernel fails GET_CUR and SET_CUR with ENOBUFS unless the buffer is exactly the /// selector's length, and both always pass [`XU_SIZE`] bytes. fn check_len(selector: u8, len: usize) -> Result<()> { @@ -198,7 +115,35 @@ impl Device { pub fn open() -> Result { let detected = detect().ok_or(Error::NoCamera)?; let file = lock_exclusive(File::open(&detected.device)?, LOCK_WAIT)?; - Ok(Self { file, detected }) + Ok(Self { + file, + detected, + #[cfg(test)] + script: None, + }) + } + + /// Tests only: a device that answers from `script`. It holds a temporary file for the + /// lock and opens no device node. + #[cfg(test)] + pub(crate) fn scripted(script: crate::scripted::Scripted) -> Self { + use std::sync::atomic::{AtomicU32, Ordering}; + static N: AtomicU32 = AtomicU32::new(0); + let path = std::env::temp_dir().join(format!( + "o4l-scripted-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let file = lock_exclusive(File::create(&path).unwrap(), LOCK_WAIT).unwrap(); + std::fs::remove_file(&path).unwrap(); + Self { + file, + detected: Detected { + model: &crate::registry::MODELS[0], + device: "/nonexistent/obsbot-test-video0".into(), + }, + script: Some(script), + } } pub fn detected(&self) -> &Detected { @@ -225,6 +170,10 @@ impl Device { /// GET_CUR on unit 2, after checking the buffer length like the vendor tools do. pub fn xu_get(&self, selector: u8) -> Result<[u8; XU_SIZE]> { + #[cfg(test)] + if let Some(script) = &self.script { + return script.xu_get(selector); + } check_len(selector, self.xu_len(selector)?)?; let mut data = [0u8; XU_SIZE]; self.xu(selector, UVC_GET_CUR, &mut data)?; @@ -233,6 +182,10 @@ impl Device { /// SET_CUR on unit 2 with a zero-padded 60-byte buffer. pub fn xu_set(&self, selector: u8, payload: &[u8]) -> Result<()> { + #[cfg(test)] + if let Some(script) = &self.script { + return script.xu_set(selector, payload); + } if payload.len() > XU_SIZE { return Err(Error::Invalid("payload longer than 60 bytes".into())); } @@ -243,12 +196,20 @@ impl Device { } pub fn get_ctrl(&self, id: u32) -> Result { + #[cfg(test)] + if let Some(script) = &self.script { + return script.get_ctrl(id); + } let mut c = V4l2Control { id, value: 0 }; unsafe { vidioc_g_ctrl(self.file.as_raw_fd(), &mut c) }?; Ok(c.value) } 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(()) @@ -256,6 +217,10 @@ impl Device { /// Range of a control, or None when the camera does not have it (or it is disabled). pub fn ctrl_range(&self, id: u32) -> Result> { + #[cfg(test)] + if let Some(script) = &self.script { + return script.ctrl_range(id); + } let mut q = V4l2QueryCtrl { id, ..Default::default() @@ -276,6 +241,7 @@ impl Device { #[cfg(test)] mod tests { use super::*; + use std::fs; #[test] fn a_second_handle_waits_for_the_first() { @@ -309,18 +275,47 @@ mod tests { 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 by_id_detection_does_not_depend_on_directory_order() { - let link = |serial: &str| { - PathBuf::from(format!( - "/dev/v4l/by-id/usb-Remo_Tech_Co.__Ltd._OBSBOT_Tiny_2_Lite_{serial}-video-index0" - )) - }; - let other = PathBuf::from("/dev/v4l/by-id/usb-Logitech_BRIO-video-index0"); - let picked = first_by_id(vec![other.clone(), link("B"), link("A")]).unwrap(); - assert_eq!(picked.device, link("A")); - let picked = first_by_id(vec![link("A"), link("B"), other]).unwrap(); - assert_eq!(picked.device, link("A")); - assert_eq!(first_by_id(Vec::new()), None); + 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/crates/obsbot-device/tests/byte_guard.rs b/crates/obsbot-device/tests/byte_guard.rs new file mode 100644 index 0000000..6eb29e4 --- /dev/null +++ b/crates/obsbot-device/tests/byte_guard.rs @@ -0,0 +1,576 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Byte guard of the groundwork stage (rebuild plan, stage G): every frame and short command the +//! builders emit for a fixed list of calls, one line per call, compared with +//! `tests/fixtures/byte-guard.txt`. That file was written from the 0.13 code before the +//! workspace move, so a move that changes one byte that would go to the camera fails here. +//! +//! `BYTE_GUARD_BLESS=1 cargo test --test byte_guard` writes the file: once when this test is +//! added, and on `main` in a change that alters what a builder sends on purpose (that change +//! lists the changed lines). A move never writes it; every other run compares. + +use clap::ValueEnum; +use obsbot_device::family::frame::{self, FRAME_SIZE}; +use obsbot_device::models::tiny2lite::commands::{self as cmd, *}; +use obsbot_device::models::tiny2lite::presets::{self, Pose, StartupAi}; +use std::fmt::Display; + +const FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/byte-guard.txt"); +const SEQ: u16 = 0x1234; +const SEQS: [u16; 4] = [SEQ, SEQ + 1, SEQ + 2, SEQ + 3]; + +/// How one builder result is written into the fixture. +trait Show { + fn show(&self) -> String; +} + +impl Show for [u8; N] { + fn show(&self) -> String { + hex::encode(self) + } +} + +impl Show for [[u8; N]; M] { + fn show(&self) -> String { + self.iter().map(hex::encode).collect::>().join(" ") + } +} + +impl Show for u8 { + fn show(&self) -> String { + self.to_string() + } +} + +impl Show for u16 { + fn show(&self) -> String { + format!("{self:#06x}") + } +} + +impl Show for bool { + fn show(&self) -> String { + self.to_string() + } +} + +impl Show for Option { + fn show(&self) -> String { + self.as_ref().map_or("none".into(), Show::show) + } +} + +impl Show for Result { + fn show(&self) -> String { + match self { + Ok(v) => v.show(), + Err(e) => format!("error: {e}"), + } + } +} + +fn rec(out: &mut Vec, label: String, value: impl Show) { + out.push(format!("{label} = {}", value.show())); +} + +/// Poses at and beyond every bound the pose builders check, and an asymmetric one. +const POSES: [(f32, f32, f32); 10] = [ + (10.5, -5.25, 2.0), + (0.0, 0.0, 1.0), + (-150.0, -70.0, 1.0), + (150.0, 90.0, 4.0), + (150.1, 0.0, 1.0), + (0.0, 90.5, 1.0), + (0.0, -70.5, 1.0), + (0.0, 0.0, 4.01), + (0.0, 0.0, 0.99), + (f32::NAN, 0.0, 1.0), +]; + +fn poses() -> impl Iterator { + POSES + .into_iter() + .map(|(pan, tilt, zoom)| Pose { pan, tilt, zoom }) +} + +/// Area ranges (min, max) in the camera's angles, at and beyond the bounds, and wrong ways round. +const RANGES: [(f32, f32); 11] = [ + (-45.0, 45.0), + (-150.0, 150.0), + (-150.5, 0.0), + (0.0, 150.5), + (-90.0, 70.0), + (-90.5, 0.0), + (0.0, 70.5), + (-30.0, 10.0), + (10.0, 10.0), + (10.0, -10.0), + (f32::NAN, 1.0), +]; + +type Short3 = fn(bool) -> [u8; 3]; +type Framed = fn(bool, u16) -> [u8; FRAME_SIZE]; +type SeqOnly = fn(u16) -> [u8; FRAME_SIZE]; + +fn record() -> Vec { + let mut out = Vec::new(); + let o = &mut out; + + // frame codec + for seq in [0x0000, 0x0001, SEQ, 0xffff] { + let q = frame::query_frame(0x04, 0x0104, seq); + rec( + o, + format!("frame::query_frame(0x04, 0x0104, {seq:#06x})"), + q, + ); + let f = frame::frame(0x02, 0xa0c2, &[1, 0, 0, 0], seq); + rec( + o, + format!("frame::frame(0x02, 0xa0c2, [1 0 0 0], {seq:#06x})"), + f, + ); + let w = frame::query_frame_with(0x04, 0x1504, &[7, 0, 0, 0], seq); + rec( + o, + format!("frame::query_frame_with(0x04, 0x1504, [7 0 0 0], {seq:#06x})"), + w, + ); + } + let full = frame::frame(0x04, 0x3e44, &[0xa5; frame::MAX_DATA], SEQ); + rec( + o, + "frame::frame(0x04, 0x3e44, [0xa5; MAX_DATA])".into(), + full, + ); + rec( + o, + "frame::crc16_usb(\"123456789\")".into(), + frame::crc16_usb(b"123456789"), + ); + + // short commands (selector 6) + let short3: [(&str, Short3); 6] = [ + ("hdr", cmd::hdr), + ("led_blink", cmd::led_blink), + ("mirror", cmd::mirror), + ("mic_in_sleep", cmd::mic_in_sleep), + ("auto_gain", cmd::auto_gain), + ("microphone", cmd::microphone), + ]; + for (name, f) in short3 { + for on in [false, true] { + rec(o, format!("cmd::{name}({on})"), f(on)); + } + } + for on in [false, true] { + rec( + o, + format!("cmd::sleep_background_mirror({on})"), + cmd::sleep_background_mirror(on), + ); + } + for m in AiMode::value_variants() { + rec(o, format!("cmd::ai_mode({m:?})"), cmd::ai_mode(*m)); + rec( + o, + format!("cmd::default_work_mode({m:?})"), + cmd::default_work_mode(*m), + ); + } + for s in [i16::MIN, -600, -1, 0, 1, 600, i16::MAX] { + rec(o, format!("cmd::auto_sleep({s})"), cmd::auto_sleep(s)); + } + for s in [0u16, 1, 600, 32767, 32768, u16::MAX] { + rec( + o, + format!("cmd::auto_sleep_off({s})"), + cmd::auto_sleep_off(s), + ); + } + for b in SleepBackground::value_variants() { + rec( + o, + format!("cmd::sleep_background({b:?})"), + cmd::sleep_background(*b), + ); + } + for b in 0..=4u8 { + rec(o, format!("cmd::led({b})"), cmd::led(b)); + } + for p in [0u8, 1, 50, 100, 101, 255] { + rec(o, format!("cmd::wake_zoom({p})"), cmd::wake_zoom(p)); + } + for f in [0.99f32, 1.0, 1.5, 2.5, 4.0, 4.01, f32::NAN] { + rec( + o, + format!("cmd::wake_zoom_percent({f})"), + cmd::wake_zoom_percent(f), + ); + } + for v in FieldOfView::value_variants() { + rec( + o, + format!("cmd::field_of_view({v:?})"), + cmd::field_of_view(*v), + ); + } + for v in NoiseReduction::value_variants() { + rec( + o, + format!("cmd::noise_reduction({v:?})"), + cmd::noise_reduction(*v), + ); + } + for v in PickupDistance::value_variants() { + rec( + o, + format!("cmd::pickup_distance({v:?})"), + cmd::pickup_distance(*v), + ); + } + for v in ExposureMode::value_variants() { + rec( + o, + format!("cmd::exposure_mode_select({v:?})"), + cmd::exposure_mode_select(*v), + ); + } + rec(o, "cmd::startup_go()".into(), cmd::startup_go()); + + // framed commands (selector 2) + let framed: [(&str, Framed); 9] = [ + ("invert", cmd::invert), + ("zone_tracking", cmd::zone_tracking), + ("hand_pan", cmd::hand_pan), + ("hand_tilt", cmd::hand_tilt), + ("hand_gimbal", cmd::hand_gimbal), + ("autofocus_mode", cmd::autofocus_mode), + ("zone_follow", cmd::zone_follow), + ("sleep", cmd::sleep), + ("auto_zoom", cmd::auto_zoom), + ]; + for (name, f) in framed { + for on in [false, true] { + rec(o, format!("cmd::{name}({on})"), f(on, SEQ)); + } + } + let seq_only: [(&str, SeqOnly); 6] = [ + ("zone_return_go", cmd::zone_return_go), + ("hand_return_go", cmd::hand_return_go), + ("hand_return_reset", cmd::hand_return_reset), + ("factory_reset", cmd::factory_reset), + ("gimbal_stop", cmd::gimbal_stop), + ("zoom_stop", cmd::zoom_stop), + ]; + for (name, f) in seq_only { + rec(o, format!("cmd::{name}()"), f(SEQ)); + } + for v in TrackingSpeed::value_variants() { + rec( + o, + format!("cmd::tracking_speed({v:?})"), + cmd::tracking_speed(*v, SEQ), + ); + } + for v in SpeedMode::value_variants() { + rec( + o, + format!("cmd::speed_mode({v:?})"), + cmd::speed_mode(*v, SEQ), + ); + } + for v in FramingSize::value_variants() { + rec( + o, + format!("cmd::framing_size({v:?})"), + cmd::framing_size(*v, SEQ), + ); + } + for v in AutoZoomSpeed::value_variants() { + rec( + o, + format!("cmd::auto_zoom_speed({v:?})"), + cmd::auto_zoom_speed(*v, SEQ), + ); + } + for v in PartMode::value_variants() { + rec(o, format!("cmd::part_mode({v:?})"), cmd::part_mode(*v, SEQ)); + } + for v in HandSide::value_variants() { + rec(o, format!("cmd::hand_side({v:?})"), cmd::hand_side(*v, SEQ)); + } + for v in AutofocusMode::value_variants() { + rec( + o, + format!("cmd::autofocus_mode_select({v:?})"), + cmd::autofocus_mode_select(*v, SEQ), + ); + } + for g in Gesture::value_variants() { + for on in [false, true] { + rec( + o, + format!("cmd::gesture({g:?}, {on})"), + cmd::gesture(*g, on, SEQ), + ); + } + } + for r in [0.99f32, 1.0, 2.5, 4.0, 4.01, f32::NAN] { + rec( + o, + format!("cmd::gesture_zoom_ratio({r})"), + cmd::gesture_zoom_ratio(r, SEQ), + ); + } + for s in [-1.0f32, -0.0, 0.0, 2.5, 600.0, f32::INFINITY, f32::NAN] { + rec( + o, + format!("cmd::hand_return_after({s})"), + cmd::hand_return_after(s, SEQ), + ); + } + for v in [0u8, 1, 50, 100, 101, 255] { + rec( + o, + format!("cmd::audio_volume({v})"), + cmd::audio_volume(v, SEQ), + ); + } + for x in [-0.01f32, 0.0, 0.25, 1.0, 1.01] { + for y in [-0.01f32, 0.0, 0.75, 1.0, 1.01] { + rec( + o, + format!("cmd::focus_point({x}, {y})"), + cmd::focus_point(x, y, SEQ), + ); + rec( + o, + format!("cmd::view_center({x}, {y})"), + cmd::view_center(x, y, SEQ), + ); + } + } + for r in RANGES { + for (yaw, pitch) in [(r, (-30.0, 10.0)), ((-45.0, 45.0), r)] { + rec( + o, + format!("cmd::zone_area({yaw:?}, {pitch:?})"), + cmd::zone_area(yaw, pitch, SEQS), + ); + rec( + o, + format!("cmd::hand_area({yaw:?}, {pitch:?})"), + cmd::hand_area(yaw, pitch, SEQS), + ); + } + } + rec( + o, + "cmd::hand_area_reset()".into(), + cmd::hand_area_reset(SEQS), + ); + let axes = [ + None, + Some(-150.0f32), + Some(-70.0), + Some(0.0), + Some(12.5), + Some(90.0), + Some(150.0), + Some(150.5), + Some(-90.5), + ]; + for yaw in axes { + for pitch in axes { + rec( + o, + format!("cmd::gimbal_pose({yaw:?}, {pitch:?})"), + cmd::gimbal_pose(yaw, pitch, SEQ), + ); + } + } + for (yaw, pitch) in [ + (None, None), + (Some(12.5f32), None), + (None, Some(-5.0f32)), + (Some(12.5), Some(-5.0)), + (Some(150.5), Some(0.0)), + ] { + for speed in [0.99f32, 1.0, 45.0, 180.0, 180.5, f32::NAN] { + let f = cmd::gimbal_pose_speed(yaw, pitch, speed, SEQ); + rec( + o, + format!("cmd::gimbal_pose_speed({yaw:?}, {pitch:?}, {speed})"), + f, + ); + } + } + for pan in [-178.5f32, -178.0, -0.0, 0.0, 12.5, 178.0, 178.5, f32::NAN] { + for tilt in [-178.5f32, -178.0, -0.0, 0.0, 5.0, 178.0, 178.5] { + let f = cmd::gimbal_rate(pan, tilt, false, false, SEQ); + rec( + o, + format!("cmd::gimbal_rate({pan}, {tilt}, false, false)"), + f, + ); + } + } + for (mirror, invert) in [(true, false), (false, true), (true, true)] { + let f = cmd::gimbal_rate(12.5, 5.0, mirror, invert, SEQ); + rec( + o, + format!("cmd::gimbal_rate(12.5, 5, {mirror}, {invert})"), + f, + ); + } + for speed in [0i32, 1, 10, 30, 50, i32::MAX] { + for ratio in [0.99f32, 1.0, 2.5, 4.0, 4.01] { + rec( + o, + format!("cmd::zoom_to({speed}, {ratio})"), + cmd::zoom_to(speed, ratio, SEQ), + ); + } + } + for vertical in [false, true] { + for f in [-0.17f32, -0.16, -0.0, 0.0, 0.1, 0.16, 0.17, f32::NAN] { + let b = cmd::framing_offset(vertical, f, SEQ); + rec(o, format!("cmd::framing_offset({vertical}, {f})"), b); + } + } + for pan in [-150.5f32, -150.0, 0.0, 150.0, 150.5] { + for tilt in [-70.5f32, -70.0, 0.0, 90.0, 90.5] { + rec( + o, + format!("cmd::gimbal_angles_ok({pan}, {tilt})"), + cmd::gimbal_angles_ok(pan, tilt), + ); + } + } + + // presets, zone views, start pose and return positions + let names = [ + "", + "Desk", + "Bereich1", + "äöü", + "12345678901234567890123456789012", + "123456789012345678901234567890123", + ]; + for slot in [0u8, 1, 2, 3, 255] { + for p in poses() { + rec( + o, + format!("presets::create({slot}, {p:?})"), + presets::create(slot, p, SEQ), + ); + rec( + o, + format!("presets::update({slot}, {p:?})"), + presets::update(slot, p, SEQ), + ); + } + for name in names { + rec( + o, + format!("presets::rename({slot}, {name:?})"), + presets::rename(slot, name, SEQ), + ); + } + rec( + o, + format!("presets::delete({slot})"), + presets::delete(slot, SEQ, SEQ + 1), + ); + rec( + o, + format!("presets::recall({slot})"), + presets::recall(slot, SEQ), + ); + } + for id in [0u32, 1, 7, 255, u32::MAX] { + for p in poses() { + rec( + o, + format!("presets::view_add({id}, {p:?})"), + presets::view_add(id, p, SEQ), + ); + } + for name in names { + rec( + o, + format!("presets::view_rename({id}, {name:?})"), + presets::view_rename(id, name, SEQ), + ); + } + rec( + o, + format!("presets::view_remove({id})"), + presets::view_remove(id, SEQ), + ); + rec( + o, + format!("presets::view_go({id})"), + presets::view_go(id, SEQ), + ); + for command in [0x1504u16, 0x1444] { + let q = presets::view_query(command, id, SEQ); + rec(o, format!("presets::view_query({command:#06x}, {id})"), q); + } + } + for source in [0u32, 1, 2, 3, 4] { + for p in poses() { + let f = presets::startup_pose(source, p, SEQ); + rec(o, format!("presets::startup_pose({source}, {p:?})"), f); + } + } + for p in poses() { + rec( + o, + format!("presets::zone_return_set({p:?})"), + presets::zone_return_set(p, SEQ), + ); + rec( + o, + format!("presets::hand_return_set({p:?})"), + presets::hand_return_set(p, SEQ), + ); + } + for ai in StartupAi::value_variants() { + let f = presets::startup_picture_settings(ai.byte(), SEQ); + rec(o, format!("presets::startup_picture_settings({ai:?})"), f); + } + let block: Vec = (0u8..48).collect(); + for len in [0usize, 39, 40, 48] { + for ai in [0x00u8, 0xfe, 0xff] { + let f = presets::startup_with_ai(&block[..len], ai, SEQ); + rec( + o, + format!("presets::startup_with_ai(0..{len}, {ai:#04x})"), + f, + ); + } + } + out +} + +#[test] +fn builders_emit_the_recorded_bytes() { + let now = record().join("\n") + "\n"; + if std::env::var_os("BYTE_GUARD_BLESS").is_some() { + std::fs::write(FIXTURE, &now).unwrap(); + return; + } + let recorded = std::fs::read_to_string(FIXTURE).expect("tests/fixtures/byte-guard.txt"); + let (now, recorded): (Vec<&str>, Vec<&str>) = + (now.lines().collect(), recorded.lines().collect()); + assert_eq!( + now.len(), + recorded.len(), + "the number of recorded calls changed" + ); + for (n, r) in now.iter().zip(&recorded) { + assert_eq!(n, r, "a builder emits other bytes than before the move"); + } +} diff --git a/crates/obsbot-device/tests/fixtures/byte-guard.txt b/crates/obsbot-device/tests/fixtures/byte-guard.txt new file mode 100644 index 0000000..eb511c1 --- /dev/null +++ b/crates/obsbot-device/tests/fixtures/byte-guard.txt @@ -0,0 +1,831 @@ +frame::query_frame(0x04, 0x0104, 0x0000) = aa0100000c0085940a040401000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::frame(0x02, 0xa0c2, [1 0 0 0], 0x0000) = aa2500000c00497d0a02c2a00400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::query_frame_with(0x04, 0x1504, [7 0 0 0], 0x0000) = aa2100000c002e5b0a0404150400bf730700000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::query_frame(0x04, 0x0104, 0x0001) = aa0101000c00d4510a040401000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::frame(0x02, 0xa0c2, [1 0 0 0], 0x0001) = aa2501000c0018b80a02c2a00400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::query_frame_with(0x04, 0x1504, [7 0 0 0], 0x0001) = aa2101000c007f9e0a0404150400bf730700000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::query_frame(0x04, 0x0104, 0x1234) = aa0134120c00101e0a040401000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::frame(0x02, 0xa0c2, [1 0 0 0], 0x1234) = aa2534120c00dcf70a02c2a00400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::query_frame_with(0x04, 0x1504, [7 0 0 0], 0x1234) = aa2134120c00bbd10a0404150400bf730700000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::query_frame(0x04, 0x0104, 0xffff) = aa01ffff0c00f5930a040401000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::frame(0x02, 0xa0c2, [1 0 0 0], 0xffff) = aa25ffff0c00397a0a02c2a00400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::query_frame_with(0x04, 0x1504, [7 0 0 0], 0xffff) = aa21ffff0c005e5c0a0404150400bf730700000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +frame::frame(0x04, 0x3e44, [0xa5; MAX_DATA]) = aa2534120c00df3e0a04443e2c000901a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5 +frame::crc16_usb("123456789") = 0xb4c8 +cmd::hdr(false) = 010100 +cmd::hdr(true) = 010101 +cmd::led_blink(false) = 180100 +cmd::led_blink(true) = 180101 +cmd::mirror(false) = 140100 +cmd::mirror(true) = 140101 +cmd::mic_in_sleep(false) = 130100 +cmd::mic_in_sleep(true) = 130101 +cmd::auto_gain(false) = 170100 +cmd::auto_gain(true) = 170101 +cmd::microphone(false) = 1c0100 +cmd::microphone(true) = 1c0101 +cmd::sleep_background_mirror(false) = 0e020200 +cmd::sleep_background_mirror(true) = 0e020201 +cmd::ai_mode(Static) = 16020000 +cmd::default_work_mode(Static) = 190100 +cmd::ai_mode(Normal) = 16020200 +cmd::default_work_mode(Normal) = 190120 +cmd::ai_mode(UpperBody) = 16020201 +cmd::default_work_mode(UpperBody) = 190121 +cmd::ai_mode(CloseUp) = 16020202 +cmd::default_work_mode(CloseUp) = 190122 +cmd::ai_mode(Headless) = 16020203 +cmd::default_work_mode(Headless) = 190123 +cmd::ai_mode(LowerBody) = 16020204 +cmd::default_work_mode(LowerBody) = 190124 +cmd::ai_mode(Hand) = 16020300 +cmd::default_work_mode(Hand) = 190130 +cmd::ai_mode(Group) = 16020100 +cmd::default_work_mode(Group) = 190110 +cmd::auto_sleep(-32768) = 0b020080 +cmd::auto_sleep(-600) = 0b02a8fd +cmd::auto_sleep(-1) = 0b02ffff +cmd::auto_sleep(0) = 0b020000 +cmd::auto_sleep(1) = 0b020100 +cmd::auto_sleep(600) = 0b025802 +cmd::auto_sleep(32767) = 0b02ff7f +cmd::auto_sleep_off(0) = 0b02a8fd +cmd::auto_sleep_off(1) = 0b02ffff +cmd::auto_sleep_off(600) = 0b02a8fd +cmd::auto_sleep_off(32767) = 0b020180 +cmd::auto_sleep_off(32768) = error: invalid value: auto-sleep time is at most 32767 s +cmd::auto_sleep_off(65535) = error: invalid value: auto-sleep time is at most 32767 s +cmd::sleep_background(Black) = 0e020000 +cmd::sleep_background(Image) = 0e020001 +cmd::led(0) = 1a0100 +cmd::led(1) = 1a0101 +cmd::led(2) = 1a0102 +cmd::led(3) = 1a0103 +cmd::led(4) = error: invalid value: LED brightness is 0-3 +cmd::wake_zoom(0) = 15026400 +cmd::wake_zoom(1) = 15026401 +cmd::wake_zoom(50) = 15026432 +cmd::wake_zoom(100) = 15026464 +cmd::wake_zoom(101) = error: invalid value: wake-up zoom is 0-100 percent +cmd::wake_zoom(255) = error: invalid value: wake-up zoom is 0-100 percent +cmd::wake_zoom_percent(0.99) = error: invalid value: wake-up zoom factor is 1.0-4.0 +cmd::wake_zoom_percent(1) = 0 +cmd::wake_zoom_percent(1.5) = 17 +cmd::wake_zoom_percent(2.5) = 50 +cmd::wake_zoom_percent(4) = 100 +cmd::wake_zoom_percent(4.01) = error: invalid value: wake-up zoom factor is 1.0-4.0 +cmd::wake_zoom_percent(NaN) = error: invalid value: wake-up zoom factor is 1.0-4.0 +cmd::field_of_view(Wide) = 040100 +cmd::field_of_view(Medium) = 040101 +cmd::field_of_view(Narrow) = 040102 +cmd::noise_reduction(Off) = 0a0100 +cmd::noise_reduction(Weak) = 0a0101 +cmd::noise_reduction(Medium) = 0a0102 +cmd::noise_reduction(Strong) = 0a0103 +cmd::pickup_distance(Near) = 1b0100 +cmd::pickup_distance(Standard) = 1b0101 +cmd::pickup_distance(Far) = 1b0102 +cmd::exposure_mode_select(Global) = 030100 +cmd::exposure_mode_select(Face) = 030101 +cmd::startup_go() = 000101 +cmd::invert(false) = aa2534120c004f3d0a04843b0100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::invert(true) = aa2534120c004f3d0a04843b010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_tracking(false) = aa2534120c003ee80a04c4080100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_tracking(true) = aa2534120c003ee80a04c408010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_pan(false) = aa2534120c001f370a0444230100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_pan(true) = aa2534120c001f370a044423010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_tilt(false) = aa2534120c007ef70a04c4230100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_tilt(true) = aa2534120c007ef70a04c423010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_gimbal(false) = aa2534120c00bef40a04c4260100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_gimbal(true) = aa2534120c00bef40a04c426010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::autofocus_mode(false) = aa2534120c000c990a0202360400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::autofocus_mode(true) = aa2534120c000c990a0202360400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_follow(false) = aa2534120c00ef1a0a04044e0100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_follow(true) = aa2534120c00ef1a0a04044e010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::sleep(false) = aa2534120c00dcf70a02c2a00400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::sleep(true) = aa2534120c00dcf70a02c2a00400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::auto_zoom(false) = aa2534120c00dee90a04440a0100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::auto_zoom(true) = aa2534120c00dee90a04440a010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_return_go() = aa2534120c001f1a0a04444f0000ffdb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_return_go() = aa2534120c00cf350a0484250000ffdb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_return_reset() = aa2534120c009f350a0444250000ffdb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::factory_reset() = aa2534120c008d310a0202a8010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_stop() = aa2534120c002ec40a0404670000ffdb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_stop() = aa2534120c003ec00a04c4680000ffdb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::tracking_speed(Standard) = aa2534120c003f2b0a04c40c0100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::tracking_speed(Headroom) = aa2534120c003f2b0a04c40c010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::tracking_speed(Sport) = aa2534120c003f2b0a04c40c010067fe0200000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::speed_mode(Lazy) = aa2534120c009ee80a0444090100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::speed_mode(Slow) = aa2534120c009ee80a044409010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::speed_mode(Normal) = aa2534120c009ee80a044409010067fe0200000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::speed_mode(Fast) = aa2534120c009ee80a0444090100a63e0300000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::speed_mode(VeryFast) = aa2534120c009ee80a0444090100e7fc0400000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::speed_mode(Auto) = aa2534120c009ee80a0444090100263c0500000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_size(LongShot) = aa2534120c00bf290a04c40a0100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_size(MediumLongShot) = aa2534120c00bf290a04c40a010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_size(MediumShot) = aa2534120c00bf290a04c40a010067fe0200000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_size(MediumCloseUp) = aa2534120c00bf290a04c40a0100a63e0300000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_size(CloseUp) = aa2534120c00bf290a04c40a0100e7fc0400000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_size(BigCloseUp) = aa2534120c00bf290a04c40a0100263c0500000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::auto_zoom_speed(Slow) = aa2534120c001f290a04440b0100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::auto_zoom_speed(Medium) = aa2534120c001f290a04440b010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::auto_zoom_speed(Fast) = aa2534120c001f290a04440b010067fe0200000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::part_mode(Normal) = aa2534120c00cf210a0484150100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::part_mode(HeadRemoval) = aa2534120c00cf210a048415010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::part_mode(LowerBody) = aa2534120c00cf210a048415010067fe0200000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_side(Right) = aa2534120c00def70a0444220100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_side(Left) = aa2534120c00def70a044422010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::autofocus_mode_select(Continuous) = aa2534120c00bc980a0242340400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::autofocus_mode_select(Single) = aa2534120c00bc980a0242340400bfbf0200000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::autofocus_mode_select(Manual) = aa2534120c00bc980a0242340400be430300000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture(TargetLock, false) = aa2534120c003f3a0a04c4300100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture(TargetLock, true) = aa2534120c003f3a0a04c430010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture(Zoom, false) = aa2534120c009f3a0a0444310100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture(Zoom, true) = aa2534120c009f3a0a044431010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture(DynamicZoom, false) = aa2534120c001efb0a0444330100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture(DynamicZoom, true) = aa2534120c001efb0a044433010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture(DirectionChange, false) = aa2534120c007f3b0a04c4330100e63f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture(DirectionChange, true) = aa2534120c007f3b0a04c433010027ff0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture_zoom_ratio(0.99) = error: invalid value: gesture zoom ratio is 1.0-4.0 +cmd::gesture_zoom_ratio(1) = aa2534120c00df3b0a04443204009fd70000803f00000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture_zoom_ratio(2.5) = aa2534120c00df3b0a0444320400a6370000204000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture_zoom_ratio(4) = aa2534120c00df3b0a0444320400de370000804000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gesture_zoom_ratio(4.01) = error: invalid value: gesture zoom ratio is 1.0-4.0 +cmd::gesture_zoom_ratio(NaN) = error: invalid value: gesture zoom ratio is 1.0-4.0 +cmd::hand_return_after(-1) = error: invalid value: hand return time is 0 seconds or more +cmd::hand_return_after(-0) = aa2534120c005ef50a0444240400bfa70000008000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_return_after(0) = aa2534120c005ef50a0444240400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_return_after(2.5) = aa2534120c005ef50a0444240400a6370000204000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_return_after(600) = aa2534120c005ef50a0444240400b0540000164400000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_return_after(inf) = error: invalid value: hand return time is 0 seconds or more +cmd::hand_return_after(NaN) = error: invalid value: hand return time is 0 seconds or more +cmd::audio_volume(0) = aa2534120c004cef0a0202810200fe060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::audio_volume(1) = aa2534120c004cef0a0202810200ff960100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::audio_volume(50) = aa2534120c004cef0a0202810200eb663200000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::audio_volume(100) = aa2534120c004cef0a0202810200d4c66400000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::audio_volume(101) = error: invalid value: microphone volume is 0-100 +cmd::audio_volume(255) = error: invalid value: microphone volume is 0-100 +cmd::focus_point(-0.01, -0.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(-0.01, -0.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(-0.01, 0) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(-0.01, 0) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(-0.01, 0.75) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(-0.01, 0.75) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(-0.01, 1) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(-0.01, 1) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(-0.01, 1.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(-0.01, 1.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(0, -0.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(0, -0.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(0, 0) = aa2534120c002d580a0282351400df600000000000000000010000006400000064000000000000000000000000000000000000000000000000000000 +cmd::view_center(0, 0) = aa2534120c009eed0a04440508007a220000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::focus_point(0, 0.75) = aa2534120c002d580a0282351400cb1f000000000000403f010000006400000064000000000000000000000000000000000000000000000000000000 +cmd::view_center(0, 0.75) = aa2534120c009eed0a04440508000bf2000000000000403f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::focus_point(0, 1) = aa2534120c002d580a02823514000bdf000000000000803f010000006400000064000000000000000000000000000000000000000000000000000000 +cmd::view_center(0, 1) = aa2534120c009eed0a04440508005bf2000000000000803f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::focus_point(0, 1.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(0, 1.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(0.25, -0.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(0.25, -0.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(0.25, 0) = aa2534120c002d580a028235140086e40000803e00000000010000006400000064000000000000000000000000000000000000000000000000000000 +cmd::view_center(0.25, 0) = aa2534120c009eed0a04440508004c270000803e00000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::focus_point(0.25, 0.75) = aa2534120c002d580a0282351400929b0000803e0000403f010000006400000064000000000000000000000000000000000000000000000000000000 +cmd::view_center(0.25, 0.75) = aa2534120c009eed0a04440508003df70000803e0000403f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::focus_point(0.25, 1) = aa2534120c002d580a0282351400525b0000803e0000803f010000006400000064000000000000000000000000000000000000000000000000000000 +cmd::view_center(0.25, 1) = aa2534120c009eed0a04440508006df70000803e0000803f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::focus_point(0.25, 1.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(0.25, 1.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(1, -0.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(1, -0.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(1, 0) = aa2534120c002d580a0282351400d7740000803f00000000010000006400000064000000000000000000000000000000000000000000000000000000 +cmd::view_center(1, 0) = aa2534120c009eed0a044405080071e70000803f00000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::focus_point(1, 0.75) = aa2534120c002d580a0282351400c30b0000803f0000403f010000006400000064000000000000000000000000000000000000000000000000000000 +cmd::view_center(1, 0.75) = aa2534120c009eed0a044405080000370000803f0000403f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::focus_point(1, 1) = aa2534120c002d580a028235140003cb0000803f0000803f010000006400000064000000000000000000000000000000000000000000000000000000 +cmd::view_center(1, 1) = aa2534120c009eed0a044405080050370000803f0000803f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::focus_point(1, 1.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(1, 1.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(1.01, -0.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(1.01, -0.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(1.01, 0) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(1.01, 0) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(1.01, 0.75) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(1.01, 0.75) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(1.01, 1) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(1.01, 1) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::focus_point(1.01, 1.01) = error: invalid value: focus point x and y are 0.0-1.0 +cmd::view_center(1.01, 1.01) = error: invalid value: centre point x and y are 0.0-1.0 +cmd::zone_area((-45.0, 45.0), (-30.0, 10.0)) = aa2534120c006edb0a04044c04002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c005ede0a04844c040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c000ed10a04044d04003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c003ed40a04844d040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_area((-45.0, 45.0), (-30.0, 10.0)) = aa2534120c005f360a04442004002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c006f330a04c420040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c003f3c0a04442104003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c000f390a04c421040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_area((-45.0, 45.0), (-45.0, 45.0)) = aa2534120c006edb0a04044c04002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c005ede0a04844c040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c000ed10a04044d04002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c003ed40a04844d040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_area((-45.0, 45.0), (-45.0, 45.0)) = aa2534120c005f360a04442004002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c006f330a04c420040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c003f3c0a04442104002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c000f390a04c421040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_area((-150.0, 150.0), (-30.0, 10.0)) = aa2534120c006edb0a04044c0400f036000016c300000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c005ede0a04844c0400f1960000164300000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c000ed10a04044d04003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c003ed40a04844d040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_area((-150.0, 150.0), (-30.0, 10.0)) = aa2534120c005f360a0444200400f036000016c300000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c006f330a04c4200400f1960000164300000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c003f3c0a04442104003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c000f390a04c421040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_area((-45.0, 45.0), (-150.0, 150.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((-45.0, 45.0), (-150.0, 150.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((-150.5, 0.0), (-30.0, 10.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((-150.5, 0.0), (-30.0, 10.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((-45.0, 45.0), (-150.5, 0.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((-45.0, 45.0), (-150.5, 0.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((0.0, 150.5), (-30.0, 10.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((0.0, 150.5), (-30.0, 10.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((-45.0, 45.0), (0.0, 150.5)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((-45.0, 45.0), (0.0, 150.5)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((-90.0, 70.0), (-30.0, 10.0)) = aa2534120c006edb0a04044c040048960000b4c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c005ede0a04844c04005af600008c4200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c000ed10a04044d04003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c003ed40a04844d040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_area((-90.0, 70.0), (-30.0, 10.0)) = aa2534120c005f360a044420040048960000b4c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c006f330a04c42004005af600008c4200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c003f3c0a04442104003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c000f390a04c421040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_area((-45.0, 45.0), (-90.0, 70.0)) = aa2534120c006edb0a04044c04002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c005ede0a04844c040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c000ed10a04044d040048960000b4c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c003ed40a04844d04005af600008c4200000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_area((-45.0, 45.0), (-90.0, 70.0)) = aa2534120c005f360a04442004002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c006f330a04c420040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c003f3c0a044421040048960000b4c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c000f390a04c42104005af600008c4200000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_area((-90.5, 0.0), (-30.0, 10.0)) = aa2534120c006edb0a04044c040049060000b5c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c005ede0a04844c0400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c000ed10a04044d04003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c003ed40a04844d040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_area((-90.5, 0.0), (-30.0, 10.0)) = aa2534120c005f360a044420040049060000b5c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c006f330a04c4200400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c003f3c0a04442104003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c000f390a04c421040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_area((-45.0, 45.0), (-90.5, 0.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((-45.0, 45.0), (-90.5, 0.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((0.0, 70.5), (-30.0, 10.0)) = aa2534120c006edb0a04044c0400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c005ede0a04844c04005b6600008d4200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c000ed10a04044d04003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c003ed40a04844d040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_area((0.0, 70.5), (-30.0, 10.0)) = aa2534120c005f360a0444200400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c006f330a04c42004005b6600008d4200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c003f3c0a04442104003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c000f390a04c421040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_area((-45.0, 45.0), (0.0, 70.5)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((-45.0, 45.0), (0.0, 70.5)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((-30.0, 10.0), (-30.0, 10.0)) = aa2534120c006edb0a04044c04003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c005ede0a04844c040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c000ed10a04044d04003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c003ed40a04844d040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_area((-30.0, 10.0), (-30.0, 10.0)) = aa2534120c005f360a04442004003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c006f330a04c420040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c003f3c0a04442104003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c000f390a04c421040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_area((-45.0, 45.0), (-30.0, 10.0)) = aa2534120c006edb0a04044c04002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c005ede0a04844c040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c000ed10a04044d04003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c003ed40a04844d040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::hand_area((-45.0, 45.0), (-30.0, 10.0)) = aa2534120c005f360a04442004002956000034c200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c006f330a04c420040028f60000344200000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c003f3c0a04442104003b970000f0c100000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c000f390a04c421040067f70000204100000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zone_area((10.0, 10.0), (-30.0, 10.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((10.0, 10.0), (-30.0, 10.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((-45.0, 45.0), (10.0, 10.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((-45.0, 45.0), (10.0, 10.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((10.0, -10.0), (-30.0, 10.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((10.0, -10.0), (-30.0, 10.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((-45.0, 45.0), (10.0, -10.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((-45.0, 45.0), (10.0, -10.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((NaN, 1.0), (-30.0, 10.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((NaN, 1.0), (-30.0, 10.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::zone_area((-45.0, 45.0), (NaN, 1.0)) = error: invalid value: zone area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area((-45.0, 45.0), (NaN, 1.0)) = error: invalid value: hand area: min must be below max, pan within -150..150, tilt within -70..90 +cmd::hand_area_reset() = aa2534120c00fef50a04c4250000ffdb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c00bf310a0404260000ffdb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2536120c007efe0a0444260000ffdb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2537120c007f3b0a0484260000ffdb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(None, None) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(None, Some(-150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(None, Some(-70.0)) = aa2534120c005f050a0444640c0040ea00007ac400008cc200007ac40000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(None, Some(0.0)) = aa2534120c005f050a0444640c00263700007ac40000000000007ac40000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(None, Some(12.5)) = aa2534120c005f050a0444640c0015b000007ac40000484100007ac40000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(None, Some(90.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(None, Some(150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(None, Some(150.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(None, Some(-90.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-150.0), None) = aa2534120c005f050a0444640c00b0be00007ac400007ac4000016c30000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-150.0), Some(-150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-150.0), Some(-70.0)) = aa2534120c005f050a0444640c002c2800007ac400008cc2000016c30000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-150.0), Some(0.0)) = aa2534120c005f050a0444640c004af500007ac400000000000016c30000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-150.0), Some(12.5)) = aa2534120c005f050a0444640c00797200007ac400004841000016c30000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-150.0), Some(90.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-150.0), Some(150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-150.0), Some(150.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-150.0), Some(-90.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-70.0), None) = aa2534120c005f050a0444640c001bde00007ac400007ac400008cc20000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-70.0), Some(-150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-70.0), Some(-70.0)) = aa2534120c005f050a0444640c00874800007ac400008cc200008cc20000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-70.0), Some(0.0)) = aa2534120c005f050a0444640c00e19500007ac40000000000008cc20000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-70.0), Some(12.5)) = aa2534120c005f050a0444640c00d21200007ac40000484100008cc20000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-70.0), Some(90.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-70.0), Some(150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-70.0), Some(150.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-70.0), Some(-90.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(0.0), None) = aa2534120c005f050a0444640c00fe8f00007ac400007ac4000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(0.0), Some(-150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(0.0), Some(-70.0)) = aa2534120c005f050a0444640c00621900007ac400008cc2000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(0.0), Some(0.0)) = aa2534120c005f050a0444640c0004c400007ac400000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(0.0), Some(12.5)) = aa2534120c005f050a0444640c00374300007ac400004841000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(0.0), Some(90.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(0.0), Some(150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(0.0), Some(150.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(0.0), Some(-90.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(12.5), None) = aa2534120c005f050a0444640c0008bf00007ac400007ac4000048410000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(12.5), Some(-150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(12.5), Some(-70.0)) = aa2534120c005f050a0444640c00942900007ac400008cc2000048410000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(12.5), Some(0.0)) = aa2534120c005f050a0444640c00f2f400007ac400000000000048410000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(12.5), Some(12.5)) = aa2534120c005f050a0444640c00c17300007ac400004841000048410000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(12.5), Some(90.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(12.5), Some(150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(12.5), Some(150.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(12.5), Some(-90.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(90.0), None) = aa2534120c005f050a0444640c0009be00007ac400007ac40000b4420000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(90.0), Some(-150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(90.0), Some(-70.0)) = aa2534120c005f050a0444640c00952800007ac400008cc20000b4420000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(90.0), Some(0.0)) = aa2534120c005f050a0444640c00f3f500007ac4000000000000b4420000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(90.0), Some(12.5)) = aa2534120c005f050a0444640c00c07200007ac4000048410000b4420000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(90.0), Some(90.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(90.0), Some(150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(90.0), Some(150.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(90.0), Some(-90.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.0), None) = aa2534120c005f050a0444640c00b11e00007ac400007ac4000016430000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(150.0), Some(-150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.0), Some(-70.0)) = aa2534120c005f050a0444640c002d8800007ac400008cc2000016430000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(150.0), Some(0.0)) = aa2534120c005f050a0444640c004b5500007ac400000000000016430000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(150.0), Some(12.5)) = aa2534120c005f050a0444640c0078d200007ac400004841000016430000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(150.0), Some(90.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.0), Some(150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.0), Some(150.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.0), Some(-90.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.5), None) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.5), Some(-150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.5), Some(-70.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.5), Some(0.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.5), Some(12.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.5), Some(90.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.5), Some(150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.5), Some(150.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(150.5), Some(-90.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-90.5), None) = aa2534120c005f050a0444640c00098e00007ac400007ac40000b5c20000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-90.5), Some(-150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-90.5), Some(-70.0)) = aa2534120c005f050a0444640c00951800007ac400008cc20000b5c20000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-90.5), Some(0.0)) = aa2534120c005f050a0444640c00f3c500007ac4000000000000b5c20000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-90.5), Some(12.5)) = aa2534120c005f050a0444640c00c04200007ac4000048410000b5c20000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_pose(Some(-90.5), Some(90.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-90.5), Some(150.0)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-90.5), Some(150.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose(Some(-90.5), Some(-90.5)) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose_speed(None, None, 0.99) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(None, None, 1) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose_speed(None, None, 45) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose_speed(None, None, 180) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose_speed(None, None, 180.5) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(None, None, NaN) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(Some(12.5), None, 0.99) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(Some(12.5), None, 1) = aa2534120c009ec50a0444651800bcaf0000803f0000803f0000803f00007ac400007ac4000048410000000000000000000000000000000000000000 +cmd::gimbal_pose_speed(Some(12.5), None, 45) = aa2534120c009ec50a04446518002b5e00003442000034420000344200007ac400007ac4000048410000000000000000000000000000000000000000 +cmd::gimbal_pose_speed(Some(12.5), None, 180) = aa2534120c009ec50a0444651800689e00003443000034430000344300007ac400007ac4000048410000000000000000000000000000000000000000 +cmd::gimbal_pose_speed(Some(12.5), None, 180.5) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(Some(12.5), None, NaN) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(None, Some(-5.0), 0.99) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(None, Some(-5.0), 1) = aa2534120c009ec50a04446518008a960000803f0000803f0000803f00007ac40000a0c000007ac40000000000000000000000000000000000000000 +cmd::gimbal_pose_speed(None, Some(-5.0), 45) = aa2534120c009ec50a04446518001d6700003442000034420000344200007ac40000a0c000007ac40000000000000000000000000000000000000000 +cmd::gimbal_pose_speed(None, Some(-5.0), 180) = aa2534120c009ec50a04446518005ea700003443000034430000344300007ac40000a0c000007ac40000000000000000000000000000000000000000 +cmd::gimbal_pose_speed(None, Some(-5.0), 180.5) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(None, Some(-5.0), NaN) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(Some(12.5), Some(-5.0), 0.99) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(Some(12.5), Some(-5.0), 1) = aa2534120c009ec50a04446518005e550000803f0000803f0000803f00007ac40000a0c0000048410000000000000000000000000000000000000000 +cmd::gimbal_pose_speed(Some(12.5), Some(-5.0), 45) = aa2534120c009ec50a0444651800c9a400003442000034420000344200007ac40000a0c0000048410000000000000000000000000000000000000000 +cmd::gimbal_pose_speed(Some(12.5), Some(-5.0), 180) = aa2534120c009ec50a04446518008a6400003443000034430000344300007ac40000a0c0000048410000000000000000000000000000000000000000 +cmd::gimbal_pose_speed(Some(12.5), Some(-5.0), 180.5) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(Some(12.5), Some(-5.0), NaN) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(Some(150.5), Some(0.0), 0.99) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(Some(150.5), Some(0.0), 1) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose_speed(Some(150.5), Some(0.0), 45) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose_speed(Some(150.5), Some(0.0), 180) = error: invalid value: gimbal pose needs pan -150..150 and/or tilt -70..90 +cmd::gimbal_pose_speed(Some(150.5), Some(0.0), 180.5) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_pose_speed(Some(150.5), Some(0.0), NaN) = error: invalid value: gimbal speed is 1-180 degrees per second +cmd::gimbal_rate(-178.5, -178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-178.5, -178, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-178.5, -0, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-178.5, 0, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-178.5, 5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-178.5, 178, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-178.5, 178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-178, -178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-178, -178, false, false) = aa2534120c000f050a0484640c0051460000000000003243000032430000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-178, -0, false, false) = aa2534120c000f050a0484640c00109b0000000000000000000032430000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-178, 0, false, false) = aa2534120c000f050a0484640c00109b0000000000000000000032430000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-178, 5, false, false) = aa2534120c000f050a0484640c00082a000000000000a0c0000032430000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-178, 178, false, false) = aa2534120c000f050a0484640c00509800000000000032c3000032430000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-178, 178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-0, -178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(-0, -178, false, false) = aa2534120c000f050a0484640c0005d70000000000003243000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-0, -0, false, false) = aa2534120c000f050a0484640c00440a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-0, 0, false, false) = aa2534120c000f050a0484640c00440a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-0, 5, false, false) = aa2534120c000f050a0484640c005cbb000000000000a0c0000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-0, 178, false, false) = aa2534120c000f050a0484640c00040900000000000032c3000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(-0, 178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(0, -178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(0, -178, false, false) = aa2534120c000f050a0484640c0005d70000000000003243000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(0, -0, false, false) = aa2534120c000f050a0484640c00440a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(0, 0, false, false) = aa2534120c000f050a0484640c00440a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(0, 5, false, false) = aa2534120c000f050a0484640c005cbb000000000000a0c0000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(0, 178, false, false) = aa2534120c000f050a0484640c00040900000000000032c3000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(0, 178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(12.5, -178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(12.5, -178, false, false) = aa2534120c000f050a0484640c00f2470000000000003243000048c10000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(12.5, -0, false, false) = aa2534120c000f050a0484640c00b39a0000000000000000000048c10000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(12.5, 0, false, false) = aa2534120c000f050a0484640c00b39a0000000000000000000048c10000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(12.5, 5, false, false) = aa2534120c000f050a0484640c00ab2b000000000000a0c0000048c10000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(12.5, 178, false, false) = aa2534120c000f050a0484640c00f39900000000000032c3000048c10000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(12.5, 178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(178, -178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(178, -178, false, false) = aa2534120c000f050a0484640c0050e60000000000003243000032c30000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(178, -0, false, false) = aa2534120c000f050a0484640c00113b0000000000000000000032c30000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(178, 0, false, false) = aa2534120c000f050a0484640c00113b0000000000000000000032c30000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(178, 5, false, false) = aa2534120c000f050a0484640c00098a000000000000a0c0000032c30000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(178, 178, false, false) = aa2534120c000f050a0484640c00513800000000000032c3000032c30000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(178, 178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(178.5, -178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(178.5, -178, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(178.5, -0, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(178.5, 0, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(178.5, 5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(178.5, 178, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(178.5, 178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(NaN, -178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(NaN, -178, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(NaN, -0, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(NaN, 0, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(NaN, 5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(NaN, 178, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(NaN, 178.5, false, false) = error: invalid value: gimbal rate is -178 to 178 degrees per second +cmd::gimbal_rate(12.5, 5, true, false) = aa2534120c000f050a0484640c00aa8b000000000000a0c0000048410000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(12.5, 5, false, true) = aa2534120c000f050a0484640c00aa8b000000000000a0c0000048410000000000000000000000000000000000000000000000000000000000000000 +cmd::gimbal_rate(12.5, 5, true, true) = aa2534120c000f050a0484640c00ab2b000000000000a0c0000048c10000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(0, 0.99) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(0, 1) = error: invalid value: zoom speed must be positive +cmd::zoom_to(0, 2.5) = error: invalid value: zoom speed must be positive +cmd::zoom_to(0, 4) = error: invalid value: zoom speed must be positive +cmd::zoom_to(0, 4.01) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(1, 0.99) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(1, 1) = aa2534120c000f000a04846808009a3e010000000000803f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(1, 2.5) = aa2534120c000f000a0484680800a3de0100000000002040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(1, 4) = aa2534120c000f000a0484680800dbde0100000000008040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(1, 4.01) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(10, 0.99) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(10, 1) = aa2534120c000f000a0484680800db8d0a0000000000803f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(10, 2.5) = aa2534120c000f000a0484680800e26d0a00000000002040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(10, 4) = aa2534120c000f000a04846808009a6d0a00000000008040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(10, 4.01) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(30, 0.99) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(30, 1) = aa2534120c000f000a0484680800db721e0000000000803f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(30, 2.5) = aa2534120c000f000a0484680800e2921e00000000002040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(30, 4) = aa2534120c000f000a04846808009a921e00000000008040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(30, 4.01) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(50, 0.99) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(50, 1) = aa2534120c000f000a0484680800d93f320000000000803f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(50, 2.5) = aa2534120c000f000a0484680800e0df3200000000002040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(50, 4) = aa2534120c000f000a048468080098df3200000000008040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(50, 4.01) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(2147483647, 0.99) = error: invalid value: zoom factor is 1.0-4.0 +cmd::zoom_to(2147483647, 1) = aa2534120c000f000a04846808001a3cffffff7f0000803f000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(2147483647, 2.5) = aa2534120c000f000a048468080023dcffffff7f00002040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(2147483647, 4) = aa2534120c000f000a04846808005bdcffffff7f00008040000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::zoom_to(2147483647, 4.01) = error: invalid value: zoom factor is 1.0-4.0 +cmd::framing_offset(false, -0.17) = error: invalid value: framing offset is -0.16 to 0.16 of the frame +cmd::framing_offset(false, -0.16) = aa2534120c007ee90a04c40b040094a70ad723be00000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(false, -0) = aa2534120c007ee90a04c40b0400bfa70000008000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(false, 0) = aa2534120c007ee90a04c40b0400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(false, 0.1) = aa2534120c007ee90a04c40b0400d445cdcccc3d00000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(false, 0.16) = aa2534120c007ee90a04c40b040095070ad7233e00000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(false, 0.17) = error: invalid value: framing offset is -0.16 to 0.16 of the frame +cmd::framing_offset(false, NaN) = error: invalid value: framing offset is -0.16 to 0.16 of the frame +cmd::framing_offset(true, -0.17) = error: invalid value: framing offset is -0.16 to 0.16 of the frame +cmd::framing_offset(true, -0.16) = aa2534120c005eeb0a04440c040094a70ad723be00000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(true, -0) = aa2534120c005eeb0a04440c0400bfa70000008000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(true, 0) = aa2534120c005eeb0a04440c0400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(true, 0.1) = aa2534120c005eeb0a04440c0400d445cdcccc3d00000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(true, 0.16) = aa2534120c005eeb0a04440c040095070ad7233e00000000000000000000000000000000000000000000000000000000000000000000000000000000 +cmd::framing_offset(true, 0.17) = error: invalid value: framing offset is -0.16 to 0.16 of the frame +cmd::framing_offset(true, NaN) = error: invalid value: framing offset is -0.16 to 0.16 of the frame +cmd::gimbal_angles_ok(-150.5, -70.5) = false +cmd::gimbal_angles_ok(-150.5, -70) = false +cmd::gimbal_angles_ok(-150.5, 0) = false +cmd::gimbal_angles_ok(-150.5, 90) = false +cmd::gimbal_angles_ok(-150.5, 90.5) = false +cmd::gimbal_angles_ok(-150, -70.5) = false +cmd::gimbal_angles_ok(-150, -70) = true +cmd::gimbal_angles_ok(-150, 0) = true +cmd::gimbal_angles_ok(-150, 90) = true +cmd::gimbal_angles_ok(-150, 90.5) = false +cmd::gimbal_angles_ok(0, -70.5) = false +cmd::gimbal_angles_ok(0, -70) = true +cmd::gimbal_angles_ok(0, 0) = true +cmd::gimbal_angles_ok(0, 90) = true +cmd::gimbal_angles_ok(0, 90.5) = false +cmd::gimbal_angles_ok(150, -70.5) = false +cmd::gimbal_angles_ok(150, -70) = true +cmd::gimbal_angles_ok(150, 0) = true +cmd::gimbal_angles_ok(150, 90) = true +cmd::gimbal_angles_ok(150, 90.5) = false +cmd::gimbal_angles_ok(150.5, -70.5) = false +cmd::gimbal_angles_ok(150.5, -70) = false +cmd::gimbal_angles_ok(150.5, 0) = false +cmd::gimbal_angles_ok(150.5, 90) = false +cmd::gimbal_angles_ok(150.5, 90.5) = false +presets::create(0, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c009efc0a0444391800f3ad00000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::update(0, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c00eefe0a04043e1800f3ad00000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::create(0, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c009efc0a044439180047e3000000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::update(0, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c00eefe0a04043e180047e3000000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::create(0, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c009efc0a044439180077ad00000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::update(0, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c00eefe0a04043e180077ad00000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::create(0, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c009efc0a0444391800c5b600000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::update(0, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c00eefe0a04043e1800c5b600000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::create(0, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(0, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(0, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(0, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(0, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(0, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(0, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(0, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(0, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(0, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(0, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(0, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::rename(0, "") = error: invalid value: name must be 1-32 printable ASCII characters +presets::rename(0, "Desk") = aa2534120c008efd0a04843a08001a12000000004465736b000000000000000000000000000000000000000000000000000000000000000000000000 +presets::rename(0, "Bereich1") = aa2534120c008efd0a04843a0c00b1a90000000042657265696368310000000000000000000000000000000000000000000000000000000000000000 +presets::rename(0, "äöü") = error: invalid value: name must be 1-32 printable ASCII characters +presets::rename(0, "12345678901234567890123456789012") = aa2534120c008efd0a04843a2400c1ee0000000031323334353637383930313233343536373839303132333435363738393031320000000000000000 +presets::rename(0, "123456789012345678901234567890123") = error: invalid value: name must be 1-32 printable ASCII characters +presets::delete(0) = aa2534120c00cefc0a0484390400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c009efa0a04843d2c002a5f00000000feffffffffffffff80ffffffffffffff00000000ffffffff00ffffffffffffffff00000000000000 +presets::recall(0) = aa2534120c00ff3c0a04c4391400d6fb000000000000803f0000803f0000803f0000803f000000000000000000000000000000000000000000000000 +presets::create(1, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c009efc0a0444391800ae3801000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::update(1, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c00eefe0a04043e1800ae3801000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::create(1, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c009efc0a04443918001a76010000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::update(1, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c00eefe0a04043e18001a76010000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::create(1, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c009efc0a04443918002a3801000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::update(1, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c00eefe0a04043e18002a3801000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::create(1, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c009efc0a0444391800982301000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::update(1, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c00eefe0a04043e1800982301000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::create(1, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(1, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(1, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(1, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(1, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(1, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(1, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(1, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(1, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(1, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(1, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(1, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::rename(1, "") = error: invalid value: name must be 1-32 printable ASCII characters +presets::rename(1, "Desk") = aa2534120c008efd0a04843a0800dbde010000004465736b000000000000000000000000000000000000000000000000000000000000000000000000 +presets::rename(1, "Bereich1") = aa2534120c008efd0a04843a0c004c6a0100000042657265696368310000000000000000000000000000000000000000000000000000000000000000 +presets::rename(1, "äöü") = error: invalid value: name must be 1-32 printable ASCII characters +presets::rename(1, "12345678901234567890123456789012") = aa2534120c008efd0a04843a240000330100000031323334353637383930313233343536373839303132333435363738393031320000000000000000 +presets::rename(1, "123456789012345678901234567890123") = error: invalid value: name must be 1-32 printable ASCII characters +presets::delete(1) = aa2534120c00cefc0a0484390400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c009efa0a04843d2c002a5f00000000feffffffffffffff80ffffffffffffff00000000ffffffff00ffffffffffffffff00000000000000 +presets::recall(1) = aa2534120c00ff3c0a04c4391400eb2a010000000000803f0000803f0000803f0000803f000000000000000000000000000000000000000000000000 +presets::create(2, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c009efc0a04443918004ac702000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::update(2, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c00eefe0a04043e18004ac702000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::create(2, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c009efc0a0444391800fe89020000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::update(2, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c00eefe0a04043e1800fe89020000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::create(2, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c009efc0a0444391800cec702000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::update(2, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c00eefe0a04043e1800cec702000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::create(2, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c009efc0a04443918007cdc02000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::update(2, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c00eefe0a04043e18007cdc02000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::create(2, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(2, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(2, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(2, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(2, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(2, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(2, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(2, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(2, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(2, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::create(2, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::update(2, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::rename(2, "") = error: invalid value: name must be 1-32 printable ASCII characters +presets::rename(2, "Desk") = aa2534120c008efd0a04843a08009bcb020000004465736b000000000000000000000000000000000000000000000000000000000000000000000000 +presets::rename(2, "Bereich1") = aa2534120c008efd0a04843a0c00486e0200000042657265696368310000000000000000000000000000000000000000000000000000000000000000 +presets::rename(2, "äöü") = error: invalid value: name must be 1-32 printable ASCII characters +presets::rename(2, "12345678901234567890123456789012") = aa2534120c008efd0a04843a240040150200000031323334353637383930313233343536373839303132333435363738393031320000000000000000 +presets::rename(2, "123456789012345678901234567890123") = error: invalid value: name must be 1-32 printable ASCII characters +presets::delete(2) = aa2534120c00cefc0a0484390400bfbf0200000000000000000000000000000000000000000000000000000000000000000000000000000000000000 aa2535120c009efa0a04843d2c002a5f00000000feffffffffffffff80ffffffffffffff00000000ffffffff00ffffffffffffffff00000000000000 +presets::recall(2) = aa2534120c00ff3c0a04c4391400af19020000000000803f0000803f0000803f0000803f000000000000000000000000000000000000000000000000 +presets::create(3, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = error: invalid value: preset slot is 0-2 +presets::create(3, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(3, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(3, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = error: invalid value: preset slot is 0-2 +presets::create(3, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(3, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(3, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(3, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset slot is 0-2 +presets::create(3, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset slot is 0-2 +presets::create(3, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(3, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::rename(3, "") = error: invalid value: preset slot is 0-2 +presets::rename(3, "Desk") = error: invalid value: preset slot is 0-2 +presets::rename(3, "Bereich1") = error: invalid value: preset slot is 0-2 +presets::rename(3, "äöü") = error: invalid value: preset slot is 0-2 +presets::rename(3, "12345678901234567890123456789012") = error: invalid value: preset slot is 0-2 +presets::rename(3, "123456789012345678901234567890123") = error: invalid value: preset slot is 0-2 +presets::delete(3) = error: invalid value: preset slot is 0-2 +presets::recall(3) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: preset slot is 0-2 +presets::create(255, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::update(255, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: preset slot is 0-2 +presets::rename(255, "") = error: invalid value: preset slot is 0-2 +presets::rename(255, "Desk") = error: invalid value: preset slot is 0-2 +presets::rename(255, "Bereich1") = error: invalid value: preset slot is 0-2 +presets::rename(255, "äöü") = error: invalid value: preset slot is 0-2 +presets::rename(255, "12345678901234567890123456789012") = error: invalid value: preset slot is 0-2 +presets::rename(255, "123456789012345678901234567890123") = error: invalid value: preset slot is 0-2 +presets::delete(255) = error: invalid value: preset slot is 0-2 +presets::recall(255) = error: invalid value: preset slot is 0-2 +presets::view_add(0, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c004f230a0484131800f3ad00000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::view_add(0, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c004f230a048413180047e3000000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(0, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c004f230a048413180077ad00000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(0, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c004f230a0484131800c5b600000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::view_add(0, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(0, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(0, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(0, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(0, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(0, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_rename(0, "") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(0, "Desk") = aa2534120c009f210a04441508001a12000000004465736b000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(0, "Bereich1") = aa2534120c009f210a0444150c00b1a90000000042657265696368310000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(0, "äöü") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(0, "12345678901234567890123456789012") = aa2534120c009f210a0444152400c1ee0000000031323334353637383930313233343536373839303132333435363738393031320000000000000000 +presets::view_rename(0, "123456789012345678901234567890123") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_remove(0) = aa2534120c007ee30a04c4130400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_go(0) = aa2534120c00eee00a0404160400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1504, 0) = aa2134120c00bbd10a0404150400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1444, 0) = aa2134120c004bd10a0444140400be070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_add(1, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c004f230a0484131800ae3801000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::view_add(1, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c004f230a04841318001a76010000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(1, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c004f230a04841318002a3801000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(1, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c004f230a0484131800982301000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::view_add(1, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(1, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(1, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(1, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(1, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(1, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_rename(1, "") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(1, "Desk") = aa2534120c009f210a0444150800dbde010000004465736b000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(1, "Bereich1") = aa2534120c009f210a0444150c004c6a0100000042657265696368310000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(1, "äöü") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(1, "12345678901234567890123456789012") = aa2534120c009f210a044415240000330100000031323334353637383930313233343536373839303132333435363738393031320000000000000000 +presets::view_rename(1, "123456789012345678901234567890123") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_remove(1) = aa2534120c007ee30a04c4130400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_go(1) = aa2534120c00eee00a0404160400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1504, 1) = aa2134120c00bbd10a0404150400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1444, 1) = aa2134120c004bd10a0444140400bffb0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_add(7, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c004f230a0484131800658707000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::view_add(7, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c004f230a0484131800d1c9070000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(7, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c004f230a0484131800e18707000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(7, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c004f230a0484131800539c07000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::view_add(7, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(7, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(7, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(7, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(7, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(7, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_rename(7, "") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(7, "Desk") = aa2534120c009f210a04441508005bf4070000004465736b000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(7, "Bereich1") = aa2534120c009f210a0444150c0044620700000042657265696368310000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(7, "äöü") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(7, "12345678901234567890123456789012") = aa2534120c009f210a0444152400807f0700000031323334353637383930313233343536373839303132333435363738393031320000000000000000 +presets::view_rename(7, "123456789012345678901234567890123") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_remove(7) = aa2534120c007ee30a04c4130400bf730700000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_go(7) = aa2534120c00eee00a0404160400bf730700000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1504, 7) = aa2134120c00bbd10a0404150400bf730700000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1444, 7) = aa2134120c004bd10a0444140400bf730700000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_add(255, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c004f230a048413180083aaff000000000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::view_add(255, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c004f230a048413180037e4ff0000000000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(255, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c004f230a048413180007aaff000000000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(255, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c004f230a0484131800b5b1ff000000000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::view_add(255, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(255, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(255, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(255, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(255, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(255, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_rename(255, "") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(255, "Desk") = aa2534120c009f210a04441508005516ff0000004465736b000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(255, "Bereich1") = aa2534120c009f210a0444150c00e6bcff00000042657265696368310000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(255, "äöü") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(255, "12345678901234567890123456789012") = aa2534120c009f210a04441524009ae5ff00000031323334353637383930313233343536373839303132333435363738393031320000000000000000 +presets::view_rename(255, "123456789012345678901234567890123") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_remove(255) = aa2534120c007ee30a04c41304008e13ff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_go(255) = aa2534120c00eee00a04041604008e13ff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1504, 255) = aa2134120c00bbd10a04041504008e13ff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1444, 255) = aa2134120c004bd10a04441404008e13ff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_add(4294967295, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c004f230a0484131800d251ffffffff000028410000a840000000000000004000007ac40000000000000000000000000000000000000000 +presets::view_add(4294967295, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c004f230a0484131800661fffffffff0000000000000000000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(4294967295, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c004f230a04841318005651ffffffff000016c300008c42000000000000803f00007ac40000000000000000000000000000000000000000 +presets::view_add(4294967295, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c004f230a0484131800e44affffffff000016430000b4c2000000000000804000007ac40000000000000000000000000000000000000000 +presets::view_add(4294967295, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(4294967295, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(4294967295, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(4294967295, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(4294967295, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_add(4294967295, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: zone view pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::view_rename(4294967295, "") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(4294967295, "Desk") = aa2534120c009f210a04441508005a02ffffffff4465736b000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(4294967295, "Bereich1") = aa2534120c009f210a0444150c00a5acffffffff42657265696368310000000000000000000000000000000000000000000000000000000000000000 +presets::view_rename(4294967295, "äöü") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_rename(4294967295, "12345678901234567890123456789012") = aa2534120c009f210a044415240081c1ffffffff31323334353637383930313233343536373839303132333435363738393031320000000000000000 +presets::view_rename(4294967295, "123456789012345678901234567890123") = error: invalid value: name must be 1-32 printable ASCII characters +presets::view_remove(4294967295) = aa2534120c007ee30a04c4130400bf93ffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_go(4294967295) = aa2534120c00eee00a0404160400bf93ffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1504, 4294967295) = aa2134120c00bbd10a0404150400bf93ffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::view_query(0x1444, 4294967295) = aa2134120c004bd10a0444140400bf93ffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000 +presets::startup_pose(0, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c00befe0a04c43e1800d15e00000000000028410000a8400000000000000040000000000000000000000000000000000000000000000000 +presets::startup_pose(0, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e18006510000000000000000000000000000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(0, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e1800555e00000000000016c300008c42000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(0, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c00befe0a04c43e1800e74500000000000016430000b4c20000000000008040000000000000000000000000000000000000000000000000 +presets::startup_pose(0, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(0, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(0, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(0, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(0, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(0, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(1, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c00befe0a04c43e18008ccb01000000000028410000a8400000000000000040000000000000000000000000000000000000000000000000 +presets::startup_pose(1, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e18003885010000000000000000000000000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(1, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e180008cb01000000000016c300008c42000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(1, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c00befe0a04c43e1800bad001000000000016430000b4c20000000000008040000000000000000000000000000000000000000000000000 +presets::startup_pose(1, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(1, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(1, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(1, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(1, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(1, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(2, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c00befe0a04c43e1800683402000000000028410000a8400000000000000040000000000000000000000000000000000000000000000000 +presets::startup_pose(2, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e1800dc7a020000000000000000000000000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(2, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e1800ec3402000000000016c300008c42000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(2, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c00befe0a04c43e18005e2f02000000000016430000b4c20000000000008040000000000000000000000000000000000000000000000000 +presets::startup_pose(2, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(2, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(2, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(2, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(2, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(2, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(3, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c00befe0a04c43e180035a103000000000028410000a8400000000000000040000000000000000000000000000000000000000000000000 +presets::startup_pose(3, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e180081ef030000000000000000000000000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(3, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e1800b1a103000000000016c300008c42000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(3, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c00befe0a04c43e180003ba03000000000016430000b4c20000000000008040000000000000000000000000000000000000000000000000 +presets::startup_pose(3, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(3, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(3, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(3, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(3, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(3, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(4, Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c00befe0a04c43e1800a38b04000000000028410000a8400000000000000040000000000000000000000000000000000000000000000000 +presets::startup_pose(4, Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e180017c5040000000000000000000000000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(4, Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c00befe0a04c43e1800278b04000000000016c300008c42000000000000803f000000000000000000000000000000000000000000000000 +presets::startup_pose(4, Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c00befe0a04c43e1800959004000000000016430000b4c20000000000008040000000000000000000000000000000000000000000000000 +presets::startup_pose(4, Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(4, Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(4, Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(4, Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(4, Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_pose(4, Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: start pose needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::zone_return_set(Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c008eda0a04844e1800d15e00000000000028410000a8400000000000000040000000000000000000000000000000000000000000000000 +presets::hand_return_set(Pose { pan: 10.5, tilt: -5.25, zoom: 2.0 }) = aa2534120c003f350a04c4241800d15e00000000000028410000a8400000000000000040000000000000000000000000000000000000000000000000 +presets::zone_return_set(Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c008eda0a04844e18006510000000000000000000000000000000000000803f000000000000000000000000000000000000000000000000 +presets::hand_return_set(Pose { pan: 0.0, tilt: 0.0, zoom: 1.0 }) = aa2534120c003f350a04c42418006510000000000000000000000000000000000000803f000000000000000000000000000000000000000000000000 +presets::zone_return_set(Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c008eda0a04844e1800555e00000000000016c300008c42000000000000803f000000000000000000000000000000000000000000000000 +presets::hand_return_set(Pose { pan: -150.0, tilt: -70.0, zoom: 1.0 }) = aa2534120c003f350a04c4241800555e00000000000016c300008c42000000000000803f000000000000000000000000000000000000000000000000 +presets::zone_return_set(Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c008eda0a04844e1800e74500000000000016430000b4c20000000000008040000000000000000000000000000000000000000000000000 +presets::hand_return_set(Pose { pan: 150.0, tilt: 90.0, zoom: 4.0 }) = aa2534120c003f350a04c4241800e74500000000000016430000b4c20000000000008040000000000000000000000000000000000000000000000000 +presets::zone_return_set(Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::hand_return_set(Pose { pan: 150.1, tilt: 0.0, zoom: 1.0 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::zone_return_set(Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::hand_return_set(Pose { pan: 0.0, tilt: 90.5, zoom: 1.0 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::zone_return_set(Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::hand_return_set(Pose { pan: 0.0, tilt: -70.5, zoom: 1.0 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::zone_return_set(Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::hand_return_set(Pose { pan: 0.0, tilt: 0.0, zoom: 4.01 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::zone_return_set(Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::hand_return_set(Pose { pan: 0.0, tilt: 0.0, zoom: 0.99 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::zone_return_set(Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::hand_return_set(Pose { pan: NaN, tilt: 0.0, zoom: 1.0 }) = error: invalid value: return position needs pan -150..150, tilt -70..90 and zoom 1.0-4.0 +presets::startup_picture_settings(Static) = aa2534120c00df3e0a04443e2800e012ffffffffffffffff80ffffffffffffff00000000ffffffff0000000000000000000000000000000000000000 +presets::startup_picture_settings(Normal) = aa2534120c00df3e0a04443e280082f400ffffffffffffff80ffffffffffffff00000000ffffffff0000000000000000000000000000000000000000 +presets::startup_picture_settings(UpperBody) = aa2534120c00df3e0a04443e28002f3201ffffffffffffff80ffffffffffffff00000000ffffffff0000000000000000000000000000000000000000 +presets::startup_picture_settings(Headless) = aa2534120c00df3e0a04443e280076ff03ffffffffffffff80ffffffffffffff00000000ffffffff0000000000000000000000000000000000000000 +presets::startup_picture_settings(Group) = aa2534120c00df3e0a04443e28004ec709ffffffffffffff80ffffffffffffff00000000ffffffff0000000000000000000000000000000000000000 +presets::startup_picture_settings(None) = aa2534120c00df3e0a04443e28004dd4feffffffffffffff80ffffffffffffff00000000ffffffff0000000000000000000000000000000000000000 +presets::startup_with_ai(0..0, 0x00) = none +presets::startup_with_ai(0..0, 0xfe) = none +presets::startup_with_ai(0..0, 0xff) = none +presets::startup_with_ai(0..39, 0x00) = none +presets::startup_with_ai(0..39, 0xfe) = none +presets::startup_with_ai(0..39, 0xff) = none +presets::startup_with_ai(0..40, 0x00) = aa2534120c00df3e0a04443e2800187d000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262700000000 +presets::startup_with_ai(0..40, 0xfe) = aa2534120c00df3e0a04443e2800d75dfe0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262700000000 +presets::startup_with_ai(0..40, 0xff) = aa2534120c00df3e0a04443e28007a9bff0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262700000000 +presets::startup_with_ai(0..48, 0x00) = aa2534120c00df3e0a04443e2800187d000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262700000000 +presets::startup_with_ai(0..48, 0xfe) = aa2534120c00df3e0a04443e2800d75dfe0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262700000000 +presets::startup_with_ai(0..48, 0xff) = aa2534120c00df3e0a04443e28007a9bff0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262700000000 diff --git a/crates/obsbot4linux/Cargo.toml b/crates/obsbot4linux/Cargo.toml new file mode 100644 index 0000000..a5e8385 --- /dev/null +++ b/crates/obsbot4linux/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "obsbot4linux" +description = "Control OBSBOT webcams on Linux from the console and the DMS widget" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish.workspace = true +readme = "../../README.md" + +[[bin]] +name = "obsbot4linux" +path = "src/main.rs" + +[dependencies] +obsbot-device.workspace = true +clap.workspace = true +nix.workspace = true +serde.workspace = true +serde_json.workspace = true + +[package.metadata.deb] +maintainer = "Thomas Kroll " +section = "video" +extended-description = "Most settings OBSBOT Center offers, from the console and from the DMS bar widget.\nWhat is missing: https://github.com/hellwand/obsbot4linux/blob/main/docs/center-parity.md\nThe obsbot4linux daemon (systemd user socket) owns the camera state." +depends = "$auto" +license-file = ["../../LICENSE", "0"] +assets = [ + ["target/release/obsbot4linux", "usr/bin/", "755"], + ["../../packaging/systemd/obsbot4linux.service", "usr/lib/systemd/user/", "644"], + ["../../packaging/systemd/obsbot4linux.socket", "usr/lib/systemd/user/", "644"], + ["../../README.md", "usr/share/doc/obsbot4linux/", "644"], + ["../../CHANGELOG.md", "usr/share/doc/obsbot4linux/", "644"], +] + +[package.metadata.generate-rpm] +assets = [ + { source = "target/release/obsbot4linux", dest = "/usr/bin/obsbot4linux", mode = "755" }, + { source = "../../packaging/systemd/obsbot4linux.service", dest = "/usr/lib/systemd/user/obsbot4linux.service", mode = "644" }, + { source = "../../packaging/systemd/obsbot4linux.socket", dest = "/usr/lib/systemd/user/obsbot4linux.socket", mode = "644" }, + { source = "../../README.md", dest = "/usr/share/doc/obsbot4linux/README.md", mode = "644", doc = true }, + { source = "../../CHANGELOG.md", dest = "/usr/share/doc/obsbot4linux/CHANGELOG.md", mode = "644", doc = true }, + { source = "../../LICENSE", dest = "/usr/share/licenses/obsbot4linux/LICENSE", mode = "644" }, +] diff --git a/src/cli.rs b/crates/obsbot4linux/src/cli.rs similarity index 99% rename from src/cli.rs rename to crates/obsbot4linux/src/cli.rs index 1869c42..4d458e1 100644 --- a/src/cli.rs +++ b/crates/obsbot4linux/src/cli.rs @@ -5,11 +5,12 @@ use std::io::Write; use clap::{Parser, Subcommand, ValueEnum}; -use obsbot4linux::camera::{Camera, Identity}; -use obsbot4linux::commands::*; -use obsbot4linux::device::Detected; -use obsbot4linux::error::{Error, Result}; -use obsbot4linux::presets::{Pose, StartupAi}; +use obsbot_device::enumeration::Detected; +use obsbot_device::error::{Error, Result}; +use obsbot_device::family::exchange::Camera; +use obsbot_device::models::tiny2lite::camera::Identity; +use obsbot_device::models::tiny2lite::commands::*; +use obsbot_device::models::tiny2lite::presets::{Pose, StartupAi}; use serde_json::{Value, json}; #[derive(Parser)] @@ -971,7 +972,7 @@ pub fn run() -> i32 { /// `obsbot4linux detect`: exit 0 and the model (and device) of a supported camera, exit 1 and /// no output without one. Never goes through the daemon. fn detect(json: bool) -> i32 { - let Some(found) = obsbot4linux::device::detect() else { + let Some(found) = obsbot_device::enumeration::detect() else { return 1; }; let (value, text) = detect_output(&found); @@ -996,7 +997,7 @@ fn report(json: bool, result: Result<()>) -> i32 { #[cfg(test)] mod tests { use super::*; - use obsbot4linux::models::MODELS; + use obsbot_device::registry::MODELS; fn parse(args: &str) -> std::result::Result { Cli::try_parse_from(std::iter::once("obsbot4linux").chain(args.split(' '))) diff --git a/src/client.rs b/crates/obsbot4linux/src/client.rs similarity index 97% rename from src/client.rs rename to crates/obsbot4linux/src/client.rs index ac5675f..2096c31 100644 --- a/src/client.rs +++ b/crates/obsbot4linux/src/client.rs @@ -4,7 +4,7 @@ use crate::cli::Cli; use crate::proto::{Response, socket_path}; -use obsbot4linux::error::{Error, Result}; +use obsbot_device::error::{Error, Result}; use serde_json::{Value, json}; use std::fs::OpenOptions; use std::io::{BufRead, BufReader, Write}; @@ -55,7 +55,7 @@ fn connect_or_start() -> Result { } let exe = std::env::current_exe()?; // its errors go next to the state file; without a log they are lost, not fatal - let log = obsbot4linux::state::path().with_file_name("daemon.log"); + let log = obsbot_device::legacy::state::path().with_file_name("daemon.log"); let stderr = log .parent() .map_or(Ok(()), std::fs::create_dir_all) diff --git a/src/daemon.rs b/crates/obsbot4linux/src/daemon.rs similarity index 99% rename from src/daemon.rs rename to crates/obsbot4linux/src/daemon.rs index d0831d0..04372b6 100644 --- a/src/daemon.rs +++ b/crates/obsbot4linux/src/daemon.rs @@ -10,10 +10,12 @@ use clap::Parser; use nix::errno::Errno; use nix::fcntl::{Flock, FlockArg}; use nix::sys::stat::{Mode, umask}; -use obsbot4linux::camera::{CAPABILITIES, Camera, Identity, ZoneInfo}; -use obsbot4linux::error::{Error, Result}; -use obsbot4linux::state; -use obsbot4linux::streaming; +use obsbot_device::error::{Error, Result}; +use obsbot_device::family::exchange::Camera; +use obsbot_device::family::streaming; +use obsbot_device::legacy::full_status::{CAPABILITIES, ZoneInfo}; +use obsbot_device::legacy::state; +use obsbot_device::models::tiny2lite::camera::Identity; use serde_json::{Value, json}; use std::collections::HashSet; use std::fs::{File, OpenOptions}; diff --git a/src/main.rs b/crates/obsbot4linux/src/main.rs similarity index 100% rename from src/main.rs rename to crates/obsbot4linux/src/main.rs diff --git a/src/proto.rs b/crates/obsbot4linux/src/proto.rs similarity index 100% rename from src/proto.rs rename to crates/obsbot4linux/src/proto.rs diff --git a/crates/obsbot4linux/tests/cli_surface.rs b/crates/obsbot4linux/tests/cli_surface.rs new file mode 100644 index 0000000..dcfadcc --- /dev/null +++ b/crates/obsbot4linux/tests/cli_surface.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! CLI surface guard of the groundwork stage (rebuild plan, stage G): the `--help` text of the +//! CLI and of every subcommand, compared with `tests/fixtures/cli-surface.txt`, which was +//! written from the 0.13 code before the workspace move, and `--version`. Only `--help` and +//! `--version` run: nothing opens a device or a socket. +//! +//! `CLI_SURFACE_BLESS=1 cargo test --test cli_surface` writes the file: once when this test is +//! added, and on `main` in a change that alters a help text on purpose. A move never writes it; +//! every other run compares. + +use std::process::Command; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/cli-surface.txt" +); + +fn run(args: &[&str]) -> String { + let out = Command::new(env!("CARGO_BIN_EXE_obsbot4linux")) + .args(args) + .output() + .expect("obsbot4linux runs"); + assert!(out.status.success(), "obsbot4linux {args:?} failed"); + String::from_utf8(out.stdout).expect("UTF-8 output") +} + +/// The names in the "Commands:" section of a help text: the lines indented by exactly two +/// spaces (a longer indent continues a description). +fn subcommands(help: &str) -> Vec { + help.lines() + .skip_while(|l| *l != "Commands:") + .skip(1) + .take_while(|l| l.starts_with(' ')) + .filter(|l| !l.starts_with(" ")) + .filter_map(|l| l.split_whitespace().next()) + .filter(|name| *name != "help") + .map(String::from) + .collect() +} + +fn walk(path: &mut Vec, out: &mut String) { + let mut args: Vec<&str> = path.iter().map(String::as_str).collect(); + args.push("--help"); + let help = run(&args); + out.push_str(&format!("=== obsbot4linux {}\n{help}", args.join(" "))); + for sub in subcommands(&help) { + path.push(sub); + walk(path, out); + path.pop(); + } +} + +#[test] +fn help_texts_are_unchanged() { + let mut out = String::new(); + walk(&mut Vec::new(), &mut out); + // a hidden subcommand is not listed under "Commands:" + walk(&mut vec!["gimbal".into(), "center".into()], &mut out); + if std::env::var_os("CLI_SURFACE_BLESS").is_some() { + std::fs::write(FIXTURE, &out).unwrap(); + return; + } + let recorded = std::fs::read_to_string(FIXTURE).expect("tests/fixtures/cli-surface.txt"); + assert_eq!(out, recorded, "a help text of the CLI changed"); +} + +#[test] +fn version_is_the_package_version() { + // the workspace move must keep the version the binary reports + assert_eq!( + run(&["--version"]), + format!("obsbot4linux {}\n", env!("CARGO_PKG_VERSION")) + ); +} diff --git a/crates/obsbot4linux/tests/fixtures/cli-surface.txt b/crates/obsbot4linux/tests/fixtures/cli-surface.txt new file mode 100644 index 0000000..2dcd923 --- /dev/null +++ b/crates/obsbot4linux/tests/fixtures/cli-surface.txt @@ -0,0 +1,1126 @@ +=== obsbot4linux --help +Control the OBSBOT Tiny 2 Lite + +Usage: obsbot4linux [OPTIONS] + +Commands: + daemon Run the daemon: owns the camera, serves the socket, broadcasts state changes + watch Stream state events as JSON lines and forward JSON requests from stdin (for widgets) + live-pose Ask the daemon to poll the gimbal pose for this connection (a `watch` request; the daemon polls it every second only while a connection wants it). Not usable as a one-off call + detect Exit 0 and print the model (--json: model and video device) when a supported camera is connected, exit 1 with no output when not. Enumeration only: no daemon, no camera command + status Show all settings (the camera's status block, tracking readback, zoom, image controls, remembered state) + sleep Put the camera to sleep + wake Wake the camera, or set the stored wake-up zoom (`wake zoom`) or the tracking mode at power-on (`wake mode`) + tracking AI tracking mode + speed Tracking type: standard, sport (fixed fast gain) or headroom (more room above the head) + speed-mode Fine tracking speed, from lazy to very-fast, or auto. Ignored while `speed sport` is set or the part mode is not normal + framing Framing options of the human tracking; an AI mode change overwrites them + hdr HDR + exposure Exposure: automatic metering over the whole picture (global) or on faces (face), or manual, optionally with the shutter time and gain (`image gain` sets the gain too; `image exposure_time` is refused unless the exposure is already manual) + zoom Zoom factor 1.0-4.0; with --speed the camera zooms smoothly instead of jumping + gimbal Move the gimbal to an absolute pose in degrees (switches tracking to static first), or `gimbal stop|rate` + fov Field of view (ignored by the camera while it sleeps) + led Status LED: brightness 0 (off) to 3, or `led blink on|off` + mirror Mirror the image + invert Invert view and gimbal (reverses the gimbal's pan direction) + auto-sleep Auto-sleep: "off" or a time in seconds + sleep-background Picture shown while the camera sleeps + sleep-background-mirror Mirror the sleep background + audio Microphone settings + gesture Gesture control + hand Hand tracking: which hand, return position, area, axes + zone Zone tracking + focus Focus: autofocus mode, continuous autofocus, manual focus + startup Initial state: the pose the camera moves to when it powers on + image UVC image control, e.g. "image brightness 128" (names are in "status") + preset Presets stored in the camera (slots 0-2) + factory-reset Reset the camera to factory settings (it restarts and deletes its presets) + help Print this message or the help of the given subcommand(s) + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help + -V, --version Print version +=== obsbot4linux daemon --help +Run the daemon: owns the camera, serves the socket, broadcasts state changes + +Usage: obsbot4linux daemon [OPTIONS] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux watch --help +Stream state events as JSON lines and forward JSON requests from stdin (for widgets) + +Usage: obsbot4linux watch [OPTIONS] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux live-pose --help +Ask the daemon to poll the gimbal pose for this connection (a `watch` request; the daemon polls it every second only while a connection wants it). Not usable as a one-off call + +Usage: obsbot4linux live-pose [OPTIONS] + +Arguments: + [possible values: on, off] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux detect --help +Exit 0 and print the model (--json: model and video device) when a supported camera is connected, exit 1 with no output when not. Enumeration only: no daemon, no camera command + +Usage: obsbot4linux detect [OPTIONS] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux status --help +Show all settings (the camera's status block, tracking readback, zoom, image controls, remembered state) + +Usage: obsbot4linux status [OPTIONS] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux sleep --help +Put the camera to sleep + +Usage: obsbot4linux sleep [OPTIONS] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux wake --help +Wake the camera, or set the stored wake-up zoom (`wake zoom`) or the tracking mode at power-on (`wake mode`) + +Usage: obsbot4linux wake [OPTIONS] [COMMAND] + +Commands: + zoom Stored wake-up zoom, factor 1.0-4.0 (the camera stores 0-100 percent). Effect unknown on the Tiny 2 Lite: no change at power-on or on waking from sleep + mode Tracking mode the camera starts in at power-on, not on waking from sleep (the current mode stays) + help Print this message or the help of the given subcommand(s) + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux wake zoom --help +Stored wake-up zoom, factor 1.0-4.0 (the camera stores 0-100 percent). Effect unknown on the Tiny 2 Lite: no change at power-on or on waking from sleep + +Usage: obsbot4linux wake zoom [OPTIONS] + +Arguments: + + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux wake mode --help +Tracking mode the camera starts in at power-on, not on waking from sleep (the current mode stays) + +Usage: obsbot4linux wake mode [OPTIONS] + +Arguments: + [possible values: static, normal, upper-body, close-up, headless, lower-body, hand, group] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux tracking --help +AI tracking mode + +Usage: obsbot4linux tracking [OPTIONS] + +Arguments: + [possible values: static, normal, upper-body, close-up, headless, lower-body, hand, group] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux speed --help +Tracking type: standard, sport (fixed fast gain) or headroom (more room above the head) + +Usage: obsbot4linux speed [OPTIONS] + +Arguments: + [possible values: standard, headroom, sport] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux speed-mode --help +Fine tracking speed, from lazy to very-fast, or auto. Ignored while `speed sport` is set or the part mode is not normal + +Usage: obsbot4linux speed-mode [OPTIONS] + +Arguments: + [possible values: lazy, slow, normal, fast, very-fast, auto] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux framing --help +Framing options of the human tracking; an AI mode change overwrites them + +Usage: obsbot4linux framing [OPTIONS] + +Commands: + auto-zoom Auto zoom of the human tracking on or off + size Size of the auto zoom framing + zoom-speed How fast the auto zoom follows + offset Shift the framing by a fraction of the frame, -0.16 to 0.16 + part Which part of the body the framing follows + help Print this message or the help of the given subcommand(s) + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux framing auto-zoom --help +Auto zoom of the human tracking on or off + +Usage: obsbot4linux framing auto-zoom [OPTIONS] + +Arguments: + [possible values: on, off] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux framing size --help +Size of the auto zoom framing + +Usage: obsbot4linux framing size [OPTIONS] + +Arguments: + [possible values: long-shot, medium-long-shot, medium-shot, medium-close-up, close-up, big-close-up] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux framing zoom-speed --help +How fast the auto zoom follows + +Usage: obsbot4linux framing zoom-speed [OPTIONS] + +Arguments: + [possible values: slow, medium, fast] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux framing offset --help +Shift the framing by a fraction of the frame, -0.16 to 0.16 + +Usage: obsbot4linux framing offset [OPTIONS] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --x + --direct Do not use a running daemon; talk to the camera directly + --y + -h, --help Print help +=== obsbot4linux framing part --help +Which part of the body the framing follows + +Usage: obsbot4linux framing part [OPTIONS] + +Arguments: + [possible values: normal, head-removal, lower-body] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux hdr --help +HDR + +Usage: obsbot4linux hdr [OPTIONS] + +Arguments: + [possible values: on, off] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --direct Do not use a running daemon; talk to the camera directly + -h, --help Print help +=== obsbot4linux exposure --help +Exposure: automatic metering over the whole picture (global) or on faces (face), or manual, optionally with the shutter time and gain (`image gain` sets the gain too; `image exposure_time` is refused unless the exposure is already manual) + +Usage: obsbot4linux exposure [OPTIONS] + +Arguments: + [possible values: global, face, manual] + +Options: + --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) + --time