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