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