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
9/// What a paired peripheral is. Mirrors `hidpp::receiver::bolt::BoltDeviceKind`
10/// but is owned by us so consumers don't depend on `hidpp`.
11///
12/// Several upstream "device type" vocabularies feed this one enum, and they do
13/// **not** agree on numbers: the Bolt pairing register uses `Unknown=0,
14/// Keyboard=1, Mouse=2, …`, while the HID++ `0x0005` feature uses
15/// `Keyboard=0, …, Mouse=3, …` (no `Unknown` at all). The asset registry adds a
16/// third, free-form *string* type (`"mouse"`, case-inconsistently `"MOUSE"`).
17/// They are converted to this enum at their respective boundaries — never by
18/// reinterpreting one source's raw byte with another's table — so the numeric
19/// mismatch can't leak past those mappers.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum DeviceKind {
23    /// Mice — the family OpenLogi's binding/DPI panels primarily target.
24    Mouse,
25    /// Keyboards, including lighting-capable ones.
26    Keyboard,
27    /// Standalone numeric keypads.
28    Numpad,
29    /// Presentation remotes (slide clickers).
30    Presenter,
31    /// Remote controls; the registry's `"remotecontrol"` string also folds here.
32    Remote,
33    /// Trackballs — treated like mice for presumed capabilities.
34    Trackball,
35    /// External touchpads; the registry's `"trackpad"` string also folds here.
36    Touchpad,
37    /// Pen/graphics tablets.
38    Tablet,
39    /// Game controllers, mirrored from the Bolt pairing vocabulary.
40    Gamepad,
41    /// Joysticks, mirrored from the Bolt pairing vocabulary.
42    Joystick,
43    /// Audio headsets paired through a receiver.
44    Headset,
45    /// Not classified by any source — also the "no asset opinion" value
46    /// [`DeviceKind::from_registry_type`] returns for unmodelled strings.
47    Unknown,
48}
49
50impl DeviceKind {
51    /// Parse the OpenLogi asset registry's `type` string into a [`DeviceKind`].
52    ///
53    /// The registry field is free-form and case-inconsistent (both `"mouse"`
54    /// and `"MOUSE"` ship), so we case-fold before matching. Values we don't
55    /// model map to [`DeviceKind::Unknown`], which callers treat as "no asset
56    /// opinion" and fall back to the HID++ classification.
57    #[must_use]
58    pub fn from_registry_type(raw: &str) -> Self {
59        match raw.trim().to_ascii_lowercase().as_str() {
60            "mouse" => Self::Mouse,
61            "keyboard" => Self::Keyboard,
62            "numpad" => Self::Numpad,
63            "presenter" => Self::Presenter,
64            "remote" | "remotecontrol" => Self::Remote,
65            "trackball" => Self::Trackball,
66            "touchpad" | "trackpad" => Self::Touchpad,
67            "tablet" => Self::Tablet,
68            "gamepad" => Self::Gamepad,
69            "joystick" => Self::Joystick,
70            "headset" => Self::Headset,
71            _ => Self::Unknown,
72        }
73    }
74}
75
76/// What a device can be *configured* to do, derived from the HID++ feature
77/// table it reports (feature `0x0001`). This is the source of truth for which
78/// configuration panels the UI offers — a panel shows iff the device exposes
79/// the feature that drives it. Gating on capability rather than on
80/// [`DeviceKind`] is what keeps a misclassified device from losing its panels
81/// (issue #127): kind is an identity guess, capability is what the firmware
82/// actually announced.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
84#[allow(
85    clippy::struct_excessive_bools,
86    reason = "capabilities is a serialized feature-bit DTO; independent booleans keep the IPC/config shape explicit"
87)]
88pub struct Capabilities {
89    /// Reprogrammable buttons — HID++ `0x1b00`–`0x1b04` (ReprogControls).
90    pub buttons: bool,
91    /// Adjustable pointer resolution — HID++ `0x2201` / `0x2202` (AdjustableDpi).
92    pub pointer: bool,
93    /// Solid-colour RGB the lighting panel can actually drive — HID++
94    /// `ColorLedEffects` (`0x8070`) or `PerKeyLighting` (`0x8080`), the features
95    /// `set_keyboard_color` writes. Backlight-only families aren't driven by the
96    /// panel, so they don't flip this and don't earn an inert Lighting tab.
97    pub lighting: bool,
98    /// Native vertical wheel inversion — HID++ `0x2121 HiResWheel` with the
99    /// firmware-reported `has_invert` capability.
100    pub scroll_inversion: bool,
101    /// HID++ `0x2121 HiResWheel` is present, so the wheel reporting resolution
102    /// can be read and changed independently of inversion support.
103    #[serde(default)]
104    pub hires_wheel: bool,
105}
106
107impl Capabilities {
108    /// Derive capabilities from the set of HID++ feature IDs a device reports.
109    /// Membership of a driving feature ID flips the corresponding flag.
110    #[must_use]
111    pub fn from_feature_ids(ids: &[u16]) -> Self {
112        const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04];
113        const POINTER: [u16; 2] = [0x2201, 0x2202];
114        // PerKeyLighting (0x8080) and ColorLedEffects (0x8070) — both now driven
115        // by `set_keyboard_color` (it prefers 0x8070's fixed effect to override a
116        // running onboard profile, falling back to 0x8080 per-key). Other families
117        // (backlight 0x198x) stay out so they don't earn a tab the panel can't drive.
118        const LIGHTING: [u16; 2] = [0x8080, 0x8070];
119        let has = |family: &[u16]| ids.iter().any(|id| family.contains(id));
120        Self {
121            buttons: has(&BUTTONS),
122            pointer: has(&POINTER),
123            lighting: has(&LIGHTING),
124            scroll_inversion: false,
125            hires_wheel: ids.contains(&0x2121),
126        }
127    }
128
129    /// Best-effort capabilities for a device we could not probe (offline /
130    /// never reached), guessed from its [`DeviceKind`]. Used only as a fallback
131    /// when no measured [`Capabilities`] exist — a sleeping mouse should still
132    /// show its button/pointer panels so its bindings (host-side) stay
133    /// configurable.
134    #[must_use]
135    pub fn presumed_from_kind(kind: DeviceKind) -> Self {
136        match kind {
137            DeviceKind::Mouse | DeviceKind::Trackball => Self {
138                buttons: true,
139                pointer: true,
140                lighting: false,
141                scroll_inversion: false,
142                hires_wheel: false,
143            },
144            DeviceKind::Keyboard => Self {
145                lighting: true,
146                ..Self::default()
147            },
148            _ => Self::default(),
149        }
150    }
151}
152
153/// Coarse battery bucket reported by the device firmware.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
155#[serde(rename_all = "lowercase")]
156pub enum BatteryLevel {
157    /// Almost depleted — the firmware's most urgent bucket.
158    Critical,
159    /// Running low; worth surfacing a charge hint.
160    Low,
161    /// Comfortable middle range, no user action needed.
162    Good,
163    /// At or near full charge.
164    Full,
165    /// The firmware did not report a level, or reported one we don't model.
166    Unknown,
167}
168
169/// Charging state. Mirrors `hidpp 0.2`'s `BatteryStatus` plus `Unknown` for
170/// values added in future protocol versions.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum BatteryStatus {
174    /// Running on battery.
175    Discharging,
176    /// Charging at the normal rate.
177    Charging,
178    /// Charging at reduced current (e.g. from a weak power source).
179    ChargingSlow,
180    /// Charge complete while still connected to power.
181    Full,
182    /// The device reported a charging fault.
183    Error,
184    /// A status value this build doesn't model (future protocol additions).
185    Unknown,
186}
187
188/// Battery snapshot for one paired device, as last polled over HID++.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct BatteryInfo {
191    /// Reported charge percentage (`0..=100`).
192    pub percentage: u8,
193    /// Coarse bucket for UI that doesn't want the raw percentage.
194    pub level: BatteryLevel,
195    /// Charging state at poll time.
196    pub status: BatteryStatus,
197}
198
199/// Identity of an enumerated receiver — no paired-device state (that lives
200/// in [`DeviceInventory::paired`]). For a direct (Bluetooth/wired) device,
201/// a synthetic entry mirroring the device's own HID identity fills this role.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct ReceiverInfo {
204    /// Product string from the HID enumeration (e.g. `"Logi Bolt Receiver"`).
205    pub name: String,
206    /// USB vendor ID (`0x046d` for Logitech).
207    pub vendor_id: u16,
208    /// USB product ID distinguishing the receiver model.
209    pub product_id: u16,
210    /// Platform-reported serial, when one is exposed. Deliberately excluded
211    /// from diagnostics (see [`crate::diagnostics::ReceiverDiag`]).
212    pub unique_id: Option<String>,
213}
214
215/// HID++ `DeviceInformation` (feature 0x0003) snapshot used to identify a
216/// device against external registries (e.g. the OpenLogi asset index).
217///
218/// `model_ids` is the per-transport PID array reported by the firmware,
219/// ordered to match the transports flagged in [`Self::transports`] (USB,
220/// eQuad, BTLE, Bluetooth) — slots that aren't enabled stay `0`. The Logi
221/// Options+ asset registry's `modelId` (e.g. `"6b023"`) is the concatenation
222/// of an extended-model byte and one of these PIDs, so callers usually want
223/// to format `extended_model_id` + `model_ids[N]` to match.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct DeviceModelInfo {
226    /// Number of firmware entities (main firmware, bootloader, …) the
227    /// device reports.
228    pub entity_count: u8,
229    /// HID++ DeviceInformation serial number, when the device supports the
230    /// optional serial-number function.
231    pub serial_number: Option<String>,
232    /// Per-unit ID bytes — unique to the physical unit, unlike the
233    /// model-level fields around it.
234    pub unit_id: [u8; 4],
235    /// Which transports the firmware supports; defines the slot order of
236    /// [`Self::model_ids`].
237    pub transports: DeviceTransports,
238    /// Per-transport PIDs ordered to match [`Self::transports`] (USB, eQuad,
239    /// BTLE, Bluetooth); slots for disabled transports stay `0`.
240    pub model_ids: [u16; 3],
241    /// Extra model byte prefixed to a PID to form the asset registry's
242    /// `modelId` — see [`Self::config_key`].
243    pub extended_model_id: u8,
244}
245
246impl DeviceModelInfo {
247    /// Stable identifier used to key per-device configuration (button
248    /// bindings, etc.) and to look up assets in the OpenLogi asset registry.
249    ///
250    /// Format: `{extended_model_id:x}{model_ids[0]:04x}` — the same string
251    /// the depot `manifest.json` uses for its `modelId` field. Example: an
252    /// MX Master 4 with `extended_model_id = 0x02` and `model_ids[0] = 0xb042`
253    /// resolves to `"2b042"`.
254    #[must_use]
255    pub fn config_key(&self) -> String {
256        format!("{:x}{:04x}", self.extended_model_id, self.model_ids[0])
257    }
258}
259
260/// Mirror of hidpp's `DeviceTransport` bitfield — one bool per protocol the
261/// device firmware exposes. The shape is dictated by HID++ feature 0x0003;
262/// a state machine doesn't fit since a single device can announce multiple
263/// transports simultaneously.
264#[allow(
265    clippy::struct_excessive_bools,
266    reason = "bitfield mirroring HID++ DeviceInformation; transports are independent flags"
267)]
268#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
269pub struct DeviceTransports {
270    /// Wired USB.
271    pub usb: bool,
272    /// Logitech eQuad — the Unifying/Bolt receiver RF protocol.
273    pub equad: bool,
274    /// Bluetooth Low Energy.
275    pub btle: bool,
276    /// Classic Bluetooth.
277    pub bluetooth: bool,
278}
279
280/// One device in the agent's inventory snapshot: a receiver pairing slot,
281/// or a direct (Bluetooth/wired) attachment under its synthetic
282/// [`ReceiverInfo`]. Embedded in [`DeviceInventory`], so its field order is
283/// IPC wire format — see that type's contract.
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285pub struct PairedDevice {
286    /// Receiver-assigned slot (1..=6 for Bolt).
287    pub slot: u8,
288    /// Firmware codename (e.g. `"MX Master 3S"`), when reported.
289    pub codename: Option<String>,
290    /// Wireless product ID. `None` for offline / unreachable devices on hidpp 0.2.
291    pub wpid: Option<u16>,
292    /// Best-guess classification. Identity only — panel gating uses
293    /// [`Self::capabilities`] instead, so a misread kind can't hide panels
294    /// (issue #127).
295    pub kind: DeviceKind,
296    /// Whether the device was reachable at enumeration time; offline devices
297    /// keep their slot with reduced detail.
298    pub online: bool,
299    /// Last battery reading, `None` when offline or the device doesn't
300    /// report battery.
301    pub battery: Option<BatteryInfo>,
302    /// Output of HID++ feature 0x0003 — populated for online devices that
303    /// expose the feature. Drives asset-registry lookups in the GUI.
304    pub model_info: Option<DeviceModelInfo>,
305    /// Configuration capabilities derived from the device's HID++ feature
306    /// table. `None` for devices we couldn't probe (offline / unreachable);
307    /// the GUI then falls back to [`Capabilities::presumed_from_kind`].
308    pub capabilities: Option<Capabilities>,
309}
310
311/// One receiver and its paired devices — the unit the agent's inventory
312/// snapshot is made of.
313///
314/// Crosses the agent↔GUI IPC (everything it embeds too: [`ReceiverInfo`],
315/// [`PairedDevice`], battery/model-info/capability types). bincode encodes
316/// field and variant *order*, so reordering, retyping, or wrapping any field
317/// in this tree is a wire-format change and requires a `PROTOCOL_VERSION`
318/// bump (guarded by `openlogi-agent-core/tests/wire_format.rs`).
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct DeviceInventory {
321    /// The receiver's identity — synthetic (mirroring the device itself)
322    /// for a direct Bluetooth/wired attachment.
323    pub receiver: ReceiverInfo,
324    /// The devices reached through this receiver; a direct attachment
325    /// carries exactly one entry.
326    pub paired: Vec<PairedDevice>,
327}
328
329#[cfg(test)]
330mod tests {
331    use super::{
332        BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind,
333        DeviceModelInfo, DeviceTransports, PairedDevice, ReceiverInfo,
334    };
335
336    fn inventory(slot: u8, wpid: Option<u16>, battery_percentage: u8) -> DeviceInventory {
337        DeviceInventory {
338            receiver: ReceiverInfo {
339                name: "Logi Bolt Receiver".to_string(),
340                vendor_id: 0x046d,
341                product_id: 0xc548,
342                unique_id: Some("receiver-1".to_string()),
343            },
344            paired: vec![PairedDevice {
345                slot,
346                codename: Some("MX Test".to_string()),
347                wpid,
348                kind: DeviceKind::Mouse,
349                online: true,
350                battery: Some(BatteryInfo {
351                    percentage: battery_percentage,
352                    level: BatteryLevel::Good,
353                    status: BatteryStatus::Discharging,
354                }),
355                model_info: Some(DeviceModelInfo {
356                    entity_count: 1,
357                    serial_number: Some("serial-1".to_string()),
358                    unit_id: [1, 2, 3, 4],
359                    transports: DeviceTransports {
360                        usb: true,
361                        equad: true,
362                        btle: false,
363                        bluetooth: false,
364                    },
365                    model_ids: [0xb023, 0, 0],
366                    extended_model_id: 0x02,
367                }),
368                capabilities: Some(Capabilities {
369                    buttons: true,
370                    pointer: true,
371                    lighting: false,
372                    scroll_inversion: false,
373                    hires_wheel: false,
374                }),
375            }],
376        }
377    }
378
379    #[test]
380    fn device_inventory_equality_includes_nested_device_fields() {
381        let base = inventory(1, Some(0xb023), 86);
382        assert_eq!(base, base.clone());
383
384        assert_ne!(
385            base,
386            inventory(2, Some(0xb023), 86),
387            "slot changes must affect inventory equality"
388        );
389        assert_ne!(
390            base,
391            inventory(1, Some(0xb024), 86),
392            "wireless product id changes must affect inventory equality"
393        );
394        assert_ne!(
395            base,
396            inventory(1, Some(0xb023), 87),
397            "nested battery changes must affect inventory equality"
398        );
399    }
400
401    #[test]
402    fn registry_type_is_case_folded() {
403        // The registry ships both `"mouse"` and `"MOUSE"`; both must resolve so
404        // the asset cross-check can't silently miss a depot.
405        assert_eq!(DeviceKind::from_registry_type("mouse"), DeviceKind::Mouse);
406        assert_eq!(DeviceKind::from_registry_type("MOUSE"), DeviceKind::Mouse);
407        assert_eq!(
408            DeviceKind::from_registry_type("  Keyboard "),
409            DeviceKind::Keyboard
410        );
411    }
412
413    #[test]
414    fn unknown_registry_type_defers_to_the_caller() {
415        // Unmodelled / empty → Unknown, i.e. "no asset opinion".
416        assert_eq!(
417            DeviceKind::from_registry_type("webcam"),
418            DeviceKind::Unknown
419        );
420        assert_eq!(DeviceKind::from_registry_type(""), DeviceKind::Unknown);
421    }
422
423    #[test]
424    fn capabilities_track_the_driving_feature_ids() {
425        use super::Capabilities;
426        // A typical MX mouse: ReprogControls (0x1b04) + ExtendedAdjustableDpi
427        // (0x2202), no lighting.
428        let mouse = Capabilities::from_feature_ids(&[0x0003, 0x1b04, 0x2121, 0x2202, 0x2110]);
429        assert_eq!(
430            mouse,
431            Capabilities {
432                buttons: true,
433                pointer: true,
434                lighting: false,
435                scroll_inversion: false,
436                hires_wheel: true,
437            }
438        );
439        // A wired G-series keyboard: PerKeyLighting (0x8080), no DPI/buttons.
440        let keyboard = Capabilities::from_feature_ids(&[0x0001, 0x8080]);
441        assert_eq!(
442            keyboard,
443            Capabilities {
444                buttons: false,
445                pointer: false,
446                lighting: true,
447                scroll_inversion: false,
448                hires_wheel: false,
449            }
450        );
451        // No driving features → nothing offered.
452        assert_eq!(
453            Capabilities::from_feature_ids(&[0x0000, 0x0003]),
454            Capabilities::default()
455        );
456    }
457
458    #[test]
459    fn persisted_capabilities_without_hires_wheel_load_as_unsupported()
460    -> Result<(), toml::de::Error> {
461        use super::Capabilities;
462
463        let capabilities: Capabilities = toml::from_str(
464            r"
465                buttons = true
466                pointer = true
467                lighting = false
468                scroll_inversion = true
469            ",
470        )?;
471
472        assert!(!capabilities.hires_wheel);
473        assert!(capabilities.scroll_inversion);
474        Ok(())
475    }
476
477    #[test]
478    fn presumed_capabilities_keep_an_unprobed_mouse_configurable() {
479        use super::Capabilities;
480        let mouse = Capabilities::presumed_from_kind(DeviceKind::Mouse);
481        assert!(mouse.buttons && mouse.pointer && !mouse.lighting);
482        assert!(Capabilities::presumed_from_kind(DeviceKind::Keyboard).lighting);
483        // An unidentified device presumes nothing — it must be measured.
484        assert_eq!(
485            Capabilities::presumed_from_kind(DeviceKind::Unknown),
486            Capabilities::default()
487        );
488    }
489}