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