Skip to main content

libobsbot_core/
device.rs

1// SPDX-License-Identifier: GPL-3.0-only
2//! Opened camera handle and the v1 method surface.
3//!
4//! Each method routes through the crate-internal `Transport` trait. The
5//! transport returns [`Error::Unsupported`] until real
6//! `control_in`/`control_out` calls land; the method signatures and
7//! entity/selector routing are stable.
8
9use core::ops::RangeInclusive;
10use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
11use std::sync::Arc;
12use std::thread::JoinHandle;
13use std::time::Duration;
14
15use crate::devices::meet2;
16use crate::discovery::DeviceInfo;
17use crate::status::{Event, EventSender};
18use crate::transport::Transport;
19use crate::types::{
20    AeMode, AiMode, AntiFlicker, AutoFramingMode, Cadence, FirmwareVersion, FovType, MediaBgColor,
21    MediaBgMode, MediaMode, ProductType, Status, WdrMode, WhiteBalanceMode,
22};
23use crate::uvc::{self, UvcGet};
24use crate::{Error, Result};
25
26/// Opened OBSBOT camera.
27///
28/// Obtain via [`crate::Devices::open`]. Dropping the handle stops the
29/// per-device status poller (joining its thread) and releases the
30/// transport's `/dev/videoN` handle.
31pub struct Device {
32    info: DeviceInfo,
33    transport: Arc<dyn Transport>,
34    firmware: String,
35    /// MAC tail used in the per-command sentinel bytes of XU RPC frames.
36    /// Seeded with the originally captured value at construction; once
37    /// [`Device::status`] runs successfully against a real camera, the
38    /// reply's device-hash field would let us learn the actual MAC -
39    /// not wired up yet.
40    mac: [u8; 6],
41    /// Poller period in milliseconds. Shared with the poller thread so
42    /// [`Device::set_status_cadence`] can swap Slow/Fast on the fly.
43    cadence_ms: Arc<AtomicU32>,
44    /// Set by [`Device::drop`] to stop the poller.
45    poller_stop: Arc<AtomicBool>,
46    poller: Option<JoinHandle<()>>,
47}
48
49impl Device {
50    pub(crate) fn new(
51        info: DeviceInfo,
52        transport: Arc<dyn Transport>,
53        events_tx: Option<EventSender>,
54        mac: [u8; 6],
55    ) -> Self {
56        let cadence_ms = Arc::new(AtomicU32::new(Cadence::Slow.period_ms()));
57        let poller_stop = Arc::new(AtomicBool::new(false));
58        let poller = events_tx.map(|tx| {
59            spawn_status_poller(
60                tx,
61                info.serial.clone(),
62                transport.clone(),
63                cadence_ms.clone(),
64                poller_stop.clone(),
65            )
66        });
67        Self {
68            info,
69            transport,
70            firmware: meet2::MIN_FW.to_owned(),
71            mac,
72            cadence_ms,
73            poller_stop,
74            poller,
75        }
76    }
77
78    /// Change how often the status poller samples the camera.
79    pub fn set_status_cadence(&self, cadence: Cadence) {
80        self.cadence_ms
81            .store(cadence.period_ms(), Ordering::Relaxed);
82    }
83
84    /// Human-readable model name.
85    #[must_use]
86    pub fn name(&self) -> &str {
87        match self.info.product_type {
88            ProductType::Meet2 => "OBSBOT Meet 2",
89        }
90    }
91
92    /// Device serial number as reported by the OS at enumeration time.
93    #[must_use]
94    pub fn serial(&self) -> &str {
95        &self.info.serial
96    }
97
98    /// Firmware version string. Returns the build-time minimum until a real
99    /// camera response can be parsed.
100    #[must_use]
101    pub fn firmware_version(&self) -> &str {
102        &self.firmware
103    }
104
105    /// Model enum value.
106    #[must_use]
107    pub fn product_type(&self) -> ProductType {
108        self.info.product_type
109    }
110
111    /// Read a fresh status snapshot synchronously.
112    ///
113    /// The current-state fields (HDR / face-AE / AI mode / focus
114    /// bits) come from a selector-0x02 RPC reply whose request frame
115    /// hasn't been captured yet, so they remain `Default::default()`
116    /// for now. Firmware / serial fields come from
117    /// [`firmware_from_camera`](Self::firmware_from_camera) /
118    /// [`serial_from_camera`](Self::serial_from_camera) and are
119    /// filled in best-effort - they're left blank on read failure
120    /// rather than failing the whole status call.
121    pub fn status(&self) -> Result<Status> {
122        let firmware = self.firmware_from_camera().unwrap_or_default();
123        let serial = self.serial_from_camera().unwrap_or_default();
124        let mut snap = sample_status(self.transport.as_ref());
125        snap.firmware = firmware;
126        snap.serial = serial;
127        Ok(snap)
128    }
129
130    /// Ask the camera for its firmware version via the XU RPC channel.
131    /// Synthesises the request frame at runtime via
132    /// `meet2::build_rpc_frame` using the CRC-16/USB algorithm
133    /// recovered from `libdev.so`. Returns a dotted-decimal string
134    /// like `"4.4.6.1"`.
135    pub fn firmware_from_camera(&self) -> Result<String> {
136        let mut tail = [0u8; 6];
137        tail.copy_from_slice(&self.mac);
138        let request = meet2::build_rpc_frame(0x01, 0x01, 0x0D, 0x08, 0x04, &[], 18, &tail);
139        self.rpc_request_then_reply(&request, meet2::decode_firmware_reply)
140    }
141
142    /// Parsed firmware version, freshly read from the camera. Convenience
143    /// over [`firmware_from_camera`](Self::firmware_from_camera) for
144    /// callers that want to do version comparisons. Returns
145    /// [`Error::BadResponse`] if the string isn't four dotted decimals.
146    pub fn firmware(&self) -> Result<FirmwareVersion> {
147        let s = self.firmware_from_camera()?;
148        FirmwareVersion::parse(&s).ok_or_else(|| Error::BadResponse {
149            selector: meet2::XU_SEL_RPC,
150            bytes: s.into_bytes(),
151        })
152    }
153
154    /// Ask the camera for its serial number via the XU RPC channel.
155    pub fn serial_from_camera(&self) -> Result<String> {
156        let mut tail = [0u8; 8];
157        tail[..6].copy_from_slice(&self.mac);
158        tail[6] = 0x01;
159        tail[7] = 0x01;
160        let request = meet2::build_rpc_frame(0x01, 0x03, 0x0D, 0xC8, 0x18, &[], 24, &tail);
161        self.rpc_request_then_reply(&request, meet2::decode_serial_reply)
162    }
163
164    /// SET a canned `XU_SEL_RPC` request, then poll GET until `decode`
165    /// returns a value. The camera processes SETs asynchronously, so
166    /// the first GET right after a SET typically returns the previous
167    /// session's reply.
168    fn rpc_request_then_reply(
169        &self,
170        request: &[u8; meet2::RPC_FRAME_LEN],
171        decode: impl Fn(&[u8]) -> Option<String>,
172    ) -> Result<String> {
173        self.transport
174            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_RPC, request)?;
175        let mut reply = [0u8; meet2::RPC_FRAME_LEN];
176        for attempt in 0..meet2::RPC_REPLY_POLL_ATTEMPTS {
177            // Tight loop, then back off; the camera typically catches up in
178            // a few milliseconds.
179            if attempt > 0 {
180                std::thread::sleep(std::time::Duration::from_millis(
181                    meet2::RPC_REPLY_POLL_DELAY_MS,
182                ));
183            }
184            let _ = self.transport.uvc_get(
185                UvcGet::Cur,
186                meet2::XU_ENTITY_ID,
187                meet2::XU_SEL_RPC,
188                &mut reply,
189            )?;
190            if let Some(decoded) = decode(&reply) {
191                return Ok(decoded);
192            }
193        }
194        Err(Error::BadResponse {
195            selector: meet2::XU_SEL_RPC,
196            bytes: reply.to_vec(),
197        })
198    }
199
200    // ---- Camera Terminal (standard UVC §A.9.4) ------------------------------
201
202    /// Set pan and tilt in normalised camera coordinates (-1.0 ..= 1.0).
203    ///
204    /// Encodes as `CT_PANTILT_ABSOLUTE_CONTROL`: i32 LE pan + i32 LE tilt in
205    /// arc-seconds (UVC 1.5 §4.2.2.1.14). The normalised-to-arc-second scale
206    /// is provisional until the camera's `GET_MIN`/`GET_MAX` are queried at
207    /// open time.
208    #[allow(clippy::cast_possible_truncation)]
209    pub fn set_pan_tilt(&self, pan: f32, tilt: f32) -> Result<()> {
210        if !(-1.0..=1.0).contains(&pan) || !(-1.0..=1.0).contains(&tilt) {
211            return Err(Error::OutOfRange);
212        }
213        let pan_i = (pan * PAN_TILT_PROVISIONAL_SCALE) as i32;
214        let tilt_i = (tilt * PAN_TILT_PROVISIONAL_SCALE) as i32;
215        let mut payload = [0u8; 8];
216        payload[..4].copy_from_slice(&pan_i.to_le_bytes());
217        payload[4..].copy_from_slice(&tilt_i.to_le_bytes());
218        self.transport
219            .uvc_set(uvc::CAMERA_TERMINAL, uvc::ct::PANTILT_ABSOLUTE, &payload)
220    }
221
222    /// Set zoom as a ratio of the camera's optical range (1.0 ..= max).
223    ///
224    /// Encodes as `CT_ZOOM_ABSOLUTE_CONTROL`: u16 LE objective focal length
225    /// (UVC 1.5 §4.2.2.1.10). Mapping is provisional until `GET_MIN`/`GET_MAX`
226    /// are queried.
227    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
228    pub fn set_zoom(&self, zoom: f32) -> Result<()> {
229        if !(0.0..=f32::from(u16::MAX)).contains(&zoom) {
230            return Err(Error::OutOfRange);
231        }
232        let v = zoom as u16;
233        self.transport.uvc_set(
234            uvc::CAMERA_TERMINAL,
235            uvc::ct::ZOOM_ABSOLUTE,
236            &v.to_le_bytes(),
237        )
238    }
239
240    /// Set focus distance.
241    ///
242    /// Encodes as `CT_FOCUS_ABSOLUTE_CONTROL`: u16 LE (UVC 1.5 §4.2.2.1.6).
243    /// Mapping is provisional until `GET_MIN`/`GET_MAX` are queried.
244    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
245    pub fn set_focus(&self, focus: f32) -> Result<()> {
246        if !(0.0..=f32::from(u16::MAX)).contains(&focus) {
247            return Err(Error::OutOfRange);
248        }
249        let v = focus as u16;
250        self.transport.uvc_set(
251            uvc::CAMERA_TERMINAL,
252            uvc::ct::FOCUS_ABSOLUTE,
253            &v.to_le_bytes(),
254        )
255    }
256
257    /// Read current pan + tilt in normalised camera coordinates
258    /// (-1.0 ..= 1.0). Inverse of [`set_pan_tilt`](Self::set_pan_tilt);
259    /// uses the same provisional arc-second scale.
260    pub fn pan_tilt(&self) -> Result<(f32, f32)> {
261        let mut buf = [0u8; 8];
262        let _ = self.transport.uvc_get(
263            UvcGet::Cur,
264            uvc::CAMERA_TERMINAL,
265            uvc::ct::PANTILT_ABSOLUTE,
266            &mut buf,
267        )?;
268        let pan = i32::from_le_bytes(buf[..4].try_into().unwrap());
269        let tilt = i32::from_le_bytes(buf[4..].try_into().unwrap());
270        Ok((normalised_pantilt(pan), normalised_pantilt(tilt)))
271    }
272
273    /// Read current zoom value. Returns the raw u16 as f32 until
274    /// `GET_MIN`/`GET_MAX` are wired up to give a meaningful ratio.
275    pub fn zoom(&self) -> Result<f32> {
276        let mut buf = [0u8; 2];
277        let _ = self.transport.uvc_get(
278            UvcGet::Cur,
279            uvc::CAMERA_TERMINAL,
280            uvc::ct::ZOOM_ABSOLUTE,
281            &mut buf,
282        )?;
283        Ok(f32::from(u16::from_le_bytes(buf)))
284    }
285
286    /// Read current focus value. Same provisional u16-as-f32 mapping
287    /// as [`set_focus`](Self::set_focus).
288    pub fn focus(&self) -> Result<f32> {
289        let mut buf = [0u8; 2];
290        let _ = self.transport.uvc_get(
291            UvcGet::Cur,
292            uvc::CAMERA_TERMINAL,
293            uvc::ct::FOCUS_ABSOLUTE,
294            &mut buf,
295        )?;
296        Ok(f32::from(u16::from_le_bytes(buf)))
297    }
298
299    /// Enable or disable autofocus. `CT_FOCUS_AUTO_CONTROL`, u8.
300    pub fn set_auto_focus(&self, on: bool) -> Result<()> {
301        self.transport
302            .uvc_set(uvc::CAMERA_TERMINAL, uvc::ct::FOCUS_AUTO, &[u8::from(on)])
303    }
304
305    /// Whether autofocus is currently enabled.
306    pub fn auto_focus(&self) -> Result<bool> {
307        let mut buf = [0u8; 1];
308        let _ = self.transport.uvc_get(
309            UvcGet::Cur,
310            uvc::CAMERA_TERMINAL,
311            uvc::ct::FOCUS_AUTO,
312            &mut buf,
313        )?;
314        Ok(buf[0] != 0)
315    }
316
317    /// Set the auto-exposure mode. `CT_AE_MODE_CONTROL`, u8 bitmap
318    /// (UVC 1.5 §4.2.2.1.2). The Meet 2 supports Manual + Auto;
319    /// Shutter Priority and Aperture Priority are accepted by the
320    /// enum for SDK parity and silently no-op on cameras that don't
321    /// support them.
322    pub fn set_ae_mode(&self, mode: AeMode) -> Result<()> {
323        let byte: u8 = match mode {
324            AeMode::Manual => 0x01,
325            AeMode::Auto => 0x02,
326            AeMode::ShutterPriority => 0x04,
327            AeMode::AperturePriority => 0x08,
328        };
329        self.transport
330            .uvc_set(uvc::CAMERA_TERMINAL, uvc::ct::AE_MODE, &[byte])
331    }
332
333    /// Read the current auto-exposure mode.
334    pub fn ae_mode(&self) -> Result<AeMode> {
335        let mut buf = [0u8; 1];
336        let _ = self.transport.uvc_get(
337            UvcGet::Cur,
338            uvc::CAMERA_TERMINAL,
339            uvc::ct::AE_MODE,
340            &mut buf,
341        )?;
342        // The GET reply has exactly one bit set per UVC §4.2.2.1.2.
343        match buf[0] {
344            0x01 => Ok(AeMode::Manual),
345            0x02 => Ok(AeMode::Auto),
346            0x04 => Ok(AeMode::ShutterPriority),
347            0x08 => Ok(AeMode::AperturePriority),
348            other => Err(Error::BadResponse {
349                selector: uvc::ct::AE_MODE,
350                bytes: vec![other],
351            }),
352        }
353    }
354
355    /// Lock or unlock exposure + gain. Convenience over
356    /// [`set_ae_mode`](Self::set_ae_mode): `true` selects
357    /// [`AeMode::Manual`] (both locked), `false` selects
358    /// [`AeMode::Auto`]. Matches the SDK's `cameraSetAELockR(bool)`.
359    pub fn set_ae_lock(&self, locked: bool) -> Result<()> {
360        self.set_ae_mode(if locked { AeMode::Manual } else { AeMode::Auto })
361    }
362
363    /// Whether exposure is currently locked.
364    pub fn ae_lock(&self) -> Result<bool> {
365        Ok(matches!(self.ae_mode()?, AeMode::Manual))
366    }
367
368    /// Set manual exposure time. `CT_EXPOSURE_TIME_ABSOLUTE_CONTROL`,
369    /// u32 LE in 100 us units (UVC 1.5 §4.2.2.1.4). Setting this when
370    /// the AE mode allows auto exposure (Auto or Aperture Priority)
371    /// has no effect; pair with [`set_ae_lock(true)`](Self::set_ae_lock)
372    /// for full manual control.
373    pub fn set_exposure_time(&self, value_100us: u32) -> Result<()> {
374        self.transport.uvc_set(
375            uvc::CAMERA_TERMINAL,
376            uvc::ct::EXPOSURE_TIME_ABSOLUTE,
377            &value_100us.to_le_bytes(),
378        )
379    }
380
381    /// Read the current exposure time, in 100 us units.
382    pub fn exposure_time(&self) -> Result<u32> {
383        let mut buf = [0u8; 4];
384        let _ = self.transport.uvc_get(
385            UvcGet::Cur,
386            uvc::CAMERA_TERMINAL,
387            uvc::ct::EXPOSURE_TIME_ABSOLUTE,
388            &mut buf,
389        )?;
390        Ok(u32::from_le_bytes(buf))
391    }
392
393    /// Reported exposure-time range, in 100 us units.
394    pub fn exposure_time_range(&self) -> Result<RangeInclusive<u32>> {
395        let mut buf = [0u8; 4];
396        let _ = self.transport.uvc_get(
397            UvcGet::Min,
398            uvc::CAMERA_TERMINAL,
399            uvc::ct::EXPOSURE_TIME_ABSOLUTE,
400            &mut buf,
401        )?;
402        let lo = u32::from_le_bytes(buf);
403        let _ = self.transport.uvc_get(
404            UvcGet::Max,
405            uvc::CAMERA_TERMINAL,
406            uvc::ct::EXPOSURE_TIME_ABSOLUTE,
407            &mut buf,
408        )?;
409        let hi = u32::from_le_bytes(buf);
410        Ok(lo..=hi)
411    }
412
413    // ---- Processing Unit (standard UVC §A.9.5) ------------------------------
414
415    /// Set brightness. `PU_BRIGHTNESS_CONTROL`, i16 LE (UVC 1.5 §4.2.2.3.2).
416    pub fn set_brightness(&self, value: i32) -> Result<()> {
417        let v = i16::try_from(value).map_err(|_| Error::OutOfRange)?;
418        self.transport
419            .uvc_set(uvc::PROCESSING_UNIT, uvc::pu::BRIGHTNESS, &v.to_le_bytes())
420    }
421
422    /// Read current brightness.
423    pub fn brightness(&self) -> Result<i32> {
424        let mut buf = [0u8; 2];
425        let _ = self.transport.uvc_get(
426            UvcGet::Cur,
427            uvc::PROCESSING_UNIT,
428            uvc::pu::BRIGHTNESS,
429            &mut buf,
430        )?;
431        Ok(i32::from(i16::from_le_bytes(buf)))
432    }
433
434    /// Reported brightness range.
435    pub fn brightness_range(&self) -> Result<RangeInclusive<i32>> {
436        let lo = self.pu_get_i16(UvcGet::Min, uvc::pu::BRIGHTNESS)?;
437        let hi = self.pu_get_i16(UvcGet::Max, uvc::pu::BRIGHTNESS)?;
438        Ok(i32::from(lo)..=i32::from(hi))
439    }
440
441    /// Set contrast. `PU_CONTRAST_CONTROL`, u16 LE (UVC 1.5 §4.2.2.3.3).
442    pub fn set_contrast(&self, value: i32) -> Result<()> {
443        let v = u16::try_from(value).map_err(|_| Error::OutOfRange)?;
444        self.transport
445            .uvc_set(uvc::PROCESSING_UNIT, uvc::pu::CONTRAST, &v.to_le_bytes())
446    }
447
448    /// Read current contrast.
449    pub fn contrast(&self) -> Result<i32> {
450        Ok(i32::from(self.pu_get_u16(UvcGet::Cur, uvc::pu::CONTRAST)?))
451    }
452
453    /// Reported contrast range.
454    pub fn contrast_range(&self) -> Result<RangeInclusive<i32>> {
455        let lo = self.pu_get_u16(UvcGet::Min, uvc::pu::CONTRAST)?;
456        let hi = self.pu_get_u16(UvcGet::Max, uvc::pu::CONTRAST)?;
457        Ok(i32::from(lo)..=i32::from(hi))
458    }
459
460    /// Set saturation. `PU_SATURATION_CONTROL`, u16 LE (UVC 1.5 §4.2.2.3.7).
461    pub fn set_saturation(&self, value: i32) -> Result<()> {
462        let v = u16::try_from(value).map_err(|_| Error::OutOfRange)?;
463        self.transport
464            .uvc_set(uvc::PROCESSING_UNIT, uvc::pu::SATURATION, &v.to_le_bytes())
465    }
466
467    /// Read current saturation.
468    pub fn saturation(&self) -> Result<i32> {
469        Ok(i32::from(
470            self.pu_get_u16(UvcGet::Cur, uvc::pu::SATURATION)?,
471        ))
472    }
473
474    /// Reported saturation range.
475    pub fn saturation_range(&self) -> Result<RangeInclusive<i32>> {
476        let lo = self.pu_get_u16(UvcGet::Min, uvc::pu::SATURATION)?;
477        let hi = self.pu_get_u16(UvcGet::Max, uvc::pu::SATURATION)?;
478        Ok(i32::from(lo)..=i32::from(hi))
479    }
480
481    /// Set image hue. `PU_HUE_CONTROL`, i16 LE (UVC 1.5 §4.2.2.3.4).
482    pub fn set_hue(&self, value: i32) -> Result<()> {
483        let v = i16::try_from(value).map_err(|_| Error::OutOfRange)?;
484        self.transport
485            .uvc_set(uvc::PROCESSING_UNIT, uvc::pu::HUE, &v.to_le_bytes())
486    }
487
488    /// Read current hue.
489    pub fn hue(&self) -> Result<i32> {
490        Ok(i32::from(self.pu_get_i16(UvcGet::Cur, uvc::pu::HUE)?))
491    }
492
493    /// Reported hue range.
494    pub fn hue_range(&self) -> Result<RangeInclusive<i32>> {
495        let lo = self.pu_get_i16(UvcGet::Min, uvc::pu::HUE)?;
496        let hi = self.pu_get_i16(UvcGet::Max, uvc::pu::HUE)?;
497        Ok(i32::from(lo)..=i32::from(hi))
498    }
499
500    /// Set image sharpness. `PU_SHARPNESS_CONTROL`, u16 LE.
501    pub fn set_sharpness(&self, value: i32) -> Result<()> {
502        let v = u16::try_from(value).map_err(|_| Error::OutOfRange)?;
503        self.transport
504            .uvc_set(uvc::PROCESSING_UNIT, uvc::pu::SHARPNESS, &v.to_le_bytes())
505    }
506
507    /// Read current sharpness.
508    pub fn sharpness(&self) -> Result<i32> {
509        Ok(i32::from(self.pu_get_u16(UvcGet::Cur, uvc::pu::SHARPNESS)?))
510    }
511
512    /// Reported sharpness range.
513    pub fn sharpness_range(&self) -> Result<RangeInclusive<i32>> {
514        let lo = self.pu_get_u16(UvcGet::Min, uvc::pu::SHARPNESS)?;
515        let hi = self.pu_get_u16(UvcGet::Max, uvc::pu::SHARPNESS)?;
516        Ok(i32::from(lo)..=i32::from(hi))
517    }
518
519    /// Set sensor gain. `PU_GAIN_CONTROL`, u16 LE.
520    pub fn set_gain(&self, value: i32) -> Result<()> {
521        let v = u16::try_from(value).map_err(|_| Error::OutOfRange)?;
522        self.transport
523            .uvc_set(uvc::PROCESSING_UNIT, uvc::pu::GAIN, &v.to_le_bytes())
524    }
525
526    /// Read current gain.
527    pub fn gain(&self) -> Result<i32> {
528        Ok(i32::from(self.pu_get_u16(UvcGet::Cur, uvc::pu::GAIN)?))
529    }
530
531    /// Reported gain range.
532    pub fn gain_range(&self) -> Result<RangeInclusive<i32>> {
533        let lo = self.pu_get_u16(UvcGet::Min, uvc::pu::GAIN)?;
534        let hi = self.pu_get_u16(UvcGet::Max, uvc::pu::GAIN)?;
535        Ok(i32::from(lo)..=i32::from(hi))
536    }
537
538    /// Set backlight-compensation. `PU_BACKLIGHT_COMPENSATION_CONTROL`,
539    /// u16 LE (UVC 1.5 §4.2.2.3.16). 0 disables it.
540    pub fn set_backlight_compensation(&self, value: i32) -> Result<()> {
541        let v = u16::try_from(value).map_err(|_| Error::OutOfRange)?;
542        self.transport.uvc_set(
543            uvc::PROCESSING_UNIT,
544            uvc::pu::BACKLIGHT_COMPENSATION,
545            &v.to_le_bytes(),
546        )
547    }
548
549    /// Read current backlight-compensation value.
550    pub fn backlight_compensation(&self) -> Result<i32> {
551        Ok(i32::from(self.pu_get_u16(
552            UvcGet::Cur,
553            uvc::pu::BACKLIGHT_COMPENSATION,
554        )?))
555    }
556
557    /// Set anti-flicker (mains-frequency rejection).
558    /// `PU_POWER_LINE_FREQUENCY_CONTROL`, u8 (UVC 1.5 §4.2.2.3.6).
559    pub fn set_anti_flicker(&self, mode: AntiFlicker) -> Result<()> {
560        let v: u8 = match mode {
561            AntiFlicker::Off => 0,
562            AntiFlicker::Hz50 => 1,
563            AntiFlicker::Hz60 => 2,
564            AntiFlicker::Auto => 3,
565        };
566        self.transport
567            .uvc_set(uvc::PROCESSING_UNIT, uvc::pu::POWER_LINE_FREQUENCY, &[v])
568    }
569
570    /// Read current anti-flicker mode.
571    pub fn anti_flicker(&self) -> Result<AntiFlicker> {
572        let mut buf = [0u8; 1];
573        let _ = self.transport.uvc_get(
574            UvcGet::Cur,
575            uvc::PROCESSING_UNIT,
576            uvc::pu::POWER_LINE_FREQUENCY,
577            &mut buf,
578        )?;
579        match buf[0] {
580            0 => Ok(AntiFlicker::Off),
581            1 => Ok(AntiFlicker::Hz50),
582            2 => Ok(AntiFlicker::Hz60),
583            3 => Ok(AntiFlicker::Auto),
584            other => Err(Error::BadResponse {
585                selector: uvc::pu::POWER_LINE_FREQUENCY,
586                bytes: vec![other],
587            }),
588        }
589    }
590
591    // ---- White balance: hybrid (PU temperature, XU presets) -----------------
592
593    /// Set white balance mode; the `kelvin` value is meaningful only when
594    /// `mode` is [`WhiteBalanceMode::Manual`]. The Meet 2 only supports
595    /// Auto and Manual (per the SDK header) so this routes entirely
596    /// through standard UVC Processing Unit selectors `0x0a` / `0x0b`.
597    pub fn set_white_balance(&self, mode: WhiteBalanceMode, kelvin: Option<u16>) -> Result<()> {
598        match mode {
599            WhiteBalanceMode::Auto => self.transport.uvc_set(
600                uvc::PROCESSING_UNIT,
601                uvc::pu::WHITE_BALANCE_TEMPERATURE_AUTO,
602                &[1],
603            ),
604            WhiteBalanceMode::Manual => {
605                self.transport.uvc_set(
606                    uvc::PROCESSING_UNIT,
607                    uvc::pu::WHITE_BALANCE_TEMPERATURE_AUTO,
608                    &[0],
609                )?;
610                let k = kelvin.unwrap_or(6500);
611                self.transport.uvc_set(
612                    uvc::PROCESSING_UNIT,
613                    uvc::pu::WHITE_BALANCE_TEMPERATURE,
614                    &k.to_le_bytes(),
615                )
616            }
617        }
618    }
619
620    /// Read current white-balance mode and Kelvin value.
621    pub fn white_balance(&self) -> Result<(WhiteBalanceMode, u16)> {
622        let mut auto_buf = [0u8; 1];
623        let _ = self.transport.uvc_get(
624            UvcGet::Cur,
625            uvc::PROCESSING_UNIT,
626            uvc::pu::WHITE_BALANCE_TEMPERATURE_AUTO,
627            &mut auto_buf,
628        )?;
629        let kelvin = self.pu_get_u16(UvcGet::Cur, uvc::pu::WHITE_BALANCE_TEMPERATURE)?;
630        let mode = if auto_buf[0] == 0 {
631            WhiteBalanceMode::Manual
632        } else {
633            WhiteBalanceMode::Auto
634        };
635        Ok((mode, kelvin))
636    }
637
638    /// Reported manual Kelvin range.
639    pub fn white_balance_range(&self) -> Result<RangeInclusive<u16>> {
640        let lo = self.pu_get_u16(UvcGet::Min, uvc::pu::WHITE_BALANCE_TEMPERATURE)?;
641        let hi = self.pu_get_u16(UvcGet::Max, uvc::pu::WHITE_BALANCE_TEMPERATURE)?;
642        Ok(lo..=hi)
643    }
644
645    // ---- OBSBOT vendor extension (entity 2) ---------------------------------
646    //
647    // Every method below routes through the XU. Selector and payload layout
648    // are pending per-method captures under doc/protocol/meet2/.
649
650    /// Set HDR mode. Writes to the OBSBOT XU mode-register selector
651    /// `0x06` with the WDR control id; see `doc/protocol/meet2/setWdr.md`
652    /// for the wire format.
653    pub fn set_wdr(&self, mode: WdrMode) -> Result<()> {
654        let payload = meet2::mode_register_payload(meet2::MODE_WDR, &[encode_wdr(mode)]);
655        self.transport
656            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
657    }
658
659    /// Read current HDR mode from the XU status blob (offset 6).
660    pub fn wdr(&self) -> Result<WdrMode> {
661        let blob = self.read_status_blob()?;
662        decode_wdr(blob[meet2::STATUS_WDR_OFFSET])
663    }
664
665    /// Whether face-based auto-exposure is on (XU status blob offset 7).
666    pub fn face_ae(&self) -> Result<bool> {
667        let blob = self.read_status_blob()?;
668        Ok(blob[meet2::STATUS_FACE_AE_OFFSET] != 0)
669    }
670
671    /// Current AI master mode (XU status blob offset 24).
672    ///
673    /// The Meet 2 collapses AI mode, auto-framing, and media-mode into
674    /// a single "AI work mode" enum at runtime - setting auto-framing
675    /// or `MediaMode::AutoFrame` changes this byte just as setting an
676    /// AI mode does. The value at offset 24 always reflects the most
677    /// recently selected mode, so `ai_mode()`, `auto_framing()`, and
678    /// `media_mode()` all read it but interpret it differently.
679    pub fn ai_mode(&self) -> Result<AiMode> {
680        let blob = self.read_status_blob()?;
681        decode_ai_mode(u16::from(blob[meet2::STATUS_AI_MODE_OFFSET]))
682    }
683
684    /// Read the 60-byte XU status blob the camera publishes via
685    /// `GET_CUR` on the mode-register selector. The blob starts with a
686    /// fixed marker byte (`0x27` on Meet 2 firmware 4.4.6.1); offsets
687    /// of individual fields are tracked under
688    /// `meet2::STATUS_*_OFFSET`.
689    fn read_status_blob(&self) -> Result<[u8; meet2::MODE_REGISTER_PAYLOAD_LEN]> {
690        let mut buf = [0u8; meet2::MODE_REGISTER_PAYLOAD_LEN];
691        let _ = self.transport.uvc_get(
692            UvcGet::Cur,
693            meet2::XU_ENTITY_ID,
694            meet2::XU_SEL_MODE_REGISTER,
695            &mut buf,
696        )?;
697        if buf[0] != meet2::STATUS_BLOB_MARKER {
698            return Err(Error::BadResponse {
699                selector: meet2::XU_SEL_MODE_REGISTER,
700                bytes: buf.to_vec(),
701            });
702        }
703        Ok(buf)
704    }
705
706    /// Set field-of-view preset. XU mode-register control id
707    /// [`meet2::MODE_FOV`](crate::devices) - see
708    /// `doc/protocol/meet2/setFov.md`.
709    pub fn set_fov(&self, fov: FovType) -> Result<()> {
710        let payload = meet2::mode_register_payload(meet2::MODE_FOV, &[encode_fov(fov)]);
711        self.transport
712            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
713    }
714
715    /// Toggle face-based auto-exposure. XU mode-register control id
716    /// `0x03` - see `doc/protocol/meet2/setFaceAE.md`.
717    pub fn set_face_ae(&self, on: bool) -> Result<()> {
718        let payload = meet2::mode_register_payload(meet2::MODE_FACE_AE, &[u8::from(on)]);
719        self.transport
720            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
721    }
722
723    /// Toggle face-based auto-focus.
724    ///
725    /// Face-focus rides the XU selector-0x02 RPC channel
726    /// (`cmd_set` 0x02, `cmd_id` 0x36). The frame is synthesised at
727    /// runtime via `meet2::build_rpc_frame`; the CRCs at `[6,7]` and
728    /// `[14,15]` are computed by the recovered CRC-16/USB routine.
729    pub fn set_face_focus(&self, on: bool) -> Result<()> {
730        let payload = [u8::from(on), 0x00, 0x00, 0x00];
731        let mut tail = [0u8; 8];
732        tail[..6].copy_from_slice(&self.mac);
733        tail[6] = 0x01;
734        tail[7] = 0x01;
735        let request = meet2::build_rpc_frame(0x25, 0x04, 0x02, 0x02, 0x36, &payload, 26, &tail);
736        self.transport
737            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_RPC, &request)
738    }
739
740    /// Select media mode. XU mode-register control id `0x00` - see
741    /// `doc/protocol/meet2/setMediaMode.md`.
742    pub fn set_media_mode(&self, mode: MediaMode) -> Result<()> {
743        let payload =
744            meet2::mode_register_payload(meet2::MODE_MEDIA_MODE, &[encode_media_mode(mode)]);
745        self.transport
746            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
747    }
748
749    /// Set the auto-framing sub-mode. XU mode-register control id
750    /// `0x0d` with a 2-byte value `[group_single, close_upper]`; see
751    /// `doc/protocol/meet2/setAutoFraming.md`.
752    pub fn set_auto_framing(&self, mode: AutoFramingMode) -> Result<()> {
753        let payload =
754            meet2::mode_register_payload(meet2::MODE_AUTO_FRAMING, &encode_auto_framing(mode));
755        self.transport
756            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
757    }
758
759    /// Set the AI master mode. XU mode-register control id `0x16`
760    /// with a u16 LE value; see `doc/protocol/meet2/setAiMode.md`.
761    pub fn set_ai_mode(&self, mode: AiMode) -> Result<()> {
762        let value: u16 = encode_ai_mode(mode);
763        let payload = meet2::mode_register_payload(meet2::MODE_AI_MODE, &value.to_le_bytes());
764        self.transport
765            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
766    }
767
768    /// Toggle the microphone Automatic Gain Control. XU mode-register
769    /// control id `0x17`. The Meet 2's standard USB Audio Class
770    /// Feature Unit only exposes Mute + Volume; AGC is an
771    /// OBSBOT-proprietary control routed through the video XU rather
772    /// than the audio surface.
773    pub fn set_audio_agc(&self, on: bool) -> Result<()> {
774        let payload = meet2::mode_register_payload(meet2::MODE_AUDIO_AGC, &[u8::from(on)]);
775        self.transport
776            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
777    }
778
779    /// Flip the image horizontally (left/right mirror). XU
780    /// mode-register control id `0x14`.
781    pub fn set_flip_horizontal(&self, on: bool) -> Result<()> {
782        let payload = meet2::mode_register_payload(meet2::MODE_FLIP_HORIZONTAL, &[u8::from(on)]);
783        self.transport
784            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
785    }
786
787    /// Switch between landscape (`false`) and portrait (`true`)
788    /// orientation. XU mode-register control id `0x0c`. The SDK calls
789    /// this "vertical mode"; in portrait mode the camera rotates the
790    /// pixel stream 90° so streaming apps see a tall frame natively.
791    pub fn set_portrait(&self, on: bool) -> Result<()> {
792        let payload = meet2::mode_register_payload(meet2::MODE_VERTICAL, &[u8::from(on)]);
793        self.transport
794            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
795    }
796
797    /// Turn the camera's front-facing status LED on or off. XU
798    /// mode-register control id `0x18`.
799    pub fn set_led(&self, on: bool) -> Result<()> {
800        let payload = meet2::mode_register_payload(meet2::MODE_LED, &[u8::from(on)]);
801        self.transport
802            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
803    }
804
805    /// Master enable for the virtual-background system. Disabling
806    /// this returns the camera to a passthrough live image regardless
807    /// of the current [`MediaBgMode`].
808    pub fn set_bg_enable(&self, on: bool) -> Result<()> {
809        let payload = meet2::mode_register_payload(meet2::MODE_BG_ENABLE, &[u8::from(on)]);
810        self.transport
811            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
812    }
813
814    /// Set the virtual-background mode (colour key / replace / blur).
815    /// Setting this to anything other than [`MediaBgMode::Disable`]
816    /// also requires [`set_bg_enable`](Self::set_bg_enable) to be on.
817    pub fn set_bg_mode(&self, mode: MediaBgMode) -> Result<()> {
818        let byte: u8 = match mode {
819            MediaBgMode::Disable => 0,
820            MediaBgMode::Color => 1,
821            MediaBgMode::Replace => 17,
822            MediaBgMode::Blur => 18,
823        };
824        let payload = meet2::mode_register_payload(meet2::MODE_BG_MODE, &[byte]);
825        self.transport
826            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
827    }
828
829    /// Pick which colour the camera removes when
830    /// [`MediaBgMode::Color`] is active. The SDK enum has two
831    /// negative sentinels (`Disable = -2`, `Null = -1`) and five
832    /// positive colour values; the wire byte is the SDK value cast
833    /// to `i8`.
834    #[allow(clippy::cast_sign_loss)]
835    pub fn set_bg_color(&self, color: MediaBgColor) -> Result<()> {
836        let byte: i8 = match color {
837            MediaBgColor::Disable => -2,
838            MediaBgColor::Null => -1,
839            MediaBgColor::Blue => 0,
840            MediaBgColor::Green => 1,
841            MediaBgColor::Red => 2,
842            MediaBgColor::Black => 3,
843            MediaBgColor::White => 4,
844        };
845        let payload = meet2::mode_register_payload(meet2::MODE_BG_COLOR, &[byte as u8]);
846        self.transport
847            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
848    }
849
850    /// Set the background-blur intensity, 0 (no blur) to 100 (max).
851    pub fn set_mask_level(&self, level: u8) -> Result<()> {
852        if level > 100 {
853            return Err(Error::OutOfRange);
854        }
855        let payload = meet2::mode_register_payload(meet2::MODE_MASK_LEVEL, &[level]);
856        self.transport
857            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
858    }
859
860    /// Control whether the camera is allowed to auto-suspend when no
861    /// host application is streaming from it. `true` keeps it awake.
862    pub fn set_disable_sleep_without_stream(&self, disable: bool) -> Result<()> {
863        let payload = meet2::mode_register_payload(
864            meet2::MODE_DISABLE_SLEEP_WITHOUT_STREAM,
865            &[u8::from(disable)],
866        );
867        self.transport
868            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
869    }
870
871    /// Set the auto-suspend timer, in minutes. `0` keeps the camera
872    /// awake indefinitely (subject to host-side power management).
873    pub fn set_suspend_time(&self, minutes: u16) -> Result<()> {
874        let payload =
875            meet2::mode_register_payload(meet2::MODE_SUSPEND_TIME, &minutes.to_le_bytes());
876        self.transport
877            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
878    }
879
880    /// Whether the microphone stays hot while the camera is asleep.
881    /// Maps to the SDK's `cameraSetMicrophoneDuringSleepU`.
882    pub fn set_microphone_during_sleep(&self, on: bool) -> Result<()> {
883        let payload = meet2::mode_register_payload(meet2::MODE_MIC_DURING_SLEEP, &[u8::from(on)]);
884        self.transport
885            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
886    }
887
888    /// Set the physical-button behaviour. Values are firmware-defined;
889    /// per `CameraStatus.meet.key_mode` in the SDK header. The raw byte
890    /// is sent as-is so callers can pick the encoding the device
891    /// firmware uses.
892    pub fn set_button_mode(&self, mode: u8) -> Result<()> {
893        let payload = meet2::mode_register_payload(meet2::MODE_BUTTON_MODE, &[mode]);
894        self.transport
895            .uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_MODE_REGISTER, &payload)
896    }
897
898    // ---- private helpers ----------------------------------------------------
899
900    fn pu_get_i16(&self, req: UvcGet, selector: u8) -> Result<i16> {
901        let mut buf = [0u8; 2];
902        let _ = self
903            .transport
904            .uvc_get(req, uvc::PROCESSING_UNIT, selector, &mut buf)?;
905        Ok(i16::from_le_bytes(buf))
906    }
907
908    fn pu_get_u16(&self, req: UvcGet, selector: u8) -> Result<u16> {
909        let mut buf = [0u8; 2];
910        let _ = self
911            .transport
912            .uvc_get(req, uvc::PROCESSING_UNIT, selector, &mut buf)?;
913        Ok(u16::from_le_bytes(buf))
914    }
915}
916
917// Pan/tilt arc-second scale used while the device's reported range hasn't
918// been queried yet. 540_000 arc-seconds ≈ 150°, within the Meet 2's
919// digital pan/tilt envelope. Real scale lands once GET_MIN/GET_MAX wire up.
920const PAN_TILT_PROVISIONAL_SCALE: f32 = 540_000.0;
921
922/// Wire-side arc-seconds -> normalised pan/tilt. The cast is fine for the
923/// camera's actual range (~±540k); clippy's `cast_precision_loss` is a
924/// red herring here since values outside f32's mantissa would be physical
925/// nonsense.
926#[allow(clippy::cast_precision_loss)]
927fn normalised_pantilt(arc_seconds: i32) -> f32 {
928    arc_seconds as f32 / PAN_TILT_PROVISIONAL_SCALE
929}
930
931/// Granularity for the poller's stop check between samples. The poll
932/// thread sleeps in chunks this small so [`Device::drop`] can interrupt
933/// it without waiting a full cadence period.
934const POLLER_TICK: Duration = Duration::from_millis(25);
935
936impl Drop for Device {
937    fn drop(&mut self) {
938        self.poller_stop.store(true, Ordering::Relaxed);
939        if let Some(handle) = self.poller.take() {
940            let _ = handle.join();
941        }
942    }
943}
944
945/// Spawn the per-device status poller thread. Reads brightness,
946/// contrast, and saturation each cadence tick (the controls that have
947/// stable wire formats today) and pushes a [`Event::Status`] into the
948/// shared event channel. Exits when `stop` flips or the channel closes.
949///
950/// Firmware/serial are intentionally *not* sampled here. They ride the
951/// XU RPC channel, take multiple round trips, and rarely change at
952/// runtime; call [`Device::firmware_from_camera`] /
953/// [`Device::serial_from_camera`] explicitly when you need them.
954fn spawn_status_poller(
955    tx: EventSender,
956    serial: String,
957    transport: Arc<dyn Transport>,
958    cadence_ms: Arc<AtomicU32>,
959    stop: Arc<AtomicBool>,
960) -> JoinHandle<()> {
961    std::thread::Builder::new()
962        .name(format!("libobsbot-poll-{serial}"))
963        .spawn(move || poll_loop(&tx, &serial, transport.as_ref(), &cadence_ms, &stop))
964        .expect("spawn status poller thread")
965}
966
967fn poll_loop(
968    tx: &EventSender,
969    serial: &str,
970    transport: &dyn Transport,
971    cadence_ms: &AtomicU32,
972    stop: &AtomicBool,
973) {
974    while !stop.load(Ordering::Relaxed) {
975        let snapshot = sample_status(transport);
976        if tx
977            .send(Event::Status {
978                serial: serial.to_owned(),
979                snapshot,
980            })
981            .is_err()
982        {
983            return;
984        }
985        let target = Duration::from_millis(u64::from(cadence_ms.load(Ordering::Relaxed)));
986        let mut slept = Duration::ZERO;
987        while slept < target {
988            if stop.load(Ordering::Relaxed) {
989                return;
990            }
991            std::thread::sleep(POLLER_TICK);
992            slept += POLLER_TICK;
993        }
994    }
995}
996
997/// One status sample. Reads what we have stable wire formats for; any
998/// individual read that errors leaves the corresponding field at its
999/// default rather than failing the whole sample.
1000fn sample_status(transport: &dyn Transport) -> Status {
1001    let mut snap = Status::default();
1002    if let Ok(v) = read_pu_i16(transport, uvc::pu::BRIGHTNESS) {
1003        snap.brightness = i32::from(v);
1004    }
1005    if let Ok(v) = read_pu_u16(transport, uvc::pu::CONTRAST) {
1006        snap.contrast = i32::from(v);
1007    }
1008    if let Ok(v) = read_pu_u16(transport, uvc::pu::SATURATION) {
1009        snap.saturation = i32::from(v);
1010    }
1011    let mut pt = [0u8; 8];
1012    if transport
1013        .uvc_get(
1014            UvcGet::Cur,
1015            uvc::CAMERA_TERMINAL,
1016            uvc::ct::PANTILT_ABSOLUTE,
1017            &mut pt,
1018        )
1019        .is_ok()
1020    {
1021        let pan = i32::from_le_bytes(pt[..4].try_into().unwrap());
1022        let tilt = i32::from_le_bytes(pt[4..].try_into().unwrap());
1023        snap.pan = normalised_pantilt(pan);
1024        snap.tilt = normalised_pantilt(tilt);
1025    }
1026    let mut zoom = [0u8; 2];
1027    if transport
1028        .uvc_get(
1029            UvcGet::Cur,
1030            uvc::CAMERA_TERMINAL,
1031            uvc::ct::ZOOM_ABSOLUTE,
1032            &mut zoom,
1033        )
1034        .is_ok()
1035    {
1036        snap.zoom = f32::from(u16::from_le_bytes(zoom));
1037    }
1038    snap
1039}
1040
1041fn read_pu_i16(transport: &dyn Transport, selector: u8) -> Result<i16> {
1042    let mut buf = [0u8; 2];
1043    let _ = transport.uvc_get(UvcGet::Cur, uvc::PROCESSING_UNIT, selector, &mut buf)?;
1044    Ok(i16::from_le_bytes(buf))
1045}
1046
1047fn read_pu_u16(transport: &dyn Transport, selector: u8) -> Result<u16> {
1048    let mut buf = [0u8; 2];
1049    let _ = transport.uvc_get(UvcGet::Cur, uvc::PROCESSING_UNIT, selector, &mut buf)?;
1050    Ok(u16::from_le_bytes(buf))
1051}
1052
1053/// Issue the XU RPC handshake that asks the camera for its 24-byte
1054/// device hash, parse the trailing 6 bytes (the MAC tail), and return
1055/// it. Called by [`crate::Devices::open`] before constructing the
1056/// `Device` so subsequent RPC sends can embed the correct MAC.
1057pub(crate) fn learn_mac(transport: &dyn Transport) -> Result<[u8; 6]> {
1058    let request = meet2::build_mac_query_request();
1059    transport.uvc_set(meet2::XU_ENTITY_ID, meet2::XU_SEL_RPC, &request)?;
1060    let mut reply = [0u8; meet2::RPC_FRAME_LEN];
1061    for attempt in 0..meet2::RPC_REPLY_POLL_ATTEMPTS {
1062        if attempt > 0 {
1063            std::thread::sleep(Duration::from_millis(meet2::RPC_REPLY_POLL_DELAY_MS));
1064        }
1065        let _ = transport.uvc_get(
1066            UvcGet::Cur,
1067            meet2::XU_ENTITY_ID,
1068            meet2::XU_SEL_RPC,
1069            &mut reply,
1070        )?;
1071        if let Some(mac) = meet2::decode_mac_query_reply(&reply) {
1072            return Ok(mac);
1073        }
1074    }
1075    Err(Error::BadResponse {
1076        selector: meet2::XU_SEL_RPC,
1077        bytes: reply.to_vec(),
1078    })
1079}
1080
1081// ---- payload helpers (XU encodings still placeholders) ---------------------
1082
1083fn encode_wdr(mode: WdrMode) -> u8 {
1084    match mode {
1085        WdrMode::Off => 0,
1086        WdrMode::Dol2To1 => 1,
1087    }
1088}
1089
1090fn decode_wdr(b: u8) -> Result<WdrMode> {
1091    match b {
1092        0 => Ok(WdrMode::Off),
1093        1 => Ok(WdrMode::Dol2To1),
1094        _ => Err(Error::BadResponse {
1095            selector: 0,
1096            bytes: vec![b],
1097        }),
1098    }
1099}
1100
1101fn encode_fov(fov: FovType) -> u8 {
1102    match fov {
1103        FovType::Wide => 0,
1104        FovType::Medium => 1,
1105        FovType::Narrow => 2,
1106    }
1107}
1108
1109fn encode_media_mode(mode: MediaMode) -> u8 {
1110    // Matches the SDK enum and `setMediaMode.pcapng` frame 58.
1111    match mode {
1112        MediaMode::Normal => 0,
1113        MediaMode::Background => 1,
1114        MediaMode::AutoFrame => 2,
1115    }
1116}
1117
1118/// 2-byte wire encoding `[group_single, close_upper]` for the
1119/// auto-framing sub-mode mode-register control.
1120fn encode_auto_framing(mode: AutoFramingMode) -> [u8; 2] {
1121    match mode {
1122        AutoFramingMode::Group => [0, 0],
1123        AutoFramingMode::SingleCloseUp => [1, 0],
1124        AutoFramingMode::SingleUpperBody => [1, 1],
1125    }
1126}
1127
1128/// Maps our [`AiMode`] enum to the wire value seen in
1129/// `setAiMode.pcapng`. Matches the SDK's `Device::AiWorkModeType`.
1130fn encode_ai_mode(mode: AiMode) -> u16 {
1131    match mode {
1132        AiMode::None => 0,
1133        AiMode::Group => 1,
1134        AiMode::Human => 2,
1135        AiMode::Hand => 3,
1136        AiMode::WhiteBoard => 4,
1137        AiMode::Desk => 5,
1138    }
1139}
1140
1141fn decode_ai_mode(v: u16) -> Result<AiMode> {
1142    match v {
1143        0 => Ok(AiMode::None),
1144        1 => Ok(AiMode::Group),
1145        2 => Ok(AiMode::Human),
1146        3 => Ok(AiMode::Hand),
1147        4 => Ok(AiMode::WhiteBoard),
1148        5 => Ok(AiMode::Desk),
1149        _ => Err(Error::BadResponse {
1150            selector: meet2::XU_SEL_MODE_REGISTER,
1151            bytes: v.to_le_bytes().to_vec(),
1152        }),
1153    }
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158    use super::*;
1159    use crate::testing::{device_with_mock, last_set};
1160
1161    #[test]
1162    fn brightness_range_decodes_min_then_max() {
1163        use crate::testing::device_with_scripted_get;
1164        // Min = -64 (i16 LE: c0 ff), Max = 64 (i16 LE: 40 00).
1165        let device = device_with_scripted_get(vec![vec![0xc0, 0xff], vec![0x40, 0x00]]);
1166        let range = device.brightness_range().unwrap();
1167        assert_eq!(*range.start(), -64);
1168        assert_eq!(*range.end(), 64);
1169    }
1170
1171    #[test]
1172    fn metadata_accessors_use_info() {
1173        let (d, _) = device_with_mock();
1174        assert_eq!(d.name(), "OBSBOT Meet 2");
1175        assert_eq!(d.serial(), "MOCK");
1176        assert_eq!(d.product_type(), ProductType::Meet2);
1177        assert_eq!(d.firmware_version(), meet2::MIN_FW);
1178    }
1179
1180    #[test]
1181    fn pan_tilt_rejects_out_of_range() {
1182        let (d, _) = device_with_mock();
1183        assert!(matches!(d.set_pan_tilt(2.0, 0.0), Err(Error::OutOfRange)));
1184        assert!(matches!(d.set_pan_tilt(0.0, -1.5), Err(Error::OutOfRange)));
1185    }
1186
1187    #[test]
1188    fn pan_tilt_routes_to_camera_terminal() {
1189        let (d, mock) = device_with_mock();
1190        d.set_pan_tilt(0.0, 0.0).unwrap();
1191        let (entity, selector, payload) = last_set(&mock);
1192        assert_eq!(entity, uvc::CAMERA_TERMINAL);
1193        assert_eq!(selector, uvc::ct::PANTILT_ABSOLUTE);
1194        assert_eq!(payload.len(), 8);
1195    }
1196
1197    #[test]
1198    fn brightness_routes_to_processing_unit_with_i16_payload() {
1199        let (d, mock) = device_with_mock();
1200        d.set_brightness(42).unwrap();
1201        let (entity, selector, payload) = last_set(&mock);
1202        assert_eq!(entity, uvc::PROCESSING_UNIT);
1203        assert_eq!(selector, uvc::pu::BRIGHTNESS);
1204        assert_eq!(payload, vec![42, 0]);
1205    }
1206
1207    #[test]
1208    fn brightness_out_of_i16_range_is_refused() {
1209        let (d, _) = device_with_mock();
1210        assert!(matches!(
1211            d.set_brightness(i32::from(i16::MAX) + 1),
1212            Err(Error::OutOfRange)
1213        ));
1214    }
1215
1216    #[test]
1217    fn hue_sharpness_gain_backlight_route_to_pu_with_correct_selectors_and_widths() {
1218        let (d, mock) = device_with_mock();
1219
1220        d.set_hue(-30).unwrap();
1221        let (entity, sel, payload) = last_set(&mock);
1222        assert_eq!(entity, uvc::PROCESSING_UNIT);
1223        assert_eq!(sel, uvc::pu::HUE);
1224        assert_eq!(payload, (-30_i16).to_le_bytes().to_vec());
1225
1226        d.set_sharpness(7).unwrap();
1227        let (_, sel, payload) = last_set(&mock);
1228        assert_eq!(sel, uvc::pu::SHARPNESS);
1229        assert_eq!(payload, 7_u16.to_le_bytes().to_vec());
1230
1231        d.set_gain(42).unwrap();
1232        let (_, sel, payload) = last_set(&mock);
1233        assert_eq!(sel, uvc::pu::GAIN);
1234        assert_eq!(payload, 42_u16.to_le_bytes().to_vec());
1235
1236        d.set_backlight_compensation(1).unwrap();
1237        let (_, sel, payload) = last_set(&mock);
1238        assert_eq!(sel, uvc::pu::BACKLIGHT_COMPENSATION);
1239        assert_eq!(payload, 1_u16.to_le_bytes().to_vec());
1240    }
1241
1242    #[test]
1243    fn auto_focus_routes_to_ct_focus_auto_with_bool_byte() {
1244        let (d, mock) = device_with_mock();
1245        d.set_auto_focus(true).unwrap();
1246        let (entity, sel, payload) = last_set(&mock);
1247        assert_eq!(entity, uvc::CAMERA_TERMINAL);
1248        assert_eq!(sel, uvc::ct::FOCUS_AUTO);
1249        assert_eq!(payload, vec![1]);
1250
1251        d.set_auto_focus(false).unwrap();
1252        let (_, _, payload) = last_set(&mock);
1253        assert_eq!(payload, vec![0]);
1254    }
1255
1256    #[test]
1257    fn ae_mode_routes_to_ct_with_uvc_bitmap_byte() {
1258        let (d, mock) = device_with_mock();
1259        for (mode, byte) in [
1260            (AeMode::Manual, 0x01),
1261            (AeMode::Auto, 0x02),
1262            (AeMode::ShutterPriority, 0x04),
1263            (AeMode::AperturePriority, 0x08),
1264        ] {
1265            d.set_ae_mode(mode).unwrap();
1266            let (entity, sel, payload) = last_set(&mock);
1267            assert_eq!(entity, uvc::CAMERA_TERMINAL);
1268            assert_eq!(sel, uvc::ct::AE_MODE);
1269            assert_eq!(payload, vec![byte], "wrong byte for {mode:?}");
1270        }
1271    }
1272
1273    #[test]
1274    fn ae_lock_is_thin_wrapper_over_ae_mode() {
1275        let (d, mock) = device_with_mock();
1276        d.set_ae_lock(true).unwrap();
1277        let (_, sel, payload) = last_set(&mock);
1278        assert_eq!(sel, uvc::ct::AE_MODE);
1279        assert_eq!(payload, vec![0x01]); // Manual
1280
1281        d.set_ae_lock(false).unwrap();
1282        let (_, _, payload) = last_set(&mock);
1283        assert_eq!(payload, vec![0x02]); // Auto
1284    }
1285
1286    #[test]
1287    fn exposure_time_routes_to_ct_with_u32_le_payload() {
1288        let (d, mock) = device_with_mock();
1289        d.set_exposure_time(1234).unwrap();
1290        let (entity, sel, payload) = last_set(&mock);
1291        assert_eq!(entity, uvc::CAMERA_TERMINAL);
1292        assert_eq!(sel, uvc::ct::EXPOSURE_TIME_ABSOLUTE);
1293        assert_eq!(payload, 1234_u32.to_le_bytes().to_vec());
1294    }
1295
1296    #[test]
1297    fn anti_flicker_encodes_each_mode() {
1298        let (d, mock) = device_with_mock();
1299        for (mode, byte) in [
1300            (AntiFlicker::Off, 0),
1301            (AntiFlicker::Hz50, 1),
1302            (AntiFlicker::Hz60, 2),
1303            (AntiFlicker::Auto, 3),
1304        ] {
1305            d.set_anti_flicker(mode).unwrap();
1306            let (entity, sel, payload) = last_set(&mock);
1307            assert_eq!(entity, uvc::PROCESSING_UNIT);
1308            assert_eq!(sel, uvc::pu::POWER_LINE_FREQUENCY);
1309            assert_eq!(payload, vec![byte], "wrong byte for {mode:?}");
1310        }
1311    }
1312
1313    #[test]
1314    fn hue_rejects_out_of_i16_range() {
1315        let (d, _) = device_with_mock();
1316        assert!(matches!(d.set_hue(i32::MAX), Err(Error::OutOfRange)));
1317    }
1318
1319    #[test]
1320    fn contrast_and_saturation_route_to_pu() {
1321        let (d, mock) = device_with_mock();
1322        d.set_contrast(100).unwrap();
1323        let (entity, sel, payload) = last_set(&mock);
1324        assert_eq!(entity, uvc::PROCESSING_UNIT);
1325        assert_eq!(sel, uvc::pu::CONTRAST);
1326        assert_eq!(payload, vec![100, 0]);
1327
1328        d.set_saturation(150).unwrap();
1329        let (entity, sel, payload) = last_set(&mock);
1330        assert_eq!(entity, uvc::PROCESSING_UNIT);
1331        assert_eq!(sel, uvc::pu::SATURATION);
1332        assert_eq!(payload, vec![150, 0]);
1333    }
1334
1335    #[test]
1336    fn zoom_routes_to_camera_terminal_with_u16_payload() {
1337        let (d, mock) = device_with_mock();
1338        d.set_zoom(2.5).unwrap();
1339        let (entity, sel, payload) = last_set(&mock);
1340        assert_eq!(entity, uvc::CAMERA_TERMINAL);
1341        assert_eq!(sel, uvc::ct::ZOOM_ABSOLUTE);
1342        assert_eq!(payload, vec![2, 0]); // truncated u16 from 2.5
1343    }
1344
1345    #[test]
1346    fn focus_routes_to_camera_terminal() {
1347        let (d, mock) = device_with_mock();
1348        d.set_focus(100.0).unwrap();
1349        let (entity, sel, _) = last_set(&mock);
1350        assert_eq!(entity, uvc::CAMERA_TERMINAL);
1351        assert_eq!(sel, uvc::ct::FOCUS_ABSOLUTE);
1352    }
1353
1354    #[test]
1355    fn wb_auto_routes_to_pu() {
1356        let (d, mock) = device_with_mock();
1357        d.set_white_balance(WhiteBalanceMode::Auto, None).unwrap();
1358        let (entity, sel, payload) = last_set(&mock);
1359        assert_eq!(entity, uvc::PROCESSING_UNIT);
1360        assert_eq!(sel, uvc::pu::WHITE_BALANCE_TEMPERATURE_AUTO);
1361        assert_eq!(payload, vec![1]);
1362    }
1363
1364    #[test]
1365    fn wb_manual_writes_kelvin_to_pu() {
1366        let (d, mock) = device_with_mock();
1367        d.set_white_balance(WhiteBalanceMode::Manual, Some(5500))
1368            .unwrap();
1369        let (entity, sel, payload) = last_set(&mock);
1370        assert_eq!(entity, uvc::PROCESSING_UNIT);
1371        assert_eq!(sel, uvc::pu::WHITE_BALANCE_TEMPERATURE);
1372        assert_eq!(payload, 5500_u16.to_le_bytes().to_vec());
1373    }
1374
1375    #[test]
1376    fn wdr_routes_to_xu_mode_register_with_wire_bytes() {
1377        let (d, mock) = device_with_mock();
1378        d.set_wdr(WdrMode::Dol2To1).unwrap();
1379        let (entity, sel, payload) = last_set(&mock);
1380        assert_eq!(entity, meet2::XU_ENTITY_ID);
1381        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1382        // setWdr.pcapng frame 70: control_id=0x01 (WDR), flag=0x01, value=0x01 (on).
1383        assert_eq!(payload.len(), meet2::MODE_REGISTER_PAYLOAD_LEN);
1384        assert_eq!(payload[..3], [0x01, 0x01, 0x01]);
1385        assert!(payload[3..].iter().all(|&b| b == 0));
1386
1387        d.set_wdr(WdrMode::Off).unwrap();
1388        let (_, _, payload) = last_set(&mock);
1389        // setWdr.pcapng frame 82: control_id=0x01, flag=0x01, value=0x00 (off).
1390        assert_eq!(payload[..3], [0x01, 0x01, 0x00]);
1391    }
1392
1393    #[test]
1394    fn auto_framing_routes_to_xu_mode_register_with_pair_value() {
1395        let (d, mock) = device_with_mock();
1396
1397        // setAutoFramingGroup.pcapng: 0d 02 00 00
1398        d.set_auto_framing(AutoFramingMode::Group).unwrap();
1399        let (entity, sel, payload) = last_set(&mock);
1400        assert_eq!(entity, meet2::XU_ENTITY_ID);
1401        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1402        assert_eq!(payload[..4], [0x0d, 0x02, 0x00, 0x00]);
1403
1404        // setAutoFramingSingleCloseUp.pcapng: 0d 02 01 00
1405        d.set_auto_framing(AutoFramingMode::SingleCloseUp).unwrap();
1406        let (_, _, payload) = last_set(&mock);
1407        assert_eq!(payload[..4], [0x0d, 0x02, 0x01, 0x00]);
1408
1409        // setAutoFramingSingleUpperBody.pcapng: 0d 02 01 01
1410        d.set_auto_framing(AutoFramingMode::SingleUpperBody)
1411            .unwrap();
1412        let (_, _, payload) = last_set(&mock);
1413        assert_eq!(payload[..4], [0x0d, 0x02, 0x01, 0x01]);
1414    }
1415
1416    #[test]
1417    fn ai_mode_routes_to_xu_mode_register_with_u16_value() {
1418        // setAiMode.pcapng frame 56 (AI mode Human=2): 16 02 02 00 …
1419        let (d, mock) = device_with_mock();
1420        d.set_ai_mode(AiMode::Human).unwrap();
1421        let (entity, sel, payload) = last_set(&mock);
1422        assert_eq!(entity, meet2::XU_ENTITY_ID);
1423        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1424        assert_eq!(payload[..4], [0x16, 0x02, 0x02, 0x00]);
1425
1426        d.set_ai_mode(AiMode::None).unwrap();
1427        let (_, _, payload) = last_set(&mock);
1428        // setAiMode.pcapng frame 64: 16 02 00 00 …
1429        assert_eq!(payload[..4], [0x16, 0x02, 0x00, 0x00]);
1430    }
1431
1432    #[test]
1433    fn fov_routes_to_xu_mode_register_with_wire_bytes() {
1434        // setFov.pcapng frame 52 (FovType78 = Medium): 04 01 01 00 …
1435        let (d, mock) = device_with_mock();
1436        d.set_fov(FovType::Medium).unwrap();
1437        let (entity, sel, payload) = last_set(&mock);
1438        assert_eq!(entity, meet2::XU_ENTITY_ID);
1439        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1440        assert_eq!(payload.len(), meet2::MODE_REGISTER_PAYLOAD_LEN);
1441        assert_eq!(payload[..3], [0x04, 0x01, 0x01]);
1442        assert!(payload[3..].iter().all(|&b| b == 0));
1443    }
1444
1445    #[test]
1446    fn media_mode_routes_to_xu_mode_register_with_wire_bytes() {
1447        // setMediaMode.pcapng frame 58 (MediaModeAutoFrame = 2): 00 01 02 …
1448        let (d, mock) = device_with_mock();
1449        d.set_media_mode(MediaMode::AutoFrame).unwrap();
1450        let (entity, sel, payload) = last_set(&mock);
1451        assert_eq!(entity, meet2::XU_ENTITY_ID);
1452        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1453        assert_eq!(payload[..3], [0x00, 0x01, 0x02]);
1454    }
1455
1456    #[test]
1457    fn microphone_during_sleep_and_button_mode_route_to_mode_register() {
1458        let (d, mock) = device_with_mock();
1459
1460        d.set_microphone_during_sleep(true).unwrap();
1461        let (entity, sel, payload) = last_set(&mock);
1462        assert_eq!(entity, meet2::XU_ENTITY_ID);
1463        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1464        assert_eq!(payload[..3], [0x13, 0x01, 0x01]);
1465
1466        d.set_button_mode(2).unwrap();
1467        let (_, _, payload) = last_set(&mock);
1468        assert_eq!(payload[..3], [0x07, 0x01, 0x02]);
1469    }
1470
1471    #[test]
1472    fn bg_mode_uses_sdk_enum_values_on_the_wire() {
1473        let (d, mock) = device_with_mock();
1474        for (mode, byte) in [
1475            (MediaBgMode::Disable, 0),
1476            (MediaBgMode::Color, 1),
1477            (MediaBgMode::Replace, 17),
1478            (MediaBgMode::Blur, 18),
1479        ] {
1480            d.set_bg_mode(mode).unwrap();
1481            let (entity, sel, payload) = last_set(&mock);
1482            assert_eq!(entity, meet2::XU_ENTITY_ID);
1483            assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1484            assert_eq!(payload[..3], [0x05, 0x01, byte], "wrong byte for {mode:?}");
1485        }
1486    }
1487
1488    #[test]
1489    fn bg_color_signed_sentinels_round_trip_through_u8() {
1490        let (d, mock) = device_with_mock();
1491        // -2 (Disable) and -1 (Null) ride as 0xfe and 0xff respectively.
1492        d.set_bg_color(MediaBgColor::Disable).unwrap();
1493        let (_, _, payload) = last_set(&mock);
1494        assert_eq!(payload[..3], [0x10, 0x01, 0xfe]);
1495
1496        d.set_bg_color(MediaBgColor::Null).unwrap();
1497        let (_, _, payload) = last_set(&mock);
1498        assert_eq!(payload[..3], [0x10, 0x01, 0xff]);
1499
1500        d.set_bg_color(MediaBgColor::Green).unwrap();
1501        let (_, _, payload) = last_set(&mock);
1502        assert_eq!(payload[..3], [0x10, 0x01, 0x01]);
1503    }
1504
1505    #[test]
1506    fn mask_level_rejects_above_100() {
1507        let (d, _) = device_with_mock();
1508        assert!(matches!(d.set_mask_level(101), Err(Error::OutOfRange)));
1509        assert!(matches!(d.set_mask_level(255), Err(Error::OutOfRange)));
1510    }
1511
1512    #[test]
1513    fn suspend_time_encodes_u16_le() {
1514        let (d, mock) = device_with_mock();
1515        d.set_suspend_time(15).unwrap();
1516        let (_, sel, payload) = last_set(&mock);
1517        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1518        // control id 0x0b, value_size 0x02, then u16 LE 15 = 0x0f 0x00.
1519        assert_eq!(payload[..4], [0x0b, 0x02, 0x0f, 0x00]);
1520    }
1521
1522    #[test]
1523    fn flip_portrait_led_route_to_xu_mode_register() {
1524        let (d, mock) = device_with_mock();
1525
1526        d.set_flip_horizontal(true).unwrap();
1527        let (entity, sel, payload) = last_set(&mock);
1528        assert_eq!(entity, meet2::XU_ENTITY_ID);
1529        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1530        assert_eq!(payload[..3], [0x14, 0x01, 0x01]);
1531
1532        d.set_portrait(true).unwrap();
1533        let (_, _, payload) = last_set(&mock);
1534        assert_eq!(payload[..3], [0x0c, 0x01, 0x01]);
1535
1536        d.set_led(false).unwrap();
1537        let (_, _, payload) = last_set(&mock);
1538        assert_eq!(payload[..3], [0x18, 0x01, 0x00]);
1539    }
1540
1541    #[test]
1542    fn audio_agc_routes_to_xu_mode_register_with_wire_bytes() {
1543        let (d, mock) = device_with_mock();
1544        d.set_audio_agc(true).unwrap();
1545        let (entity, sel, payload) = last_set(&mock);
1546        assert_eq!(entity, meet2::XU_ENTITY_ID);
1547        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1548        // control id 0x17, value_size 0x01, value 0x01 (on).
1549        assert_eq!(payload[..3], [0x17, 0x01, 0x01]);
1550        assert!(payload[3..].iter().all(|&b| b == 0));
1551
1552        d.set_audio_agc(false).unwrap();
1553        let (_, _, payload) = last_set(&mock);
1554        assert_eq!(payload[..3], [0x17, 0x01, 0x00]);
1555    }
1556
1557    #[test]
1558    fn face_ae_routes_to_xu_mode_register_with_wire_bytes() {
1559        // setFaceAE.pcapng frame 52 (on): 03 01 01 …
1560        let (d, mock) = device_with_mock();
1561        d.set_face_ae(true).unwrap();
1562        let (entity, sel, payload) = last_set(&mock);
1563        assert_eq!(entity, meet2::XU_ENTITY_ID);
1564        assert_eq!(sel, meet2::XU_SEL_MODE_REGISTER);
1565        assert_eq!(payload[..3], [0x03, 0x01, 0x01]);
1566
1567        d.set_face_ae(false).unwrap();
1568        let (_, _, payload) = last_set(&mock);
1569        assert_eq!(payload[..3], [0x03, 0x01, 0x00]);
1570    }
1571
1572    #[test]
1573    fn wdr_encode_decode_round_trip() {
1574        for mode in [WdrMode::Off, WdrMode::Dol2To1] {
1575            assert_eq!(decode_wdr(encode_wdr(mode)).unwrap(), mode);
1576        }
1577    }
1578
1579    #[test]
1580    fn decode_rejects_unknown_byte() {
1581        assert!(matches!(decode_wdr(99), Err(Error::BadResponse { .. })));
1582    }
1583
1584    #[test]
1585    fn pan_tilt_getter_decodes_two_i32_le() {
1586        use crate::testing::device_with_scripted_get;
1587        // 540_000 arc-seconds (= 1.0 after normalisation) is 0x0083d600.
1588        let mut buf = Vec::new();
1589        buf.extend_from_slice(&540_000_i32.to_le_bytes());
1590        buf.extend_from_slice(&(-270_000_i32).to_le_bytes());
1591        let device = device_with_scripted_get(vec![buf]);
1592        let (pan, tilt) = device.pan_tilt().unwrap();
1593        assert!((pan - 1.0).abs() < 1e-3);
1594        assert!((tilt + 0.5).abs() < 1e-3);
1595    }
1596
1597    #[test]
1598    fn zoom_and_focus_getters_decode_u16_le() {
1599        use crate::testing::device_with_scripted_get;
1600        let device = device_with_scripted_get(vec![
1601            vec![0x10, 0x00], // zoom = 16
1602            vec![0xff, 0x00], // focus = 255
1603        ]);
1604        assert!((device.zoom().unwrap() - 16.0).abs() < f32::EPSILON);
1605        assert!((device.focus().unwrap() - 255.0).abs() < f32::EPSILON);
1606    }
1607
1608    #[test]
1609    fn status_poller_emits_snapshots_when_sender_provided_and_stops_on_drop() {
1610        use crate::testing::{meet2_mock_info, Forward, MockTransport};
1611
1612        let mock = Arc::new(MockTransport::default());
1613        let transport: Arc<dyn Transport> = Arc::new(Forward(mock));
1614        let (tx, rx) = crossbeam_channel::unbounded::<Event>();
1615        let device = Device::new(meet2_mock_info(), transport, Some(tx), meet2::CAPTURED_MAC);
1616        device.set_status_cadence(Cadence::Fast);
1617
1618        // The poller emits its first sample immediately on entry to the
1619        // loop, so a generous timeout here is purely defensive.
1620        let ev = rx
1621            .recv_timeout(Duration::from_millis(500))
1622            .expect("first status event");
1623        let Event::Status { snapshot, serial } = ev else {
1624            panic!("expected Status event, got {ev:?}");
1625        };
1626        assert_eq!(serial, "MOCK");
1627        // MockTransport zero-fills GETs, so every field reads as zero.
1628        assert_eq!(snapshot.brightness, 0);
1629        assert_eq!(snapshot.contrast, 0);
1630        assert_eq!(snapshot.saturation, 0);
1631
1632        drop(device);
1633
1634        // After drop, drain any in-flight samples then assert the
1635        // channel is quiet (poller exited).
1636        std::thread::sleep(Duration::from_millis(100));
1637        while rx.try_recv().is_ok() {}
1638        assert!(rx.try_recv().is_err(), "poller still emitting after drop");
1639    }
1640}