Skip to main content

libobsbot_core/devices/
meet2.rs

1// SPDX-License-Identifier: GPL-3.0-only
2//! OBSBOT Meet 2 command table.
3//!
4//! Every constant in this module MUST be justified by a committed pcap under
5//! `doc/protocol/meet2/`, or - for the camera-level identifiers below - by
6//! the committed `descriptors.txt` dump of `lsusb -v -d 3564:fefb`. See
7//! `CONTRIBUTING.md` for the sourcing rule.
8
9/// OBSBOT USB vendor id (Remo Tech Co., Ltd.).
10pub(crate) const VENDOR_ID: u16 = 0x3564;
11
12/// OBSBOT Meet 2 USB product id.
13pub(crate) const PRODUCT_ID_MEET2: u16 = 0xfefb;
14
15/// Extension Unit entity id for the OBSBOT vendor XU on the Meet 2.
16///
17/// The Meet 2's XU has GUID `{9a1e7291-6843-4683-6d92-39bc7906ee49}` and
18/// exposes 7 controls. The GUID itself is documented in
19/// `doc/protocol/meet2/README.md` and `descriptors.txt`; we identify the
20/// device by `(vendor_id, product_id)` and address the XU by entity id.
21pub(crate) const XU_ENTITY_ID: u8 = 2;
22
23/// XU "mode register" selector: 60-byte SET with the layout
24/// `[control_id, 0x01, value, 0x00 × 57]`. Each known control id maps
25/// to one OBSBOT proprietary on/off-or-enum control.
26///
27/// Refs: `doc/protocol/meet2/setWdr.pcapng`,
28///       `doc/protocol/meet2/setWdr.md`.
29pub(crate) const XU_SEL_MODE_REGISTER: u8 = 0x06;
30
31/// `XU_SEL_MODE_REGISTER` control id for media mode. Value byte matches
32/// the SDK's `Device::MediaMode` enum (0=Normal, 1=Background, 2=AutoFrame).
33/// `setMediaMode.pcapng` frame 58 (value=2).
34pub(crate) const MODE_MEDIA_MODE: u8 = 0x00;
35
36/// `XU_SEL_MODE_REGISTER` control id for WDR / HDR. Value byte: `1` =
37/// `DOL2-to-1` HDR on, `0` = HDR off. `setWdr.pcapng` frames 70 + 82.
38pub(crate) const MODE_WDR: u8 = 0x01;
39
40/// `XU_SEL_MODE_REGISTER` control id for face-based auto-exposure.
41/// Value byte: `0` = off, `1` = on. `setFaceAE.pcapng` frame 52.
42pub(crate) const MODE_FACE_AE: u8 = 0x03;
43
44/// `XU_SEL_MODE_REGISTER` control id for FOV preset. Value byte matches
45/// the SDK's `Device::FovType` enum (0=86°/Wide, 1=78°/Medium,
46/// 2=65°/Narrow). `setFov.pcapng` frame 52 (value=1).
47pub(crate) const MODE_FOV: u8 = 0x04;
48
49/// `XU_SEL_MODE_REGISTER` control id for AI master mode. Value is a
50/// u16 LE matching the SDK's `Device::AiWorkModeType` enum (0=None,
51/// 1=Group, 2=Human, 3=Hand, 4=WhiteBoard, 5=Desk).
52/// `setAiMode.pcapng` frames 56 (value=2) + 64 (value=0).
53pub(crate) const MODE_AI_MODE: u8 = 0x16;
54
55/// `XU_SEL_MODE_REGISTER` control id for the microphone Automatic
56/// Gain Control. Value byte: `0` = off, `1` = on. Recovered from
57/// `libdev.so::cameraSetAudioAGC` (catch-all branch calls
58/// `uvcExtSet(selector=0x06, [0x17, 0x01, value, 0×57])`).
59pub(crate) const MODE_AUDIO_AGC: u8 = 0x17;
60
61/// `XU_SEL_MODE_REGISTER` control id for portrait-mode orientation.
62/// Value byte: `0` = landscape (default), `1` = portrait (90°
63/// rotation). Recovered from `libdev.so::cameraSetVerticalModeU`
64/// (always calls `uvcExtSet(0x06, [0x0c, 0x01, value, ...])` regardless
65/// of `productType`).
66pub(crate) const MODE_VERTICAL: u8 = 0x0c;
67
68/// `XU_SEL_MODE_REGISTER` control id for horizontal image flip
69/// (left/right mirror). Value byte: `0` = off, `1` = on. Recovered
70/// from `libdev.so::cameraSetImageFlipHorizonU`.
71pub(crate) const MODE_FLIP_HORIZONTAL: u8 = 0x14;
72
73/// `XU_SEL_MODE_REGISTER` control id for the camera's front-facing
74/// status LED. Value byte: `0` = off, `1` = on. Recovered from
75/// `libdev.so::cameraSetLedCtrlU`.
76pub(crate) const MODE_LED: u8 = 0x18;
77
78/// `XU_SEL_MODE_REGISTER` control id for the virtual-background mode.
79/// Value byte from the SDK's `MediaBgMode` enum (0 = disabled,
80/// 1 = green-screen colour, 17 = replace with image, 18 = blur).
81/// Recovered from `libdev.so::cameraSetBgModeU`.
82pub(crate) const MODE_BG_MODE: u8 = 0x05;
83
84/// `XU_SEL_MODE_REGISTER` control id for the virtual-background blur
85/// mask intensity. Value byte: 0-100 (percent). Recovered from
86/// `libdev.so::cameraSetMaskLevelU`.
87pub(crate) const MODE_MASK_LEVEL: u8 = 0x06;
88
89/// `XU_SEL_MODE_REGISTER` control id for the virtual-background colour
90/// chroma-key selection. Value byte from the SDK's
91/// `MediaBgModeColorType` enum (-2 = disable, -1 = null, 0 = blue,
92/// 1 = green, 2 = red, 3 = black, 4 = white). Recovered from
93/// `libdev.so::cameraSetBgColorU`.
94pub(crate) const MODE_BG_COLOR: u8 = 0x10;
95
96/// `XU_SEL_MODE_REGISTER` control id for the virtual-background
97/// master enable. Value byte: `0` = off, `1` = on. Recovered from
98/// `libdev.so::cameraSetBgEnableU`.
99pub(crate) const MODE_BG_ENABLE: u8 = 0x11;
100
101/// `XU_SEL_MODE_REGISTER` control id for whether the camera is
102/// allowed to suspend itself when no host application is streaming.
103/// Value byte: `0` = allow auto-sleep (default), `1` = disable it.
104/// Recovered from `libdev.so::cameraSetDisableSleepWithoutStreamU`.
105pub(crate) const MODE_DISABLE_SLEEP_WITHOUT_STREAM: u8 = 0x12;
106
107/// `XU_SEL_MODE_REGISTER` control id for the auto-suspend timer.
108/// Value: u16 LE, minutes (`0` keeps the camera awake indefinitely).
109/// Recovered from `libdev.so::cameraSetSuspendTimeU`.
110pub(crate) const MODE_SUSPEND_TIME: u8 = 0x0b;
111
112/// `XU_SEL_MODE_REGISTER` control id for whether the microphone stays
113/// hot while the camera is asleep. Value byte: `0` = mic off in sleep
114/// (default), `1` = mic on in sleep. Recovered from
115/// `libdev.so::cameraSetMicrophoneDuringSleepU`.
116pub(crate) const MODE_MIC_DURING_SLEEP: u8 = 0x13;
117
118/// `XU_SEL_MODE_REGISTER` control id for the physical-button behaviour.
119/// Value byte from the SDK's `ButtonMode` set (vendor-defined; not all
120/// modes apply to every Meet 2 firmware). Recovered from
121/// `libdev.so::cameraSetButtonModeU`.
122pub(crate) const MODE_BUTTON_MODE: u8 = 0x07;
123
124/// `XU_SEL_MODE_REGISTER` control id for the auto-framing sub-mode.
125/// Value is two u8 bytes `[group_single, close_upper]` matching the
126/// SDK's `Device::AutoFramingType` enum
127/// (`group_single`: 0=Group / 1=Single; `close_upper`: 0=CloseUp /
128/// 1=UpperBody, ignored when Group). `setAutoFraming*.pcapng`.
129pub(crate) const MODE_AUTO_FRAMING: u8 = 0x0d;
130
131/// Payload length for every `XU_SEL_MODE_REGISTER` SET observed so far.
132pub(crate) const MODE_REGISTER_PAYLOAD_LEN: usize = 60;
133
134/// First byte of the 60-byte status blob the camera returns on a
135/// `GET_CUR` of `XU_SEL_MODE_REGISTER`. Hard-coded by firmware 4.4.6.1;
136/// re-verify on firmware updates.
137pub(crate) const STATUS_BLOB_MARKER: u8 = 0x27;
138
139/// Offset in the status blob of the WDR byte (`0` off / `1` on).
140pub(crate) const STATUS_WDR_OFFSET: usize = 6;
141
142/// Offset in the status blob of the face-AE byte (`0` off / `1` on).
143pub(crate) const STATUS_FACE_AE_OFFSET: usize = 7;
144
145/// Offset in the status blob of the AI master-mode byte. Matches the
146/// `AiMode` enum (0=None, 1=Group, 2=Human, 3=Hand, 4=WhiteBoard,
147/// 5=Desk). Setting `MediaMode::AutoFrame` or any `AutoFramingMode`
148/// updates this same byte to the equivalent AI mode value.
149pub(crate) const STATUS_AI_MODE_OFFSET: usize = 0x18;
150
151/// Build a payload for `XU_SEL_MODE_REGISTER` from
152/// `(control_id, value_bytes)`. Layout:
153/// `[control_id, value_bytes.len() as u8, value_bytes..., 0x00 padding]`.
154/// `value_bytes` must fit in the 58 remaining bytes; the WDR / FOV /
155/// faceAE / mediaMode controls use 1 byte, AI mode uses 2.
156pub(crate) fn mode_register_payload(
157    control_id: u8,
158    value_bytes: &[u8],
159) -> [u8; MODE_REGISTER_PAYLOAD_LEN] {
160    let mut buf = [0u8; MODE_REGISTER_PAYLOAD_LEN];
161    buf[0] = control_id;
162    buf[1] = u8::try_from(value_bytes.len()).expect("mode-register value must fit in 255 bytes");
163    buf[2..2 + value_bytes.len()].copy_from_slice(value_bytes);
164    buf
165}
166
167/// Minimum firmware version this build supports.
168/// Updated once the first hardware verification run lands.
169pub(crate) const MIN_FW: &str = "0.0.0";
170
171/// XU "RPC channel" selector. 60-byte SET/GET pairs carry an
172/// OBSBOT-proprietary command framing; see
173/// `doc/protocol/meet2/getStatus.md`.
174pub(crate) const XU_SEL_RPC: u8 = 0x02;
175
176/// Length of every `XU_SEL_RPC` SET request and GET reply.
177pub(crate) const RPC_FRAME_LEN: usize = 60;
178
179/// How many times to poll the `XU_SEL_RPC` GET buffer before giving up
180/// on a request, since the camera processes our SET asynchronously.
181pub(crate) const RPC_REPLY_POLL_ATTEMPTS: u32 = 10;
182
183/// Delay between `XU_SEL_RPC` GET polls in milliseconds.
184pub(crate) const RPC_REPLY_POLL_DELAY_MS: u64 = 20;
185
186/// Offset of the `cmd_id` byte inside an `XU_SEL_RPC` frame.
187const RPC_CMD_ID_OFFSET: usize = 10;
188/// Offset of the `sub_cmd_id` byte.
189const RPC_SUB_CMD_ID_OFFSET: usize = 11;
190/// Offset of the little-endian u16 payload length.
191const RPC_LEN_OFFSET: usize = 12;
192/// Offset where the variable-length payload starts.
193const RPC_PAYLOAD_OFFSET: usize = 16;
194
195/// `XU_SEL_RPC` reply tuple `(cmd_id, sub_cmd_id)` that returns the
196/// 4-byte firmware version. `getStatus.pcapng` frame 33.
197const RPC_GET_FIRMWARE: (u8, u8) = (0x08, 0x04);
198
199/// `XU_SEL_RPC` reply tuple that returns the device serial as up to
200/// 14 ASCII bytes (NUL-padded). `getStatus.pcapng` frame 49.
201const RPC_GET_SERIAL: (u8, u8) = (0xC8, 0x18);
202
203/// MAC tail of the originally captured Meet 2. Used only by unit
204/// tests (real opens learn the MAC from the camera via
205/// [`build_mac_query_request`]).
206#[cfg(test)]
207pub(crate) const CAPTURED_MAC: [u8; 6] = [0xad, 0xb6, 0x1b, 0x98, 0xdc, 0x8d];
208
209/// Pinned test fixture: the captured firmware-request frame.
210/// Regenerated on demand by [`build_rpc_frame`] with the captured MAC.
211/// Kept as a `const` so unit tests can verify byte-for-byte that the
212/// builder still reproduces what `getStatus.pcapng` frame 28 showed.
213#[cfg(test)]
214pub(crate) const RPC_REQUEST_FIRMWARE: [u8; RPC_FRAME_LEN] = [
215    0xaa, 0x01, 0x01, 0x00, 0x0c, 0x00, 0xc1, 0x50, 0x0a, 0x0d, 0x08, 0x04, 0x00, 0x00, 0x00, 0x00,
216    0x00, 0x00, 0xad, 0xb6, 0x1b, 0x98, 0xdc, 0x8d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
217    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
218    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
219];
220
221/// Pinned test fixture: the captured serial-request frame.
222#[cfg(test)]
223pub(crate) const RPC_REQUEST_SERIAL: [u8; RPC_FRAME_LEN] = [
224    0xaa, 0x01, 0x03, 0x00, 0x0c, 0x00, 0x31, 0x53, 0x0a, 0x0d, 0xc8, 0x18, 0x00, 0x00, 0x00, 0x00,
225    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xad, 0xb6, 0x1b, 0x98, 0xdc, 0x8d, 0x01, 0x01,
226    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
227    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
228];
229
230/// Pinned test fixture: the captured face-focus-on frame.
231#[cfg(test)]
232pub(crate) const RPC_REQUEST_FACE_FOCUS_ON: [u8; RPC_FRAME_LEN] = [
233    0xaa, 0x25, 0x04, 0x00, 0x0c, 0x00, 0xd8, 0xc6, 0x0a, 0x02, 0x02, 0x36, 0x04, 0x00, 0xbf, 0xfb,
234    0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xad, 0xb6, 0x1b, 0x98, 0xdc, 0x8d,
235    0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
236    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
237];
238
239/// Pinned test fixture: the captured face-focus-off frame.
240#[cfg(test)]
241pub(crate) const RPC_REQUEST_FACE_FOCUS_OFF: [u8; RPC_FRAME_LEN] = [
242    0xaa, 0x25, 0x04, 0x00, 0x0c, 0x00, 0xd8, 0xc6, 0x0a, 0x02, 0x02, 0x36, 0x04, 0x00, 0xbe, 0x07,
243    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xad, 0xb6, 0x1b, 0x98, 0xdc, 0x8d,
244    0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
245    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
246];
247
248/// Decode an `XU_SEL_RPC` GET reply that's expected to carry the
249/// firmware version.
250pub(crate) fn decode_firmware_reply(buf: &[u8]) -> Option<String> {
251    let (cmd_id, sub_cmd_id, payload) = parse_rpc_reply(buf)?;
252    if (cmd_id, sub_cmd_id) != RPC_GET_FIRMWARE || payload.len() < 4 {
253        return None;
254    }
255    Some(format!(
256        "{}.{}.{}.{}",
257        payload[3], payload[2], payload[1], payload[0]
258    ))
259}
260
261/// Decode an `XU_SEL_RPC` GET reply that's expected to carry the
262/// device serial.
263pub(crate) fn decode_serial_reply(buf: &[u8]) -> Option<String> {
264    let (cmd_id, sub_cmd_id, payload) = parse_rpc_reply(buf)?;
265    if (cmd_id, sub_cmd_id) != RPC_GET_SERIAL {
266        return None;
267    }
268    let end = payload
269        .iter()
270        .position(|&b| b == 0)
271        .unwrap_or(payload.len());
272    let bytes = &payload[..end];
273    if bytes.iter().any(|&b| !b.is_ascii_graphic()) {
274        return None;
275    }
276    Some(core::str::from_utf8(bytes).ok()?.to_owned())
277}
278
279/// CRC-16/USB (poly `0x8005`, init `0xFFFF`, refin/refout=true,
280/// xorout=`0xFFFF`). This is the algorithm `libdev.so::calc_crc16`
281/// implements via its `crc16_low_tab` / `crc16_high_tab` lookup
282/// tables; see `doc/protocol/meet2/crc-investigation.md` for the
283/// disassembly trace that nailed it.
284pub(crate) fn crc16_usb(data: &[u8]) -> u16 {
285    let mut crc: u16 = 0xFFFF;
286    for &b in data {
287        crc ^= u16::from(b);
288        for _ in 0..8 {
289            if crc & 1 != 0 {
290                crc = (crc >> 1) ^ 0xA001;
291            } else {
292                crc >>= 1;
293            }
294        }
295    }
296    !crc
297}
298
299/// Build an `XU_SEL_RPC` request frame from its logical pieces, computing
300/// the outer CRC (and the inner one when bit 5 or 6 of `seq_byte` is
301/// set) the same way `libdev.so::frmHeaderProcessForSendV3` does.
302///
303/// Outer header layout (12 bytes covered by the outer CRC at `[6,7]`):
304///
305/// ```text
306/// offset 0:     0xAA               magic
307/// offset 1:     `seq_byte`         (libdev forces the low 10 bits to
308///                                  0x1AA; we accept the full byte from
309///                                  the original capture)
310/// offset 2:     `sub_seq`          increments per (request, reply) pair
311/// offset 3:     0x00               reserved
312/// offset 4-5:   `outer_len`        u16 LE; libdev always writes 12
313/// offset 6-7:   outer CRC          filled in by this helper
314/// offset 8:     0x0A               request direction marker
315/// offset 9:     `cmd_set`          varies per command family
316/// offset 10:    `cmd_id`
317/// offset 11:    `sub_cmd_id`
318/// ```
319///
320/// Inner section (covered by the inner CRC at `[14,15]` when
321/// `seq_byte & 0x60 != 0`):
322///
323/// ```text
324/// offset 12-13: `payload.len()` u16 LE
325/// offset 14-15: inner CRC (zeroed during CRC computation)
326/// offset 16..:  payload bytes
327/// ```
328///
329/// Everything past the inner section is left at whatever the caller
330/// supplies in `tail`. That's where the device-specific MAC and any
331/// per-command sentinel bytes live; the camera doesn't validate them
332/// via CRC, but it does seem to require the right MAC at the right
333/// offset for some commands.
334#[allow(clippy::too_many_arguments)]
335pub(crate) fn build_rpc_frame(
336    seq_byte: u8,
337    sub_seq: u8,
338    cmd_set: u8,
339    cmd_id: u8,
340    sub_cmd_id: u8,
341    payload: &[u8],
342    tail_offset: usize,
343    tail: &[u8],
344) -> [u8; RPC_FRAME_LEN] {
345    assert!(payload.len() + 16 <= RPC_FRAME_LEN, "payload too long");
346    assert!(tail_offset >= 16 + payload.len(), "tail overlaps payload");
347    assert!(tail_offset + tail.len() <= RPC_FRAME_LEN, "tail past frame");
348    let mut buf = [0u8; RPC_FRAME_LEN];
349    buf[0] = 0xAA;
350    buf[1] = seq_byte;
351    buf[2] = sub_seq;
352    let outer_len: u16 = 12;
353    buf[4..6].copy_from_slice(&outer_len.to_le_bytes());
354    buf[8] = 0x0A;
355    buf[9] = cmd_set;
356    buf[10] = cmd_id;
357    buf[11] = sub_cmd_id;
358    let inner_len = u16::try_from(payload.len()).expect("payload fits in u16");
359    buf[12..14].copy_from_slice(&inner_len.to_le_bytes());
360    buf[16..16 + payload.len()].copy_from_slice(payload);
361    buf[tail_offset..tail_offset + tail.len()].copy_from_slice(tail);
362
363    let outer = crc16_usb(&buf[..usize::from(outer_len)]);
364    buf[6..8].copy_from_slice(&outer.to_le_bytes());
365
366    if seq_byte & 0x60 != 0 {
367        let inner_end = 16 + payload.len();
368        let inner = crc16_usb(&buf[12..inner_end]);
369        buf[14..16].copy_from_slice(&inner.to_le_bytes());
370    }
371
372    buf
373}
374
375/// Build the `(cmd_id, sub_cmd_id) = (0x08, 0x18)` handshake request
376/// that asks the camera for its 24-byte device hash. The MAC tail of
377/// that hash is what other RPC commands need to embed; this request
378/// itself takes no MAC, so it's the one bootstrap frame we can build
379/// on a freshly-opened camera with no prior state.
380pub(crate) fn build_mac_query_request() -> [u8; RPC_FRAME_LEN] {
381    build_rpc_frame(0x01, 0x00, 0x0D, 0x08, 0x18, &[], 16, &[])
382}
383
384/// `(cmd_id, sub_cmd_id)` of the device-hash reply. The reply payload
385/// is 24 bytes; bytes 18-23 of the payload are the MAC tail.
386const RPC_GET_DEVICE_HASH: (u8, u8) = (0x08, 0x18);
387
388/// Pull the MAC tail out of an `XU_SEL_RPC` GET reply. Returns `None`
389/// unless the reply matches `RPC_GET_DEVICE_HASH` and is long enough.
390pub(crate) fn decode_mac_query_reply(buf: &[u8]) -> Option<[u8; 6]> {
391    let (cmd_id, sub_cmd_id, payload) = parse_rpc_reply(buf)?;
392    if (cmd_id, sub_cmd_id) != RPC_GET_DEVICE_HASH || payload.len() < 24 {
393        return None;
394    }
395    payload[18..24].try_into().ok()
396}
397
398/// Pull (`cmd_id`, `sub_cmd_id`, payload) out of an `XU_SEL_RPC` GET
399/// reply buffer. Returns `None` if the magic or direction marker
400/// doesn't match a reply.
401fn parse_rpc_reply(buf: &[u8]) -> Option<(u8, u8, &[u8])> {
402    if buf.len() < RPC_PAYLOAD_OFFSET || buf[0] != 0xAA {
403        return None;
404    }
405    // Reply direction marker: 0x0D, 0x0A. Requests have these flipped.
406    if buf[8] != 0x0D || buf[9] != 0x0A {
407        return None;
408    }
409    let cmd_id = buf[RPC_CMD_ID_OFFSET];
410    let sub_cmd_id = buf[RPC_SUB_CMD_ID_OFFSET];
411    let len = u16::from_le_bytes([buf[RPC_LEN_OFFSET], buf[RPC_LEN_OFFSET + 1]]) as usize;
412    let end = RPC_PAYLOAD_OFFSET.checked_add(len)?;
413    if end > buf.len() {
414        return None;
415    }
416    Some((cmd_id, sub_cmd_id, &buf[RPC_PAYLOAD_OFFSET..end]))
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn mode_register_payload_layout_matches_wire_one_byte() {
425        // setWdr.pcapng frame 70 (HDR on): 01 01 01 00 …
426        let on = mode_register_payload(MODE_WDR, &[1]);
427        assert_eq!(on[..3], [0x01, 0x01, 0x01]);
428        assert!(on[3..].iter().all(|&b| b == 0));
429        assert_eq!(on.len(), 60);
430
431        // setWdr.pcapng frame 82 (HDR off): 01 01 00 00 …
432        let off = mode_register_payload(MODE_WDR, &[0]);
433        assert_eq!(off[..3], [0x01, 0x01, 0x00]);
434        assert!(off[3..].iter().all(|&b| b == 0));
435    }
436
437    #[test]
438    fn crc16_usb_matches_known_vectors() {
439        // CRC-16/USB("123456789") = 0xB4C8 per the CRC catalog.
440        assert_eq!(crc16_usb(b"123456789"), 0xB4C8);
441        // Zero-byte input: CRC starts at 0xFFFF; final XOR (NOT) is 0x0000.
442        assert_eq!(crc16_usb(b""), 0x0000);
443    }
444
445    #[test]
446    fn build_rpc_frame_reproduces_canned_firmware_request() {
447        // RPC_REQUEST_FIRMWARE: seq=0x01 sub=0x01 cmd_set=0x0D
448        // cmd=0x08 sub_cmd=0x04 no payload, MAC at [18..24].
449        let mac = [0xad, 0xb6, 0x1b, 0x98, 0xdc, 0x8d];
450        let built = build_rpc_frame(0x01, 0x01, 0x0D, 0x08, 0x04, &[], 18, &mac);
451        assert_eq!(built, RPC_REQUEST_FIRMWARE);
452    }
453
454    #[test]
455    fn build_rpc_frame_reproduces_canned_serial_request() {
456        // RPC_REQUEST_SERIAL: seq=0x01 sub=0x03 cmd_set=0x0D
457        // cmd=0xC8 sub_cmd=0x18 no payload, MAC at [24..30] + sentinel
458        // `01 01` at [30..32].
459        let mut tail = [0u8; 8];
460        tail[..6].copy_from_slice(&[0xad, 0xb6, 0x1b, 0x98, 0xdc, 0x8d]);
461        tail[6] = 0x01;
462        tail[7] = 0x01;
463        let built = build_rpc_frame(0x01, 0x03, 0x0D, 0xC8, 0x18, &[], 24, &tail);
464        assert_eq!(built, RPC_REQUEST_SERIAL);
465    }
466
467    #[test]
468    fn build_rpc_frame_reproduces_canned_face_focus_on() {
469        // RPC_REQUEST_FACE_FOCUS_ON: seq=0x25 (triggers inner CRC since
470        // 0x25 & 0x60 = 0x20), sub=0x04, cmd_set=0x02 cmd=0x02 sub_cmd=0x36
471        // payload=[01,00,00,00], MAC at [26..32], sentinel `01 01` at [32,33].
472        let payload = [0x01, 0x00, 0x00, 0x00];
473        let mut tail = [0u8; 8];
474        tail[..6].copy_from_slice(&[0xad, 0xb6, 0x1b, 0x98, 0xdc, 0x8d]);
475        tail[6] = 0x01;
476        tail[7] = 0x01;
477        let built = build_rpc_frame(0x25, 0x04, 0x02, 0x02, 0x36, &payload, 26, &tail);
478        assert_eq!(built, RPC_REQUEST_FACE_FOCUS_ON);
479    }
480
481    #[test]
482    fn build_rpc_frame_reproduces_canned_face_focus_off() {
483        let payload = [0x00, 0x00, 0x00, 0x00];
484        let mut tail = [0u8; 8];
485        tail[..6].copy_from_slice(&[0xad, 0xb6, 0x1b, 0x98, 0xdc, 0x8d]);
486        tail[6] = 0x01;
487        tail[7] = 0x01;
488        let built = build_rpc_frame(0x25, 0x04, 0x02, 0x02, 0x36, &payload, 26, &tail);
489        assert_eq!(built, RPC_REQUEST_FACE_FOCUS_OFF);
490    }
491
492    #[test]
493    fn mode_register_payload_layout_matches_wire_two_byte() {
494        // setAiMode.pcapng frame 56 (AI mode Human=2): 16 02 02 00 …
495        let on = mode_register_payload(MODE_AI_MODE, &2u16.to_le_bytes());
496        assert_eq!(on[..4], [0x16, 0x02, 0x02, 0x00]);
497        assert!(on[4..].iter().all(|&b| b == 0));
498
499        // setAiMode.pcapng frame 64 (AI mode None=0): 16 02 00 00 …
500        let off = mode_register_payload(MODE_AI_MODE, &0u16.to_le_bytes());
501        assert_eq!(off[..4], [0x16, 0x02, 0x00, 0x00]);
502        assert!(off[4..].iter().all(|&b| b == 0));
503    }
504}