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}