Skip to main content

openlogi_core/
device.rs

1//! Serializable device-model types.
2//!
3//! These mirror the HID++ types from the `hidpp` crate but live here so the
4//! CLI and any future GUI can depend on them without dragging in the protocol
5//! crate or its async transport.
6
7use serde::{Deserialize, Serialize};
8
9use crate::hid::DeviceRoute;
10
11mod light;
12
13pub use light::{LightCapabilities, LightValueRange, LightValueRangeError, LightValueUnit};
14
15/// What a paired peripheral is. Mirrors `hidpp::receiver::bolt::BoltDeviceKind`
16/// but is owned by us so consumers don't depend on `hidpp`.
17///
18/// Several upstream "device type" vocabularies feed this one enum, and they do
19/// **not** agree on numbers: the Bolt pairing register uses `Unknown=0,
20/// Keyboard=1, Mouse=2, …`, while the HID++ `0x0005` feature uses
21/// `Keyboard=0, …, Mouse=3, …` (no `Unknown` at all). The asset registry adds a
22/// third, free-form *string* type (`"mouse"`, case-inconsistently `"MOUSE"`).
23/// They are converted to this enum at their respective boundaries — never by
24/// reinterpreting one source's raw byte with another's table — so the numeric
25/// mismatch can't leak past those mappers.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum DeviceKind {
29    /// Mice — the family OpenLogi's binding/DPI panels primarily target.
30    Mouse,
31    /// Keyboards, including lighting-capable ones.
32    Keyboard,
33    /// Standalone numeric keypads.
34    Numpad,
35    /// Presentation remotes (slide clickers).
36    Presenter,
37    /// Remote controls; the registry's `"remotecontrol"` string also folds here.
38    Remote,
39    /// Trackballs — treated like mice for presumed capabilities.
40    Trackball,
41    /// External touchpads; the registry's `"trackpad"` string also folds here.
42    Touchpad,
43    /// Pen/graphics tablets.
44    Tablet,
45    /// Game controllers, mirrored from the Bolt pairing vocabulary.
46    Gamepad,
47    /// Joysticks, mirrored from the Bolt pairing vocabulary.
48    Joystick,
49    /// Audio headsets paired through a receiver.
50    Headset,
51    /// Logitech webcam (UVC), configured through `openlogi-camera`.
52    Camera,
53    /// Not classified by any source.
54    Unknown,
55    /// Standalone light or other illumination device controlled outside HID++.
56    ///
57    /// This is an identity hint only. UI controls are gated by the dedicated
58    /// light capability descriptor, never by this variant alone.
59    Light,
60}
61
62impl DeviceKind {
63    /// Parse the OpenLogi asset registry's `type` string into a [`DeviceKind`].
64    ///
65    /// The registry field is free-form and case-inconsistent (both `"mouse"`
66    /// and `"MOUSE"` ship), so we case-fold before matching. `None` for values
67    /// we don't model — "no asset opinion", typed as the absence of one rather
68    /// than overloading [`DeviceKind::Unknown`], which stays a real wire value
69    /// a live device can carry.
70    #[must_use]
71    pub fn from_registry_type(raw: &str) -> Option<Self> {
72        match raw.trim().to_ascii_lowercase().as_str() {
73            "mouse" => Some(Self::Mouse),
74            "keyboard" => Some(Self::Keyboard),
75            "numpad" => Some(Self::Numpad),
76            "presenter" => Some(Self::Presenter),
77            "remote" | "remotecontrol" => Some(Self::Remote),
78            "trackball" => Some(Self::Trackball),
79            "touchpad" | "trackpad" => Some(Self::Touchpad),
80            "tablet" => Some(Self::Tablet),
81            "gamepad" => Some(Self::Gamepad),
82            "joystick" => Some(Self::Joystick),
83            "headset" => Some(Self::Headset),
84            "camera" => Some(Self::Camera),
85            "light" | "lighting" | "illumination_light" => Some(Self::Light),
86            _ => None,
87        }
88    }
89}
90
91/// What a device can be *configured* to do, derived from the HID++ feature
92/// table it reports (feature `0x0001`). This is the source of truth for which
93/// configuration panels the UI offers — a panel shows iff the device exposes
94/// the feature that drives it. Gating on capability rather than on
95/// [`DeviceKind`] is what keeps a misclassified device from losing its panels
96/// (issue #127): kind is an identity guess, capability is what the firmware
97/// actually announced.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100#[expect(
101    clippy::struct_excessive_bools,
102    reason = "capabilities is a serialized feature-bit DTO; independent booleans keep the IPC/config shape explicit"
103)]
104pub struct Capabilities {
105    /// Reprogrammable buttons — HID++ `0x1b00`–`0x1b04` (ReprogControls).
106    pub buttons: bool,
107    /// Adjustable pointer resolution — HID++ `0x2201` / `0x2202` (AdjustableDpi).
108    pub pointer: bool,
109    /// Solid-colour RGB the lighting panel can drive — HID++ effect engines
110    /// (`0x8070` / `0x8071`) or per-zone lighting (`0x8080` / `0x8081`).
111    /// Backlight-only families aren't driven by the panel, so they don't earn
112    /// an inert Lighting tab.
113    pub lighting: bool,
114    /// Native vertical wheel inversion — HID++ `0x2121 HiResWheel` with the
115    /// firmware-reported `has_invert` capability.
116    pub scroll_inversion: bool,
117    /// HID++ `0x2121 HiResWheel` is present, so the wheel reporting resolution
118    /// can be read and changed independently of inversion support.
119    #[serde(default)]
120    pub hires_wheel: bool,
121    /// A horizontal thumb wheel is available: either the dedicated HID++
122    /// `0x2150 Thumbwheel` feature or a legacy `0x6501 Gestures2` descriptor
123    /// (gesture id 46, used by MX Master 2S).
124    #[serde(default)]
125    pub thumbwheel: bool,
126    /// Programmable haptic feedback — reverse-engineered HID++ `0x19b0`.
127    #[serde(default)]
128    pub haptic_feedback: bool,
129    /// A divertable Haptic Sense Panel control (`0x01a0`) was found in the
130    /// device's `0x1b04` control table.
131    #[serde(default)]
132    pub haptic_panel: bool,
133    /// A DPI/ModeShift control in the device's `0x1b04` control table supports
134    /// both diversion and raw-XY reporting for hold-and-swipe gestures.
135    #[serde(default)]
136    pub dpi_gestures: bool,
137    /// Keyboard Fn-lock can be read and written — HID++ `0x40a2` or `0x40a3`
138    /// fn inversion. Appended: crosses the IPC and the persisted identity.
139    #[serde(default)]
140    pub fn_lock: bool,
141}
142
143impl Capabilities {
144    /// Derive capabilities from the set of HID++ feature IDs a device reports.
145    /// Membership of a driving feature ID flips the corresponding flag.
146    #[must_use]
147    pub fn from_feature_ids(ids: &[u16]) -> Self {
148        const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04];
149        const POINTER: [u16; 2] = [0x2201, 0x2202];
150        // Every family here is driven by `set_keyboard_color`, which tries
151        // effect engines before per-zone paths. Backlight (0x198x) stays out.
152        const LIGHTING: [u16; 4] = [0x8070, 0x8071, 0x8081, 0x8080];
153        let has = |family: &[u16]| ids.iter().any(|id| family.contains(id));
154        Self {
155            buttons: has(&BUTTONS),
156            pointer: has(&POINTER),
157            lighting: has(&LIGHTING),
158            scroll_inversion: false,
159            hires_wheel: ids.contains(&0x2121),
160            thumbwheel: ids.contains(&0x2150),
161            haptic_feedback: ids.contains(&0x19b0),
162            haptic_panel: false,
163            dpi_gestures: false,
164            fn_lock: ids.contains(&0x40a2) || ids.contains(&0x40a3),
165        }
166    }
167
168    /// Best-effort capabilities for a device we could not probe (offline /
169    /// never reached), guessed from its [`DeviceKind`]. Used only as a fallback
170    /// when no measured [`Capabilities`] exist — a sleeping mouse should still
171    /// show its button/pointer panels so its bindings (host-side) stay
172    /// configurable.
173    #[must_use]
174    pub fn presumed_from_kind(kind: DeviceKind) -> Self {
175        match kind {
176            DeviceKind::Mouse | DeviceKind::Trackball => Self {
177                buttons: true,
178                pointer: true,
179                ..Self::default()
180            },
181            DeviceKind::Keyboard => Self {
182                lighting: true,
183                ..Self::default()
184            },
185            _ => Self::default(),
186        }
187    }
188}
189
190/// Coarse battery bucket reported by the device firmware.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
192#[serde(rename_all = "lowercase")]
193pub enum BatteryLevel {
194    /// Almost depleted — the firmware's most urgent bucket.
195    Critical,
196    /// Running low; worth surfacing a charge hint.
197    Low,
198    /// Comfortable middle range, no user action needed.
199    Good,
200    /// At or near full charge.
201    Full,
202    /// The firmware did not report a level, or reported one we don't model.
203    Unknown,
204}
205
206/// Charging state. Mirrors `hidpp 0.2`'s `BatteryStatus` plus `Unknown` for
207/// values added in future protocol versions.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum BatteryStatus {
211    /// Running on battery.
212    Discharging,
213    /// Charging at the normal rate.
214    Charging,
215    /// Charging at reduced current (e.g. from a weak power source).
216    ChargingSlow,
217    /// Charge complete while still connected to power.
218    Full,
219    /// The device reported a charging fault.
220    Error,
221    /// A status value this build doesn't model (future protocol additions).
222    Unknown,
223}
224
225/// Battery snapshot for one paired device, as last polled over HID++.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct BatteryInfo {
228    /// Reported charge percentage (`0..=100`).
229    pub percentage: u8,
230    /// Coarse bucket for UI that doesn't want the raw percentage.
231    pub level: BatteryLevel,
232    /// Charging state at poll time.
233    pub status: BatteryStatus,
234}
235
236/// Identity of an enumerated receiver — no paired-device state (that lives
237/// in [`DeviceInventory::paired`]). For a direct (Bluetooth/wired) device,
238/// a synthetic entry mirroring the device's own HID identity fills this role.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct ReceiverInfo {
241    /// Product string from the HID enumeration (e.g. `"Logi Bolt Receiver"`).
242    pub name: String,
243    /// USB vendor ID (`0x046d` for Logitech).
244    pub vendor_id: u16,
245    /// USB product ID distinguishing the receiver model.
246    pub product_id: u16,
247    /// Platform-reported serial, when one is exposed. Deliberately excluded
248    /// from diagnostics (see [`crate::diagnostics::ReceiverDiag`]).
249    pub unique_id: Option<String>,
250}
251
252/// HID++ `DeviceInformation` (feature 0x0003) snapshot used to identify a
253/// device against external registries (e.g. the OpenLogi asset index).
254///
255/// `model_ids` is the per-transport PID array reported by the firmware,
256/// ordered to match the transports flagged in [`Self::transports`] (USB,
257/// eQuad, BTLE, Bluetooth) — slots that aren't enabled stay `0`. The Logi
258/// Options+ asset registry's `modelId` (e.g. `"6b023"`) is the concatenation
259/// of an extended-model byte and one of these PIDs, so callers usually want
260/// to format `extended_model_id` + `model_ids[N]` to match.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct DeviceModelInfo {
264    /// Number of firmware entities (main firmware, bootloader, …) the
265    /// device reports.
266    pub entity_count: u8,
267    /// HID++ DeviceInformation serial number, when the device supports the
268    /// optional serial-number function.
269    pub serial_number: Option<String>,
270    /// Per-unit ID bytes — unique to the physical unit, unlike the
271    /// model-level fields around it.
272    pub unit_id: [u8; 4],
273    /// Which transports the firmware supports; defines the slot order of
274    /// [`Self::model_ids`].
275    pub transports: DeviceTransports,
276    /// Per-transport PIDs for the transports enabled in [`Self::transports`],
277    /// packed from the front in ascending HID++ transport-bit order:
278    /// Bluetooth (bit 0), BTLE (bit 1), eQuad (bit 2), USB (bit 3). Only
279    /// enabled transports take a slot — a disabled transport is skipped, not
280    /// zero-filled — so unused trailing slots stay `0` and the array holds at
281    /// most three PIDs.
282    ///
283    /// Note this is the reverse of the field order of [`DeviceTransports`],
284    /// which lists `usb` first. A device with `usb + equad` reports
285    /// `[eQuad PID, USB PID, 0]`; one with `equad + btle` reports
286    /// `[BTLE PID, eQuad PID, 0]`.
287    pub model_ids: [u16; 3],
288    /// Extra model byte prefixed to a PID to form the asset registry's
289    /// `modelId` — see [`Self::model_key`].
290    pub extended_model_id: u8,
291}
292
293impl DeviceModelInfo {
294    /// The model's key in the OpenLogi asset registry: the string the depot
295    /// `manifest.json` uses for its `modelId` field, formatted
296    /// `{extended_model_id:x}{model_ids[0]:04x}`. An MX Master 4 with
297    /// `extended_model_id = 0x02` and `model_ids[0] = 0xb042` resolves to
298    /// `"2b042"`. Per-device configuration is keyed by
299    /// [`crate::device_order::DeviceIdentity::config_key`], not by this.
300    #[must_use]
301    pub fn model_key(&self) -> String {
302        format!("{:x}{:04x}", self.extended_model_id, self.model_ids[0])
303    }
304
305    /// Identifies which transport `product_id` belongs to, by matching it
306    /// against the packed per-transport [`Self::model_ids`] slots.
307    ///
308    /// A `Direct` route only ever carries one vendor/product id — the HID
309    /// node this session actually enumerated — so on a multi-transport
310    /// device this pinpoints the transport that's *live right now*, as
311    /// opposed to [`Self::transports`], which is the static set the firmware
312    /// merely claims to support. Returns `None` when `product_id` matches
313    /// none of the enabled slots (unknown model info, or a PID this device
314    /// doesn't report).
315    #[must_use]
316    pub fn transport_for_product_id(&self, product_id: u16) -> Option<ModelTransport> {
317        let t = self.transports;
318        [
319            (t.bluetooth, ModelTransport::Bluetooth),
320            (t.btle, ModelTransport::Btle),
321            (t.equad, ModelTransport::Equad),
322            (t.usb, ModelTransport::Usb),
323        ]
324        .into_iter()
325        .filter_map(|(enabled, kind)| enabled.then_some(kind))
326        .zip(self.model_ids)
327        .find(|(_, pid)| *pid == product_id)
328        .map(|(kind, _)| kind)
329    }
330}
331
332/// One of the individual transports packed into [`DeviceModelInfo::model_ids`],
333/// as resolved by [`DeviceModelInfo::transport_for_product_id`].
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub enum ModelTransport {
336    /// Classic Bluetooth.
337    Bluetooth,
338    /// Bluetooth Low Energy.
339    Btle,
340    /// Logitech eQuad (Unifying/Bolt receiver RF protocol).
341    Equad,
342    /// Wired USB.
343    Usb,
344}
345
346/// Mirror of hidpp's `DeviceTransport` bitfield — one bool per protocol the
347/// device firmware exposes. The shape is dictated by HID++ feature 0x0003;
348/// a state machine doesn't fit since a single device can announce multiple
349/// transports simultaneously.
350#[expect(
351    clippy::struct_excessive_bools,
352    reason = "bitfield mirroring HID++ DeviceInformation; transports are independent flags"
353)]
354#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(deny_unknown_fields)]
356pub struct DeviceTransports {
357    /// Wired USB.
358    pub usb: bool,
359    /// Logitech eQuad — the Unifying/Bolt receiver RF protocol.
360    pub equad: bool,
361    /// Bluetooth Low Energy.
362    pub btle: bool,
363    /// Classic Bluetooth.
364    pub bluetooth: bool,
365}
366
367/// One device in the agent's inventory snapshot: a receiver pairing slot,
368/// or a direct (Bluetooth/wired) attachment under its synthetic
369/// [`ReceiverInfo`]. Embedded in [`DeviceInventory`], so its field order is
370/// IPC wire format — see that type's contract.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372pub struct PairedDevice {
373    /// Receiver-assigned slot (1..=6 for Bolt).
374    pub slot: u8,
375    /// Firmware codename (e.g. `"MX Master 3S"`), when reported.
376    pub codename: Option<String>,
377    /// Wireless product ID. `None` for offline / unreachable devices on hidpp 0.2.
378    pub wpid: Option<u16>,
379    /// Best-guess classification. Identity only — panel gating uses
380    /// [`Self::capabilities`] instead, so a misread kind can't hide panels
381    /// (issue #127).
382    pub kind: DeviceKind,
383    /// Whether the device was reachable at enumeration time; offline devices
384    /// keep their slot with reduced detail.
385    pub online: bool,
386    /// Last battery reading, `None` when offline or the device doesn't
387    /// report battery.
388    pub battery: Option<BatteryInfo>,
389    /// Output of HID++ feature 0x0003 — populated for online devices that
390    /// expose the feature. Drives asset-registry lookups in the GUI.
391    pub model_info: Option<DeviceModelInfo>,
392    /// Configuration capabilities derived from the device's HID++ feature
393    /// table. `None` for devices we couldn't probe (offline / unreachable);
394    /// the GUI then falls back to [`Capabilities::presumed_from_kind`].
395    pub capabilities: Option<Capabilities>,
396}
397
398/// Address of a standalone raw-HID interface.
399///
400/// The identity is an opaque transport-generated string. It is deliberately
401/// kept separate from the HID++ receiver/slot address so a raw device cannot
402/// accidentally enter the HID++ `Direct` path.
403#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
404pub struct RawDeviceAddress {
405    /// HID vendor ID.
406    pub vendor_id: u16,
407    /// HID product ID.
408    pub product_id: u16,
409    /// HID usage page.
410    pub usage_page: u16,
411    /// HID usage ID.
412    pub usage_id: u16,
413    /// Identity chosen by the transport: a serial when available, otherwise
414    /// an explicitly transient OS-node identity. It is never an enumeration
415    /// index and a transient value is not persisted as a physical key.
416    pub identity: String,
417}
418
419/// A standalone device that is not a HID++ receiver pairing slot.
420///
421/// This is the inventory bridge for Litra and future non-HID++ categories.
422/// Receiver-backed devices continue to use [`PairedDevice`] inside
423/// [`DeviceInventory`].
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct StandaloneDevice {
426    /// Raw HID address used to re-find the interface.
427    pub address: RawDeviceAddress,
428    /// Human-readable name supplied by the OS/HID descriptor.
429    pub display_name: String,
430    /// Human-readable manufacturer, when available.
431    pub manufacturer: Option<String>,
432    /// Device serial, when the HID backend exposes one.
433    pub serial_number: Option<String>,
434    /// Stable four-byte identity when the protocol/driver provides one.
435    /// Raw HID drivers may use zeroes when no such field exists.
436    pub unit_id: [u8; 4],
437    /// Identity classification. Capability fields gate controls.
438    pub kind: DeviceKind,
439    /// Whether this interface was present in the latest completed scan.
440    pub online: bool,
441    /// HID++ capabilities are absent for a non-HID++ device.
442    pub capabilities: Option<Capabilities>,
443    /// Standalone capability descriptor, if the selected driver recognizes it.
444    pub light_capabilities: Option<LightCapabilities>,
445    /// Stable identifier of the driver family that owns this raw interface.
446    /// This is deliberately separate from the product ID so a future family
447    /// can share a protocol driver across several product variants.
448    pub driver_id: String,
449    /// Optional model-level identity in the OpenLogi asset registry.
450    ///
451    /// This is deliberately appended: `StandaloneDevice` crosses the
452    /// append-only GUI↔agent bincode wire format.
453    #[serde(default)]
454    pub registry_model_id: Option<String>,
455}
456
457impl StandaloneDevice {
458    /// The route that reaches this device.
459    #[must_use]
460    pub fn route(&self) -> DeviceRoute {
461        DeviceRoute::from(&self.address)
462    }
463}
464
465/// One receiver and its paired devices — the unit the agent's inventory
466/// snapshot is made of.
467///
468/// Crosses the agent↔GUI IPC (everything it embeds too: [`ReceiverInfo`],
469/// [`PairedDevice`], battery/model-info/capability types). bincode encodes
470/// field and variant *order*, so reordering, retyping, or wrapping any field
471/// in this tree is a wire-format change and requires a `PROTOCOL_VERSION`
472/// bump (guarded by `openlogi-ipc/tests/wire_format.rs`).
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474pub struct DeviceInventory {
475    /// The receiver's identity — synthetic (mirroring the device itself)
476    /// for a direct Bluetooth/wired attachment.
477    pub receiver: ReceiverInfo,
478    /// The devices reached through this receiver; a direct attachment
479    /// carries exactly one entry.
480    pub paired: Vec<PairedDevice>,
481}
482
483#[cfg(test)]
484mod tests {
485    use super::{
486        BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind,
487        DeviceModelInfo, DeviceTransports, LightValueRange, LightValueUnit, ModelTransport,
488        PairedDevice, ReceiverInfo,
489    };
490
491    fn inventory(slot: u8, wpid: Option<u16>, battery_percentage: u8) -> DeviceInventory {
492        DeviceInventory {
493            receiver: ReceiverInfo {
494                name: "Logi Bolt Receiver".to_string(),
495                vendor_id: 0x046d,
496                product_id: 0xc548,
497                unique_id: Some("receiver-1".to_string()),
498            },
499            paired: vec![PairedDevice {
500                slot,
501                codename: Some("MX Test".to_string()),
502                wpid,
503                kind: DeviceKind::Mouse,
504                online: true,
505                battery: Some(BatteryInfo {
506                    percentage: battery_percentage,
507                    level: BatteryLevel::Good,
508                    status: BatteryStatus::Discharging,
509                }),
510                model_info: Some(DeviceModelInfo {
511                    entity_count: 1,
512                    serial_number: Some("serial-1".to_string()),
513                    unit_id: [1, 2, 3, 4],
514                    transports: DeviceTransports {
515                        usb: true,
516                        equad: true,
517                        btle: false,
518                        bluetooth: false,
519                    },
520                    model_ids: [0xb023, 0, 0],
521                    extended_model_id: 0x02,
522                }),
523                capabilities: Some(Capabilities {
524                    buttons: true,
525                    pointer: true,
526                    lighting: false,
527                    scroll_inversion: false,
528                    hires_wheel: false,
529                    thumbwheel: false,
530                    haptic_feedback: false,
531                    haptic_panel: false,
532                    dpi_gestures: false,
533                    fn_lock: false,
534                }),
535            }],
536        }
537    }
538
539    #[test]
540    fn device_inventory_equality_includes_nested_device_fields() {
541        let base = inventory(1, Some(0xb023), 86);
542        assert_eq!(base, base.clone());
543
544        assert_ne!(
545            base,
546            inventory(2, Some(0xb023), 86),
547            "slot changes must affect inventory equality"
548        );
549        assert_ne!(
550            base,
551            inventory(1, Some(0xb024), 86),
552            "wireless product id changes must affect inventory equality"
553        );
554        assert_ne!(
555            base,
556            inventory(1, Some(0xb023), 87),
557            "nested battery changes must affect inventory equality"
558        );
559    }
560
561    #[test]
562    fn transport_for_product_id_resolves_the_live_slot() {
563        // G915 X LS from issue #1218: usb + equad + btle all supported,
564        // model_ids packed BTLE/eQuad/USB per the ascending-bit-order
565        // contract documented on `DeviceModelInfo::model_ids`.
566        let model = DeviceModelInfo {
567            entity_count: 3,
568            serial_number: None,
569            unit_id: [0, 0, 0, 0],
570            transports: DeviceTransports {
571                usb: true,
572                equad: true,
573                btle: true,
574                bluetooth: false,
575            },
576            model_ids: [0xb38a, 0x40b5, 0xc356],
577            extended_model_id: 0x01,
578        };
579
580        assert_eq!(
581            model.transport_for_product_id(0xc356),
582            Some(ModelTransport::Usb),
583            "the USB-slot pid must resolve to Usb even though the device \
584             also supports BTLE"
585        );
586        assert_eq!(
587            model.transport_for_product_id(0xb38a),
588            Some(ModelTransport::Btle)
589        );
590        assert_eq!(
591            model.transport_for_product_id(0x40b5),
592            Some(ModelTransport::Equad)
593        );
594        assert_eq!(
595            model.transport_for_product_id(0xdead),
596            None,
597            "an unrecognized pid must not silently match a slot"
598        );
599    }
600
601    #[test]
602    fn registry_type_is_case_folded() {
603        // The registry ships both `"mouse"` and `"MOUSE"`; both must resolve so
604        // the asset cross-check can't silently miss a depot.
605        assert_eq!(
606            DeviceKind::from_registry_type("mouse"),
607            Some(DeviceKind::Mouse)
608        );
609        assert_eq!(
610            DeviceKind::from_registry_type("MOUSE"),
611            Some(DeviceKind::Mouse)
612        );
613        assert_eq!(
614            DeviceKind::from_registry_type("  Keyboard "),
615            Some(DeviceKind::Keyboard)
616        );
617    }
618
619    #[test]
620    fn unknown_registry_type_defers_to_the_caller() {
621        // Unmodelled / empty → no asset opinion.
622        assert_eq!(DeviceKind::from_registry_type("webcam"), None);
623        assert_eq!(DeviceKind::from_registry_type(""), None);
624    }
625
626    #[test]
627    fn capabilities_track_the_driving_feature_ids() {
628        use super::Capabilities;
629        // A typical MX mouse: ReprogControls (0x1b04) + ExtendedAdjustableDpi
630        // (0x2202), no lighting.
631        let mouse =
632            Capabilities::from_feature_ids(&[0x0003, 0x1b04, 0x2121, 0x2150, 0x2202, 0x2110]);
633        assert_eq!(
634            mouse,
635            Capabilities {
636                buttons: true,
637                pointer: true,
638                lighting: false,
639                scroll_inversion: false,
640                hires_wheel: true,
641                thumbwheel: true,
642                haptic_feedback: false,
643                haptic_panel: false,
644                dpi_gestures: false,
645                fn_lock: false,
646            }
647        );
648        assert!(!Capabilities::from_feature_ids(&[0x0003, 0x1b04]).thumbwheel);
649        // A wired G-series keyboard: PerKeyLighting (0x8080), no DPI/buttons.
650        let keyboard = Capabilities::from_feature_ids(&[0x0001, 0x8080]);
651        assert_eq!(
652            keyboard,
653            Capabilities {
654                buttons: false,
655                pointer: false,
656                lighting: true,
657                scroll_inversion: false,
658                hires_wheel: false,
659                thumbwheel: false,
660                haptic_feedback: false,
661                haptic_panel: false,
662                dpi_gestures: false,
663                fn_lock: false,
664            }
665        );
666        // No driving features → nothing offered.
667        assert_eq!(
668            Capabilities::from_feature_ids(&[0x0000, 0x0003]),
669            Capabilities::default()
670        );
671    }
672
673    #[test]
674    fn every_drivable_lighting_family_earns_the_tab() {
675        // `set_keyboard_color` walks 0x8070 → 0x8071 → 0x8081 → 0x8080.
676        for id in [0x8070, 0x8071, 0x8080, 0x8081] {
677            assert!(
678                Capabilities::from_feature_ids(&[0x0001, id]).lighting,
679                "0x{id:04x} must offer the lighting tab"
680            );
681        }
682        // Backlight (0x198x) stays out — the panel cannot drive it.
683        assert!(!Capabilities::from_feature_ids(&[0x0001, 0x1982]).lighting);
684        // Either fn-inversion feature gives the keyboard an Fn-lock control.
685        assert!(Capabilities::from_feature_ids(&[0x0001, 0x40a3]).fn_lock);
686        assert!(Capabilities::from_feature_ids(&[0x0001, 0x40a2]).fn_lock);
687        assert!(!Capabilities::from_feature_ids(&[0x0001, 0x1982]).fn_lock);
688    }
689
690    #[test]
691    fn persisted_capabilities_without_appended_wheel_fields_load_as_unsupported()
692    -> Result<(), toml::de::Error> {
693        use super::Capabilities;
694
695        let capabilities: Capabilities = toml::from_str(
696            r"
697                buttons = true
698                pointer = true
699                lighting = false
700                scroll_inversion = true
701            ",
702        )?;
703
704        assert!(!capabilities.hires_wheel);
705        assert!(!capabilities.thumbwheel);
706        assert!(capabilities.scroll_inversion);
707        Ok(())
708    }
709
710    #[test]
711    fn presumed_capabilities_keep_an_unprobed_mouse_configurable() {
712        use super::Capabilities;
713        let mouse = Capabilities::presumed_from_kind(DeviceKind::Mouse);
714        assert!(mouse.buttons && mouse.pointer && !mouse.lighting);
715        assert!(!mouse.thumbwheel);
716        assert!(Capabilities::presumed_from_kind(DeviceKind::Keyboard).lighting);
717        // An unidentified device presumes nothing — it must be measured.
718        assert_eq!(
719            Capabilities::presumed_from_kind(DeviceKind::Unknown),
720            Capabilities::default()
721        );
722    }
723
724    #[test]
725    fn light_ranges_reject_invalid_grids_and_units() {
726        LightValueRange::new(10, 1, 1, LightValueUnit::Lumens)
727            .expect_err("a minimum above the maximum must be rejected");
728        LightValueRange::new(0, 10, 0, LightValueUnit::Lumens)
729            .expect_err("a zero step must be rejected");
730        LightValueRange::new(0, 10, 3, LightValueUnit::Lumens)
731            .expect_err("a step that does not divide the span must be rejected");
732        LightValueRange::new(0, 101, 1, LightValueUnit::Percent)
733            .expect_err("a percent range above 100 must be rejected");
734    }
735
736    #[test]
737    fn light_ranges_quantize_without_leaving_the_advertised_grid() {
738        let range = LightValueRange::new(20, 250, 10, LightValueUnit::Lumens).expect("valid range");
739        assert_eq!(range.native_for_percent(0), Some(20));
740        assert_eq!(range.native_for_percent(50), Some(140));
741        assert_eq!(range.native_for_percent(100), Some(250));
742        assert_eq!(range.quantize(249), 250);
743        assert!(range.contains(range.native_for_percent(65).expect("mapped value")));
744    }
745
746    #[test]
747    fn invalid_light_ranges_fail_toml_deserialization() {
748        let result = toml::from_str::<LightValueRange>(
749            "min = 2700\nmax = 6500\nstep = 0\nunit = 'kelvin'\n",
750        );
751        result.expect_err("a zero step must not survive deserialization");
752    }
753}