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