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    Mouse,
24    Keyboard,
25    Numpad,
26    Presenter,
27    Remote,
28    Trackball,
29    Touchpad,
30    Tablet,
31    Gamepad,
32    Joystick,
33    Headset,
34    Unknown,
35}
36
37impl DeviceKind {
38    /// Parse the OpenLogi asset registry's `type` string into a [`DeviceKind`].
39    ///
40    /// The registry field is free-form and case-inconsistent (both `"mouse"`
41    /// and `"MOUSE"` ship), so we case-fold before matching. Values we don't
42    /// model map to [`DeviceKind::Unknown`], which callers treat as "no asset
43    /// opinion" and fall back to the HID++ classification.
44    #[must_use]
45    pub fn from_registry_type(raw: &str) -> Self {
46        match raw.trim().to_ascii_lowercase().as_str() {
47            "mouse" => Self::Mouse,
48            "keyboard" => Self::Keyboard,
49            "numpad" => Self::Numpad,
50            "presenter" => Self::Presenter,
51            "remote" | "remotecontrol" => Self::Remote,
52            "trackball" => Self::Trackball,
53            "touchpad" | "trackpad" => Self::Touchpad,
54            "tablet" => Self::Tablet,
55            "gamepad" => Self::Gamepad,
56            "joystick" => Self::Joystick,
57            "headset" => Self::Headset,
58            _ => Self::Unknown,
59        }
60    }
61}
62
63/// What a device can be *configured* to do, derived from the HID++ feature
64/// table it reports (feature `0x0001`). This is the source of truth for which
65/// configuration panels the UI offers — a panel shows iff the device exposes
66/// the feature that drives it. Gating on capability rather than on
67/// [`DeviceKind`] is what keeps a misclassified device from losing its panels
68/// (issue #127): kind is an identity guess, capability is what the firmware
69/// actually announced.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71#[allow(
72    clippy::struct_excessive_bools,
73    reason = "capabilities is a serialized feature-bit DTO; independent booleans keep the IPC/config shape explicit"
74)]
75pub struct Capabilities {
76    /// Reprogrammable buttons — HID++ `0x1b00`–`0x1b04` (ReprogControls).
77    pub buttons: bool,
78    /// Adjustable pointer resolution — HID++ `0x2201` / `0x2202` (AdjustableDpi).
79    pub pointer: bool,
80    /// Solid-colour RGB the lighting panel can actually drive — HID++
81    /// `ColorLedEffects` (`0x8070`) or `PerKeyLighting` (`0x8080`), the features
82    /// `set_keyboard_color` writes. Backlight-only families aren't driven by the
83    /// panel, so they don't flip this and don't earn an inert Lighting tab.
84    pub lighting: bool,
85    /// Native vertical wheel inversion — HID++ `0x2121 HiResWheel` with the
86    /// firmware-reported `has_invert` capability.
87    pub scroll_inversion: bool,
88}
89
90impl Capabilities {
91    /// Derive capabilities from the set of HID++ feature IDs a device reports.
92    /// Membership of a driving feature ID flips the corresponding flag.
93    #[must_use]
94    pub fn from_feature_ids(ids: &[u16]) -> Self {
95        const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04];
96        const POINTER: [u16; 2] = [0x2201, 0x2202];
97        // PerKeyLighting (0x8080) and ColorLedEffects (0x8070) — both now driven
98        // by `set_keyboard_color` (it prefers 0x8070's fixed effect to override a
99        // running onboard profile, falling back to 0x8080 per-key). Other families
100        // (backlight 0x198x) stay out so they don't earn a tab the panel can't drive.
101        const LIGHTING: [u16; 2] = [0x8080, 0x8070];
102        let has = |family: &[u16]| ids.iter().any(|id| family.contains(id));
103        Self {
104            buttons: has(&BUTTONS),
105            pointer: has(&POINTER),
106            lighting: has(&LIGHTING),
107            scroll_inversion: false,
108        }
109    }
110
111    /// Best-effort capabilities for a device we could not probe (offline /
112    /// never reached), guessed from its [`DeviceKind`]. Used only as a fallback
113    /// when no measured [`Capabilities`] exist — a sleeping mouse should still
114    /// show its button/pointer panels so its bindings (host-side) stay
115    /// configurable.
116    #[must_use]
117    pub fn presumed_from_kind(kind: DeviceKind) -> Self {
118        match kind {
119            DeviceKind::Mouse | DeviceKind::Trackball => Self {
120                buttons: true,
121                pointer: true,
122                lighting: false,
123                scroll_inversion: false,
124            },
125            DeviceKind::Keyboard => Self {
126                lighting: true,
127                ..Self::default()
128            },
129            _ => Self::default(),
130        }
131    }
132}
133
134/// Coarse battery bucket reported by the device firmware.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum BatteryLevel {
138    Critical,
139    Low,
140    Good,
141    Full,
142    Unknown,
143}
144
145/// Charging state. Mirrors `hidpp 0.2`'s `BatteryStatus` plus `Unknown` for
146/// values added in future protocol versions.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum BatteryStatus {
150    Discharging,
151    Charging,
152    ChargingSlow,
153    Full,
154    Error,
155    Unknown,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct BatteryInfo {
160    pub percentage: u8,
161    pub level: BatteryLevel,
162    pub status: BatteryStatus,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ReceiverInfo {
167    pub name: String,
168    pub vendor_id: u16,
169    pub product_id: u16,
170    pub unique_id: Option<String>,
171}
172
173/// HID++ `DeviceInformation` (feature 0x0003) snapshot used to identify a
174/// device against external registries (e.g. the OpenLogi asset index).
175///
176/// `model_ids` is the per-transport PID array reported by the firmware,
177/// ordered to match the transports flagged in [`Self::transports`] (USB,
178/// eQuad, BTLE, Bluetooth) — slots that aren't enabled stay `0`. The Logi
179/// Options+ asset registry's `modelId` (e.g. `"6b023"`) is the concatenation
180/// of an extended-model byte and one of these PIDs, so callers usually want
181/// to format `extended_model_id` + `model_ids[N]` to match.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct DeviceModelInfo {
184    pub entity_count: u8,
185    /// HID++ DeviceInformation serial number, when the device supports the
186    /// optional serial-number function.
187    pub serial_number: Option<String>,
188    pub unit_id: [u8; 4],
189    pub transports: DeviceTransports,
190    pub model_ids: [u16; 3],
191    pub extended_model_id: u8,
192}
193
194impl DeviceModelInfo {
195    /// Stable identifier used to key per-device configuration (button
196    /// bindings, etc.) and to look up assets in the OpenLogi asset registry.
197    ///
198    /// Format: `{extended_model_id:x}{model_ids[0]:04x}` — the same string
199    /// the depot `manifest.json` uses for its `modelId` field. Example: an
200    /// MX Master 4 with `extended_model_id = 0x02` and `model_ids[0] = 0xb042`
201    /// resolves to `"2b042"`.
202    #[must_use]
203    pub fn config_key(&self) -> String {
204        format!("{:x}{:04x}", self.extended_model_id, self.model_ids[0])
205    }
206}
207
208/// Mirror of hidpp's `DeviceTransport` bitfield — one bool per protocol the
209/// device firmware exposes. The shape is dictated by HID++ feature 0x0003;
210/// a state machine doesn't fit since a single device can announce multiple
211/// transports simultaneously.
212#[allow(
213    clippy::struct_excessive_bools,
214    reason = "bitfield mirroring HID++ DeviceInformation; transports are independent flags"
215)]
216#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
217pub struct DeviceTransports {
218    pub usb: bool,
219    pub equad: bool,
220    pub btle: bool,
221    pub bluetooth: bool,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct PairedDevice {
226    /// Receiver-assigned slot (1..=6 for Bolt).
227    pub slot: u8,
228    pub codename: Option<String>,
229    /// Wireless product ID. `None` for offline / unreachable devices on hidpp 0.2.
230    pub wpid: Option<u16>,
231    pub kind: DeviceKind,
232    pub online: bool,
233    pub battery: Option<BatteryInfo>,
234    /// Output of HID++ feature 0x0003 — populated for online devices that
235    /// expose the feature. Drives asset-registry lookups in the GUI.
236    pub model_info: Option<DeviceModelInfo>,
237    /// Configuration capabilities derived from the device's HID++ feature
238    /// table. `None` for devices we couldn't probe (offline / unreachable);
239    /// the GUI then falls back to [`Capabilities::presumed_from_kind`].
240    pub capabilities: Option<Capabilities>,
241}
242
243/// One receiver and its paired devices — the unit the agent's inventory
244/// snapshot is made of.
245///
246/// Crosses the agent↔GUI IPC (everything it embeds too: [`ReceiverInfo`],
247/// [`PairedDevice`], battery/model-info/capability types). bincode encodes
248/// field and variant *order*, so reordering, retyping, or wrapping any field
249/// in this tree is a wire-format change and requires a `PROTOCOL_VERSION`
250/// bump (guarded by `openlogi-agent-core/tests/wire_format.rs`).
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct DeviceInventory {
253    pub receiver: ReceiverInfo,
254    pub paired: Vec<PairedDevice>,
255}
256
257#[cfg(test)]
258mod tests {
259    use super::DeviceKind;
260
261    #[test]
262    fn registry_type_is_case_folded() {
263        // The registry ships both `"mouse"` and `"MOUSE"`; both must resolve so
264        // the asset cross-check can't silently miss a depot.
265        assert_eq!(DeviceKind::from_registry_type("mouse"), DeviceKind::Mouse);
266        assert_eq!(DeviceKind::from_registry_type("MOUSE"), DeviceKind::Mouse);
267        assert_eq!(
268            DeviceKind::from_registry_type("  Keyboard "),
269            DeviceKind::Keyboard
270        );
271    }
272
273    #[test]
274    fn unknown_registry_type_defers_to_the_caller() {
275        // Unmodelled / empty → Unknown, i.e. "no asset opinion".
276        assert_eq!(
277            DeviceKind::from_registry_type("webcam"),
278            DeviceKind::Unknown
279        );
280        assert_eq!(DeviceKind::from_registry_type(""), DeviceKind::Unknown);
281    }
282
283    #[test]
284    fn capabilities_track_the_driving_feature_ids() {
285        use super::Capabilities;
286        // A typical MX mouse: ReprogControls (0x1b04) + ExtendedAdjustableDpi
287        // (0x2202), no lighting.
288        let mouse = Capabilities::from_feature_ids(&[0x0003, 0x1b04, 0x2202, 0x2110]);
289        assert_eq!(
290            mouse,
291            Capabilities {
292                buttons: true,
293                pointer: true,
294                lighting: false,
295                scroll_inversion: false,
296            }
297        );
298        // A wired G-series keyboard: PerKeyLighting (0x8080), no DPI/buttons.
299        let keyboard = Capabilities::from_feature_ids(&[0x0001, 0x8080]);
300        assert_eq!(
301            keyboard,
302            Capabilities {
303                buttons: false,
304                pointer: false,
305                lighting: true,
306                scroll_inversion: false,
307            }
308        );
309        // No driving features → nothing offered.
310        assert_eq!(
311            Capabilities::from_feature_ids(&[0x0000, 0x0003]),
312            Capabilities::default()
313        );
314    }
315
316    #[test]
317    fn presumed_capabilities_keep_an_unprobed_mouse_configurable() {
318        use super::Capabilities;
319        let mouse = Capabilities::presumed_from_kind(DeviceKind::Mouse);
320        assert!(mouse.buttons && mouse.pointer && !mouse.lighting);
321        assert!(Capabilities::presumed_from_kind(DeviceKind::Keyboard).lighting);
322        // An unidentified device presumes nothing — it must be measured.
323        assert_eq!(
324            Capabilities::presumed_from_kind(DeviceKind::Unknown),
325            Capabilities::default()
326        );
327    }
328}