From c2d1d0e0e6a6fd25bd092cd0c2ce181906d08ff1 Mon Sep 17 00:00:00 2001 From: pons Date: Sat, 12 Sep 2026 22:14:53 -0300 Subject: [PATCH] Add Link 2C digital framing via Host PTZ --- README.md | 42 ++++++- docs/link2c-framing.md | 136 ++++++++++++++++++++++ docs/safety.md | 19 ++-- src/camera/insta360/link2.rs | 6 +- src/camera/insta360/link2c.rs | 207 ++++++++++++++++++++++++++++++++++ src/camera/insta360/mod.rs | 1 + src/camera/insta360/xu.rs | 5 +- src/cli.rs | 71 ++++++++++++ src/commands/frame.rs | 78 +++++++++++++ src/commands/mod.rs | 2 + src/error.rs | 3 + tests/hardware.rs | 19 +++- 12 files changed, 573 insertions(+), 16 deletions(-) create mode 100644 docs/link2c-framing.md create mode 100644 src/camera/insta360/link2c.rs create mode 100644 src/commands/frame.rs diff --git a/README.md b/README.md index ceb32e7..62b8e51 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,48 @@ Python, and never detaches the `uvcvideo` driver. | model | USB id | status | |-------|--------|--------| | Insta360 Link 2 | `2e1a:4c04` | validated on real hardware | -| Insta360 Link 2C | `2e1a:4c03` | zoom and image controls; detection and read-only checks validated; no motorized pan/tilt | +| Insta360 Link 2C | `2e1a:4c03` | image controls and zoom; experimental digital framing; no motorized pan/tilt | | Insta360 Link (original) | `2e1a:4c01` | recognised, **not tested** | -On the Link 2C, pan/tilt, movement commands (`left`/`right`/`up`/`down`/`center`/`move`) and preset save/load are not supported and exit with code 7 (`UnsupportedControl`), because presets require pan and tilt. +### Link 2C digital framing + +Link 2C has no gimbal. Use `frame` to move its digital crop rather than +`pan`, `tilt`, `left`, `right`, `up`, `down`, `move`, or `center`. Those +motor commands, and saving/loading pan/tilt presets, are unsupported on 2C +and exit with code 7 (`UnsupportedControl`), because presets require pan and tilt. + +```sh +linkctl frame # read crop center and zoom +linkctl frame --zoom 2 --x 0.5 --y 0.5 # 2x zoom, centered +linkctl frame --x 0.6 # change X; preserve Y and zoom +linkctl frame --y 0.4 # change Y; preserve X and zoom +linkctl frame --center # recenter without changing zoom +linkctl frame --json # normalized x/y and zoom multiplier +``` + +Coordinates are normalized from 0 to 1, with 0.5 at the center; they are +not degrees. Zoom ranges from 1 to 4. At 1x there is no room to move the +crop, and firmware may clamp positions near the edges when zoomed in. +Writes require an active stream (or explicit `--force`) and validate the +extension-unit GUID, eight-byte payload length, and SET capability. Only +normal/tracking/Auto Framing mode-state layouts are decoded. Other modes +or idle firmware may report that framing state is unavailable. + +Output reports readback, not merely the requested values, with a warning +if the camera has not confirmed the change. Auto Framing can override manual +positioning; disable it if the crop will not stay where requested. + +Detection, coordinate readback, and command length/capability queries have +been verified on a physical Link 2C. Digital framing writes have also been +confirmed working by the camera owner in manual testing. Edge clamping, +direction polarity, and interaction with Auto Framing still need broader +validation. See [protocol details](docs/link2c-framing.md). ## Features * Pan, tilt, zoom in human units (degrees, zoom factor) with device-reported ranges, absolute and relative. +* Experimental Link 2C digital crop positioning with `frame --x/--y/--zoom`. * Focus, white balance, brightness, contrast, saturation, sharpness, hue. * Framing presets stored in a TOML config file. * Resolution, pixel format (MJPEG/H.264) and frame rate: list what the @@ -287,6 +320,7 @@ linkctl pan [DEGREES] absolute pan, negative = left; no arg reads linkctl tilt [DEGREES] absolute tilt, negative = down; no arg reads linkctl move --pan 30 --tilt -10 +linkctl frame [--x X --y Y --zoom FACTOR | --center] Link 2C digital crop linkctl zoom [FACTOR] 1 .. 4 on the Link 2 linkctl focus auto|VALUE linkctl wb auto|KELVIN @@ -487,7 +521,9 @@ Check your device permissions or group membership. * Standard V4L2 controls are the normal path and are validated against the device's own ranges. -* Vendor writes are limited to one documented, reversible, 1-byte control +* Vendor writes are limited to documented AI tracking and Link 2C digital + framing controls; see [protocol details](docs/link2c-framing.md). +* The AI tracking write is a reversible, 1-byte control and go through read → validate (`GET_LEN`, `GET_INFO`, GUID) → write → read-back. * The kernel driver is never detached; libusb is not used at all. diff --git a/docs/link2c-framing.md b/docs/link2c-framing.md new file mode 100644 index 0000000..a96d6b1 --- /dev/null +++ b/docs/link2c-framing.md @@ -0,0 +1,136 @@ +# Link 2C digital framing protocol + +Status: recovered by static analysis of the official Linux SDK and implemented +in `src/camera/insta360/link2c.rs` via the `frame` command. Readback and +GET_LEN/GET_INFO have been validated on a physical camera. The camera owner +subsequently confirmed that framing writes work in manual testing. That is +user-reported hardware validation, not an automated visual-motion test. +No SDK executable was run. + +## Source and reproducibility + +Official repository: https://github.com/Insta360Develop/Link-SDK + +Analyzed revision: `2dd2c78186b0335a530042ae49accbef249d6a95` + +Binary: `UVCCamera_Linux/bin/libUVCCamera.so` (x86-64 ELF) + +SHA-256: `1caf4198fc10f8f16ace1f93263fa8627f7cfb60902688549faac48863798061` + +Public declarations: `UVCCamera_Linux/include/uvc_common.h` (`HostPTZInfo`) +and `uvc_camera.h` (`GetHostPTZ` / `SetHostPTZ`). Example: +`UVCCamera_Linux/test/main.cc`, case 1108. + +Inspect without executing the SDK: + +```sh +nm -DC libUVCCamera.so | grep HostPTZ +objdump -dC -Mintel libUVCCamera.so > sdk.asm +``` + +## Write crop position and zoom + +- Transport: `UVCIOC_CTRL_QUERY` on the video node, not libusb. +- Extension GUID: `E307E649-4618-A3FF-82FC-2D8B5F216773`. +- Unit ID on the attached Link 2C: **10**. Resolve/validate by GUID. +- Selector: **0x13**. +- Request: `UVC_SET_CUR` (**0x01**). +- Logical payload: **8 bytes**. + +| Byte offset | Encoding | Meaning | +| --- | --- | --- | +| 0 | uint16 little-endian | Zoom ratio; example accepts 100..400 (1x..4x) | +| 2 | uint16 little-endian | Horizontal crop center, normalized X times 65535 | +| 4 | uint16 little-endian | Vertical crop center, normalized Y times 65535 | +| 6 | uint8 | Movement step; SDK example uses 20 | +| 7 | uint8 | Zoom step; SDK example uses 20 | + +Encoding example, not a hardware test: + +```python +import struct +payload = struct.pack(' AiMode::Whiteboard, 0x05 => AiMode::Overhead, 0x06 => AiMode::DeskView, + 0x07 => AiMode::AutoFraming, 0xFF => AiMode::Idle, other => AiMode::Unknown(other), } @@ -111,6 +112,7 @@ impl AiMode { AiMode::Whiteboard => "whiteboard".into(), AiMode::Overhead => "overhead".into(), AiMode::DeskView => "deskview".into(), + AiMode::AutoFraming => "auto framing".into(), AiMode::Idle => "idle".into(), AiMode::Unknown(b) => format!("unknown (0x{b:02x})"), } diff --git a/src/camera/insta360/link2c.rs b/src/camera/insta360/link2c.rs new file mode 100644 index 0000000..9b9cbfc --- /dev/null +++ b/src/camera/insta360/link2c.rs @@ -0,0 +1,207 @@ +//! Link 2C digital crop positioning. Protocol evidence: docs/link2c-framing.md. + +use super::link2::{self, guid, AI_MODE_STATUS, XU_IMAGE_UNIT, XU_INFO_UNIT}; +use super::xu::XuControl; +use crate::camera::model::Model; +use crate::camera::Camera; +use crate::error::{Error, Result}; + +/// Official SDK SetHostPTZ: three LE uint16 values, followed by two step bytes. +pub const HOST_PTZ: XuControl = XuControl { + unit: XU_IMAGE_UNIT, + selector: 0x13, + len: 8, + name: "link2c_host_ptz", +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Frame { + pub x: u16, + pub y: u16, + pub zoom: u16, + mode: u8, +} + +impl Frame { + pub fn normalized_x(self) -> f64 { + f64::from(self.x) / f64::from(u16::MAX) + } + + pub fn normalized_y(self) -> f64 { + f64::from(self.y) / f64::from(u16::MAX) + } + + pub fn zoom_factor(self) -> f64 { + f64::from(self.zoom) / 100.0 + } + + /// Preserve exact raw coordinates when only one axis or zoom is changed. + pub fn updated(self, x: Option, y: Option, zoom: Option) -> Result { + let next = Self { + x: x.map(|v| coordinate(v, "x")).transpose()?.unwrap_or(self.x), + y: y.map(|v| coordinate(v, "y")).transpose()?.unwrap_or(self.y), + zoom: zoom.map(zoom_value).transpose()?.unwrap_or(self.zoom), + ..self + }; + Ok(next) + } + + fn payload(self) -> [u8; 8] { + let mut bytes = [0; 8]; + bytes[0..2].copy_from_slice(&self.zoom.to_le_bytes()); + bytes[2..4].copy_from_slice(&self.x.to_le_bytes()); + bytes[4..6].copy_from_slice(&self.y.to_le_bytes()); + bytes[6] = 20; + bytes[7] = 20; + bytes + } +} + +pub fn coordinate(value: f64, axis: &str) -> Result { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + return Err(Error::InvalidValue(format!( + "frame {axis} must be between 0 and 1" + ))); + } + Ok((value * f64::from(u16::MAX)).round() as u16) +} + +pub fn zoom_value(value: f64) -> Result { + if !value.is_finite() || !(1.0..=4.0).contains(&value) { + return Err(Error::InvalidValue( + "frame zoom must be between 1 and 4".into(), + )); + } + Ok((value * 100.0).round() as u16) +} + +fn check_units(cam: &Camera) -> Result<()> { + if cam.info().model != Model::Link2C { + return Err(Error::UnsupportedControl("digital framing (Link 2C only)")); + } + let descriptors = link2::read_descriptors(&cam.info().usb.sysfs_path)?; + let units = link2::parse_extension_units(&descriptors); + link2::confirm_unit(&units, XU_INFO_UNIT, &guid::INFO)?; + link2::confirm_unit(&units, HOST_PTZ.unit, &guid::IMAGE) +} + +fn decode_mode(bytes: &[u8], zoom: u16) -> Result { + // The SDK decodes these offsets only in the extended mode-state layout. + // Special modes and the idle/transition sentinel must not become coordinates. + if bytes.len() < 52 || !matches!(bytes[0], 0 | 1 | 7) { + return Err(Error::Vendor( + "digital framing state unavailable; use normal video mode with a live stream".into(), + )); + } + if !(100..=400).contains(&zoom) { + return Err(Error::Vendor(format!( + "unexpected frame zoom readback: {zoom}" + ))); + } + Ok(Frame { + x: u16::from_le_bytes([bytes[38], bytes[39]]), + y: u16::from_le_bytes([bytes[40], bytes[41]]), + zoom, + mode: bytes[0], + }) +} + +pub fn read(cam: &Camera) -> Result { + check_units(cam)?; + let bytes = cam + .device() + .xu_read_reported(AI_MODE_STATUS.unit, AI_MODE_STATUS.selector)?; + let zoom = cam.get_raw(crate::camera::controls::Control::ZoomAbsolute)?; + let zoom = u16::try_from(zoom) + .map_err(|_| Error::Vendor(format!("unexpected frame zoom readback: {zoom}")))?; + decode_mode(&bytes, zoom) +} + +/// Caller must pass the activity guard. The XU layer validates length and SET support. +pub fn write(cam: &Camera, frame: Frame) -> Result<()> { + check_units(cam)?; + cam.device().xu_write(&HOST_PTZ, &frame.payload()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(mode: u8) -> Frame { + let mut bytes = [0; 56]; + bytes[0] = mode; + bytes[38..42].copy_from_slice(&[0x34, 0x12, 0xcd, 0xab]); + decode_mode(&bytes, 200).unwrap() + } + + #[test] + fn command_and_payload_match_sdk() { + assert_eq!( + (HOST_PTZ.unit, HOST_PTZ.selector, HOST_PTZ.len), + (10, 0x13, 8) + ); + assert_eq!( + sample(0).payload(), + [0xc8, 0, 0x34, 0x12, 0xcd, 0xab, 20, 20] + ); + } + + #[test] + fn reads_coordinates_from_mode_not_write_selector() { + let frame = sample(0); + assert_eq!((frame.x, frame.y, frame.zoom), (0x1234, 0xabcd, 200)); + assert_eq!(coordinate(frame.normalized_x(), "x").unwrap(), frame.x); + assert_eq!(coordinate(frame.normalized_y(), "y").unwrap(), frame.y); + assert_eq!(frame.zoom_factor(), 2.0); + } + + #[test] + fn partial_updates_preserve_other_raw_fields() { + let current = sample(0); + let next = current.updated(Some(0.75), None, None).unwrap(); + assert_eq!(next.x, 49151); + assert_eq!((next.y, next.zoom), (current.y, current.zoom)); + let next = current.updated(None, None, Some(1.5)).unwrap(); + assert_eq!((next.x, next.y, next.zoom), (current.x, current.y, 150)); + let next = current.updated(Some(0.5), Some(0.5), None).unwrap(); + assert_eq!((next.x, next.y, next.zoom), (32768, 32768, 200)); + } + + #[test] + fn rejects_bad_coordinates_and_zoom() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.01, 1.01] { + assert!(sample(0).updated(Some(bad), None, None).is_err()); + assert!(sample(0).updated(None, Some(bad), None).is_err()); + } + for bad in [f64::NAN, f64::INFINITY, -1.0, 0.99, 4.01] { + assert!(sample(0).updated(None, None, Some(bad)).is_err()); + } + assert_eq!(coordinate(0.0, "x").unwrap(), 0); + assert_eq!(coordinate(1.0, "x").unwrap(), 65535); + assert_eq!(zoom_value(1.0).unwrap(), 100); + assert_eq!(zoom_value(4.0).unwrap(), 400); + } + + #[test] + fn rejects_short_special_and_idle_mode_payloads() { + for len in 0..52 { + assert!(decode_mode(&vec![0; len], 200).is_err()); + } + for mode in [4, 5, 6, 0xff, 0xfe] { + let mut bytes = [0; 56]; + bytes[0] = mode; + assert!(decode_mode(&bytes, 200).is_err()); + } + assert!(decode_mode(&[0; 56], 0).is_err()); + assert!(decode_mode(&[0; 56], 401).is_err()); + } + + #[test] + fn auto_framing_mode_uses_the_same_coordinate_layout() { + for mode in [1, 7] { + let frame = sample(mode); + assert_eq!(frame.x, 0x1234); + assert!(frame.updated(Some(0.5), None, None).is_ok()); + } + } +} diff --git a/src/camera/insta360/mod.rs b/src/camera/insta360/mod.rs index a81ce71..0803158 100644 --- a/src/camera/insta360/mod.rs +++ b/src/camera/insta360/mod.rs @@ -5,4 +5,5 @@ //! Units. See `docs/research.md` and `docs/safety.md`. pub mod link2; +pub mod link2c; pub mod xu; diff --git a/src/camera/insta360/xu.rs b/src/camera/insta360/xu.rs index bd663ff..014f8e5 100644 --- a/src/camera/insta360/xu.rs +++ b/src/camera/insta360/xu.rs @@ -136,8 +136,9 @@ impl V4l2Device { /// `SET_CUR` of a documented control. The payload length is validated /// against both the constant and the device's `GET_LEN`, and `GET_INFO` - /// must advertise SET support. Callers are expected to have produced - /// `payload` by read-modify-write of [`V4l2Device::xu_read`]. + /// must advertise SET support. Callers preserve unknown fields with + /// read-modify-write, or construct a fully documented command payload + /// (e.g. Link 2C Host PTZ, whose readback lives on a different selector). pub fn xu_write(&self, ctrl: &XuControl, payload: &[u8]) -> Result<()> { if payload.len() != usize::from(ctrl.len) { return Err(Error::Vendor(format!( diff --git a/src/cli.rs b/src/cli.rs index db32dfc..24a32d6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -72,6 +72,9 @@ pub enum Command { /// Set pan and/or tilt absolutely in one command Move(MoveArgs), + /// Show or set Link 2C digital framing (normalized crop center, not degrees) + Frame(FrameArgs), + /// Set zoom multiplier (e.g. 1, 1.5, 2, 4); omit to read Zoom(ZoomArg), /// Set focus: `auto` or a manual value; omit to read @@ -149,6 +152,38 @@ pub struct MoveArgs { pub tilt: Option, } +#[derive(Debug, Args)] +pub struct FrameArgs { + /// Horizontal crop center, 0..1; omit to preserve + #[arg(long, allow_negative_numbers = true, value_parser = parse_coordinate)] + pub x: Option, + /// Vertical crop center, 0..1; omit to preserve + #[arg(long, allow_negative_numbers = true, value_parser = parse_coordinate)] + pub y: Option, + /// Zoom multiplier, 1..4; omit to preserve + #[arg(long, value_parser = parse_frame_zoom)] + pub zoom: Option, + /// Center the digital crop without changing zoom (unless --zoom is given) + #[arg(long, conflicts_with_all = ["x", "y"])] + pub center: bool, +} + +fn parse_coordinate(value: &str) -> std::result::Result { + let parsed = value + .parse::() + .map_err(|_| "expected a number between 0 and 1".to_string())?; + crate::camera::insta360::link2c::coordinate(parsed, "coordinate").map_err(|e| e.to_string())?; + Ok(parsed) +} + +fn parse_frame_zoom(value: &str) -> std::result::Result { + let parsed = value + .parse::() + .map_err(|_| "expected a zoom between 1 and 4".to_string())?; + crate::camera::insta360::link2c::zoom_value(parsed).map_err(|e| e.to_string())?; + Ok(parsed) +} + #[derive(Debug, Args)] pub struct ZoomArg { /// Zoom multiplier (1.0 = no zoom) @@ -318,6 +353,42 @@ mod tests { assert!(parse(&["--json", "--quiet", "status"]).is_err()); } + #[test] + fn digital_frame_arguments() { + let c = parse(&["frame"]).unwrap(); + assert!(matches!( + c.command, + Command::Frame(FrameArgs { + x: None, + y: None, + zoom: None, + center: false, + }) + )); + let c = parse(&[ + "frame", "--x", "0.25", "--y", "0.75", "--zoom", "2", "--json", + ]) + .unwrap(); + assert!(c.json); + assert!(matches!(c.command, Command::Frame(FrameArgs { + x: Some(x), y: Some(y), zoom: Some(zoom), center: false, + }) if x == 0.25 && y == 0.75 && zoom == 2.0)); + assert!(parse(&["frame", "--center", "--zoom", "2"]).is_ok()); + assert!(parse(&["frame", "--center", "--x", "0.5"]).is_err()); + assert!(parse(&["frame", "--center", "--y", "0.5"]).is_err()); + for axis in ["--x", "--y"] { + for value in ["NaN", "inf", "-0.1", "1.1", "bad"] { + assert!(parse(&["frame", axis, value]).is_err()); + } + for value in ["0", "1"] { + assert!(parse(&["frame", axis, value]).is_ok()); + } + } + for value in ["NaN", "inf", "0", "4.1", "bad"] { + assert!(parse(&["frame", "--zoom", value]).is_err()); + } + } + #[test] fn relative_moves() { let c = parse(&["left"]).unwrap(); diff --git a/src/commands/frame.rs b/src/commands/frame.rs new file mode 100644 index 0000000..3069dae --- /dev/null +++ b/src/commands/frame.rs @@ -0,0 +1,78 @@ +//! Digital framing is deliberately separate from degree-based gimbal controls. + +use serde::Serialize; + +use super::Context; +use crate::camera::insta360::link2c::{self, Frame}; +use crate::cli::FrameArgs; +use crate::error::Result; + +#[derive(Serialize)] +struct Coordinates { + x: f64, + y: f64, + zoom: f64, +} + +impl From for Coordinates { + fn from(frame: Frame) -> Self { + Self { + x: frame.normalized_x(), + y: frame.normalized_y(), + zoom: frame.zoom_factor(), + } + } +} + +#[derive(Serialize)] +struct FrameJson { + #[serde(flatten)] + current: Coordinates, + #[serde(skip_serializing_if = "Option::is_none")] + requested: Option, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option<&'static str>, +} + +pub fn run(ctx: &Context, args: &FrameArgs) -> Result<()> { + let x = if args.center { Some(0.5) } else { args.x }; + let y = if args.center { Some(0.5) } else { args.y }; + let (current, requested, warning) = if x.is_none() && y.is_none() && args.zoom.is_none() { + (link2c::read(&ctx.open_camera()?)?, None, None) + } else { + let (cam, target) = ctx.open_validated(|cam| { + let current = link2c::read(cam)?; + let target = current.updated(x, y, args.zoom)?; + cam.check_zoom_factor(target.zoom_factor())?; + Ok(target) + })?; + link2c::write(&cam, target)?; + // Report observed coordinates, never present the request as confirmation. + let current = link2c::read(&cam)?; + let warning = if (current.x, current.y, current.zoom) != (target.x, target.y, target.zoom) { + Some("The camera has not confirmed the requested frame yet. It may still be moving, clamping the crop, or applying Auto Framing; check the live preview and run 'linkctl frame' again.") + } else { + None + }; + (current, Some(Coordinates::from(target)), warning) + }; + let json = FrameJson { + current: current.into(), + requested, + warning, + }; + ctx.out.emit( + || { + let mut text = format!( + "Frame: x {:.4}, y {:.4}, zoom {:.2}x", + json.current.x, json.current.y, json.current.zoom + ); + if let Some(warning) = warning { + text.push_str(&format!("\n\n{warning}")); + } + text + }, + &json, + ); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1a27b6d..d6eb4fd 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ mod devices; mod format; +mod frame; mod image; mod info; mod motion; @@ -128,6 +129,7 @@ pub fn run(cli: Cli) -> Result<()> { Command::Tilt(a) => motion::tilt(&ctx, a.degrees), Command::Move(a) => motion::move_to(&ctx, a.pan, a.tilt), Command::Zoom(a) => motion::zoom(&ctx, a.factor), + Command::Frame(a) => frame::run(&ctx, &a), Command::Focus(a) => image::focus(&ctx, a.value), Command::Wb(a) => image::white_balance(&ctx, a.value), diff --git a/src/error.rs b/src/error.rs index b5f2833..d416941 100644 --- a/src/error.rs +++ b/src/error.rs @@ -118,6 +118,9 @@ impl Error { pub fn hint(&self) -> Option { match self { Error::CameraInactive => Some("Start a preview with:\n linkctl preview".into()), + Error::UnsupportedControl("pan_absolute" | "tilt_absolute") => Some( + "Link 2C has no motorized gimbal. Use 'linkctl frame --x 0.5 --y 0.5' for digital framing.".into(), + ), Error::PermissionDenied(_) => { Some("Check your device permissions or group membership.".into()) } diff --git a/tests/hardware.rs b/tests/hardware.rs index d75e64e..8d33687 100644 --- a/tests/hardware.rs +++ b/tests/hardware.rs @@ -3,7 +3,7 @@ //! These are never run by default: //! //! ```bash -//! # read-only checks (safe while the camera is inactive) +//! # read-only checks (no writes; Link 2C frame readback needs a valid video mode) //! cargo test --features hardware-tests -- --ignored readonly //! //! # movement checks: start `linkctl preview` first, then @@ -85,6 +85,7 @@ fn readonly_reads_do_not_require_activity() { if is_link2c() { let (code, _, err) = linkctl(&[cmd]); assert_eq!(code, 7, "{err}"); + assert!(err.contains("linkctl frame"), "{err}"); } else { assert_ok(&[cmd]); } @@ -100,6 +101,22 @@ fn readonly_out_of_range_is_rejected_before_guard() { assert_eq!(code, 11, "{err}"); } +/// Requires a Link 2C with normal/tracking/Auto Framing state; sends no writes. +#[test] +#[ignore] +fn readonly_link2c_frame() { + if !is_link2c() { + return; + } + let out = assert_ok(&["frame", "--json"]); + let frame: serde_json::Value = serde_json::from_str(&out).unwrap(); + for axis in ["x", "y"] { + assert!((0.0..=1.0).contains(&frame[axis].as_f64().unwrap())); + } + assert!((1.0..=4.0).contains(&frame["zoom"].as_f64().unwrap())); + assert!(frame.get("requested").is_none()); +} + /// Requires an active camera (e.g. `linkctl preview` in another terminal). /// Skipped on the Link 2C (PID 4c03), which has no motorized gimbal. #[test]