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