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