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}
102
103impl Capabilities {
104 /// Derive capabilities from the set of HID++ feature IDs a device reports.
105 /// Membership of a driving feature ID flips the corresponding flag.
106 #[must_use]
107 pub fn from_feature_ids(ids: &[u16]) -> Self {
108 const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04];
109 const POINTER: [u16; 2] = [0x2201, 0x2202];
110 // PerKeyLighting (0x8080) and ColorLedEffects (0x8070) — both now driven
111 // by `set_keyboard_color` (it prefers 0x8070's fixed effect to override a
112 // running onboard profile, falling back to 0x8080 per-key). Other families
113 // (backlight 0x198x) stay out so they don't earn a tab the panel can't drive.
114 const LIGHTING: [u16; 2] = [0x8080, 0x8070];
115 let has = |family: &[u16]| ids.iter().any(|id| family.contains(id));
116 Self {
117 buttons: has(&BUTTONS),
118 pointer: has(&POINTER),
119 lighting: has(&LIGHTING),
120 scroll_inversion: false,
121 }
122 }
123
124 /// Best-effort capabilities for a device we could not probe (offline /
125 /// never reached), guessed from its [`DeviceKind`]. Used only as a fallback
126 /// when no measured [`Capabilities`] exist — a sleeping mouse should still
127 /// show its button/pointer panels so its bindings (host-side) stay
128 /// configurable.
129 #[must_use]
130 pub fn presumed_from_kind(kind: DeviceKind) -> Self {
131 match kind {
132 DeviceKind::Mouse | DeviceKind::Trackball => Self {
133 buttons: true,
134 pointer: true,
135 lighting: false,
136 scroll_inversion: false,
137 },
138 DeviceKind::Keyboard => Self {
139 lighting: true,
140 ..Self::default()
141 },
142 _ => Self::default(),
143 }
144 }
145}
146
147/// Coarse battery bucket reported by the device firmware.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum BatteryLevel {
151 /// Almost depleted — the firmware's most urgent bucket.
152 Critical,
153 /// Running low; worth surfacing a charge hint.
154 Low,
155 /// Comfortable middle range, no user action needed.
156 Good,
157 /// At or near full charge.
158 Full,
159 /// The firmware did not report a level, or reported one we don't model.
160 Unknown,
161}
162
163/// Charging state. Mirrors `hidpp 0.2`'s `BatteryStatus` plus `Unknown` for
164/// values added in future protocol versions.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum BatteryStatus {
168 /// Running on battery.
169 Discharging,
170 /// Charging at the normal rate.
171 Charging,
172 /// Charging at reduced current (e.g. from a weak power source).
173 ChargingSlow,
174 /// Charge complete while still connected to power.
175 Full,
176 /// The device reported a charging fault.
177 Error,
178 /// A status value this build doesn't model (future protocol additions).
179 Unknown,
180}
181
182/// Battery snapshot for one paired device, as last polled over HID++.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct BatteryInfo {
185 /// Reported charge percentage (`0..=100`).
186 pub percentage: u8,
187 /// Coarse bucket for UI that doesn't want the raw percentage.
188 pub level: BatteryLevel,
189 /// Charging state at poll time.
190 pub status: BatteryStatus,
191}
192
193/// Identity of an enumerated receiver — no paired-device state (that lives
194/// in [`DeviceInventory::paired`]). For a direct (Bluetooth/wired) device,
195/// a synthetic entry mirroring the device's own HID identity fills this role.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ReceiverInfo {
198 /// Product string from the HID enumeration (e.g. `"Logi Bolt Receiver"`).
199 pub name: String,
200 /// USB vendor ID (`0x046d` for Logitech).
201 pub vendor_id: u16,
202 /// USB product ID distinguishing the receiver model.
203 pub product_id: u16,
204 /// Platform-reported serial, when one is exposed. Deliberately excluded
205 /// from diagnostics (see [`crate::diagnostics::ReceiverDiag`]).
206 pub unique_id: Option<String>,
207}
208
209/// HID++ `DeviceInformation` (feature 0x0003) snapshot used to identify a
210/// device against external registries (e.g. the OpenLogi asset index).
211///
212/// `model_ids` is the per-transport PID array reported by the firmware,
213/// ordered to match the transports flagged in [`Self::transports`] (USB,
214/// eQuad, BTLE, Bluetooth) — slots that aren't enabled stay `0`. The Logi
215/// Options+ asset registry's `modelId` (e.g. `"6b023"`) is the concatenation
216/// of an extended-model byte and one of these PIDs, so callers usually want
217/// to format `extended_model_id` + `model_ids[N]` to match.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct DeviceModelInfo {
220 /// Number of firmware entities (main firmware, bootloader, …) the
221 /// device reports.
222 pub entity_count: u8,
223 /// HID++ DeviceInformation serial number, when the device supports the
224 /// optional serial-number function.
225 pub serial_number: Option<String>,
226 /// Per-unit ID bytes — unique to the physical unit, unlike the
227 /// model-level fields around it.
228 pub unit_id: [u8; 4],
229 /// Which transports the firmware supports; defines the slot order of
230 /// [`Self::model_ids`].
231 pub transports: DeviceTransports,
232 /// Per-transport PIDs ordered to match [`Self::transports`] (USB, eQuad,
233 /// BTLE, Bluetooth); slots for disabled transports stay `0`.
234 pub model_ids: [u16; 3],
235 /// Extra model byte prefixed to a PID to form the asset registry's
236 /// `modelId` — see [`Self::config_key`].
237 pub extended_model_id: u8,
238}
239
240impl DeviceModelInfo {
241 /// Stable identifier used to key per-device configuration (button
242 /// bindings, etc.) and to look up assets in the OpenLogi asset registry.
243 ///
244 /// Format: `{extended_model_id:x}{model_ids[0]:04x}` — the same string
245 /// the depot `manifest.json` uses for its `modelId` field. Example: an
246 /// MX Master 4 with `extended_model_id = 0x02` and `model_ids[0] = 0xb042`
247 /// resolves to `"2b042"`.
248 #[must_use]
249 pub fn config_key(&self) -> String {
250 format!("{:x}{:04x}", self.extended_model_id, self.model_ids[0])
251 }
252}
253
254/// Mirror of hidpp's `DeviceTransport` bitfield — one bool per protocol the
255/// device firmware exposes. The shape is dictated by HID++ feature 0x0003;
256/// a state machine doesn't fit since a single device can announce multiple
257/// transports simultaneously.
258#[allow(
259 clippy::struct_excessive_bools,
260 reason = "bitfield mirroring HID++ DeviceInformation; transports are independent flags"
261)]
262#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
263pub struct DeviceTransports {
264 /// Wired USB.
265 pub usb: bool,
266 /// Logitech eQuad — the Unifying/Bolt receiver RF protocol.
267 pub equad: bool,
268 /// Bluetooth Low Energy.
269 pub btle: bool,
270 /// Classic Bluetooth.
271 pub bluetooth: bool,
272}
273
274/// One device in the agent's inventory snapshot: a receiver pairing slot,
275/// or a direct (Bluetooth/wired) attachment under its synthetic
276/// [`ReceiverInfo`]. Embedded in [`DeviceInventory`], so its field order is
277/// IPC wire format — see that type's contract.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct PairedDevice {
280 /// Receiver-assigned slot (1..=6 for Bolt).
281 pub slot: u8,
282 /// Firmware codename (e.g. `"MX Master 3S"`), when reported.
283 pub codename: Option<String>,
284 /// Wireless product ID. `None` for offline / unreachable devices on hidpp 0.2.
285 pub wpid: Option<u16>,
286 /// Best-guess classification. Identity only — panel gating uses
287 /// [`Self::capabilities`] instead, so a misread kind can't hide panels
288 /// (issue #127).
289 pub kind: DeviceKind,
290 /// Whether the device was reachable at enumeration time; offline devices
291 /// keep their slot with reduced detail.
292 pub online: bool,
293 /// Last battery reading, `None` when offline or the device doesn't
294 /// report battery.
295 pub battery: Option<BatteryInfo>,
296 /// Output of HID++ feature 0x0003 — populated for online devices that
297 /// expose the feature. Drives asset-registry lookups in the GUI.
298 pub model_info: Option<DeviceModelInfo>,
299 /// Configuration capabilities derived from the device's HID++ feature
300 /// table. `None` for devices we couldn't probe (offline / unreachable);
301 /// the GUI then falls back to [`Capabilities::presumed_from_kind`].
302 pub capabilities: Option<Capabilities>,
303}
304
305/// One receiver and its paired devices — the unit the agent's inventory
306/// snapshot is made of.
307///
308/// Crosses the agent↔GUI IPC (everything it embeds too: [`ReceiverInfo`],
309/// [`PairedDevice`], battery/model-info/capability types). bincode encodes
310/// field and variant *order*, so reordering, retyping, or wrapping any field
311/// in this tree is a wire-format change and requires a `PROTOCOL_VERSION`
312/// bump (guarded by `openlogi-agent-core/tests/wire_format.rs`).
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct DeviceInventory {
315 /// The receiver's identity — synthetic (mirroring the device itself)
316 /// for a direct Bluetooth/wired attachment.
317 pub receiver: ReceiverInfo,
318 /// The devices reached through this receiver; a direct attachment
319 /// carries exactly one entry.
320 pub paired: Vec<PairedDevice>,
321}
322
323#[cfg(test)]
324mod tests {
325 use super::DeviceKind;
326
327 #[test]
328 fn registry_type_is_case_folded() {
329 // The registry ships both `"mouse"` and `"MOUSE"`; both must resolve so
330 // the asset cross-check can't silently miss a depot.
331 assert_eq!(DeviceKind::from_registry_type("mouse"), DeviceKind::Mouse);
332 assert_eq!(DeviceKind::from_registry_type("MOUSE"), DeviceKind::Mouse);
333 assert_eq!(
334 DeviceKind::from_registry_type(" Keyboard "),
335 DeviceKind::Keyboard
336 );
337 }
338
339 #[test]
340 fn unknown_registry_type_defers_to_the_caller() {
341 // Unmodelled / empty → Unknown, i.e. "no asset opinion".
342 assert_eq!(
343 DeviceKind::from_registry_type("webcam"),
344 DeviceKind::Unknown
345 );
346 assert_eq!(DeviceKind::from_registry_type(""), DeviceKind::Unknown);
347 }
348
349 #[test]
350 fn capabilities_track_the_driving_feature_ids() {
351 use super::Capabilities;
352 // A typical MX mouse: ReprogControls (0x1b04) + ExtendedAdjustableDpi
353 // (0x2202), no lighting.
354 let mouse = Capabilities::from_feature_ids(&[0x0003, 0x1b04, 0x2202, 0x2110]);
355 assert_eq!(
356 mouse,
357 Capabilities {
358 buttons: true,
359 pointer: true,
360 lighting: false,
361 scroll_inversion: false,
362 }
363 );
364 // A wired G-series keyboard: PerKeyLighting (0x8080), no DPI/buttons.
365 let keyboard = Capabilities::from_feature_ids(&[0x0001, 0x8080]);
366 assert_eq!(
367 keyboard,
368 Capabilities {
369 buttons: false,
370 pointer: false,
371 lighting: true,
372 scroll_inversion: false,
373 }
374 );
375 // No driving features → nothing offered.
376 assert_eq!(
377 Capabilities::from_feature_ids(&[0x0000, 0x0003]),
378 Capabilities::default()
379 );
380 }
381
382 #[test]
383 fn presumed_capabilities_keep_an_unprobed_mouse_configurable() {
384 use super::Capabilities;
385 let mouse = Capabilities::presumed_from_kind(DeviceKind::Mouse);
386 assert!(mouse.buttons && mouse.pointer && !mouse.lighting);
387 assert!(Capabilities::presumed_from_kind(DeviceKind::Keyboard).lighting);
388 // An unidentified device presumes nothing — it must be measured.
389 assert_eq!(
390 Capabilities::presumed_from_kind(DeviceKind::Unknown),
391 Capabilities::default()
392 );
393 }
394}