openlogi_device/write/diagnostics.rs
1use std::sync::Arc;
2
3use hidpp::{
4 channel::HidppChannel,
5 device::Device,
6 feature::CreatableFeature,
7 feature::FeatureType,
8 feature::battery_status::BatteryStatusFeature,
9 feature::device_information::{
10 DeviceEntityFirmwareInfo, DeviceEntityType, DeviceInformationFeature,
11 },
12 feature::feature_set::FeatureSetFeature,
13 feature::unified_battery::UnifiedBatteryFeature,
14 protocol::v20::Hidpp20Error,
15};
16
17use crate::backend::HidBackend;
18use crate::channel::route::DeviceRoute;
19use crate::reprog_controls::{self, CidFlags, CidInfo, ReprogControlsV4};
20use crate::write::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
21
22/// Snapshot of one HID++ feature exposed by a device: protocol ID +
23/// version. Returned by [`dump_features`] for diagnostics.
24#[derive(Debug, Clone, Copy)]
25pub struct FeatureEntry {
26 /// HID++ feature ID.
27 pub id: u16,
28 /// Feature version reported by the device.
29 pub version: u8,
30 /// Obsolete / hidden / engineering flags the device advertises alongside
31 /// the feature.
32 pub typ: FeatureType,
33}
34
35/// Snapshot of one HID++ `0x1b04` reprogrammable control. Returned by
36/// [`dump_reprog_controls`] for diagnostics so new device controls can be
37/// identified before OpenLogi maps them to a first-class button.
38#[derive(Debug, Clone, Copy)]
39pub struct ReprogControlEntry {
40 /// HID++ control ID.
41 pub cid: u16,
42 /// Default task ID assigned to the control.
43 pub task_id: u16,
44 /// Capability and classification flags for the control.
45 pub flags: CidFlags,
46}
47
48impl From<CidInfo> for ReprogControlEntry {
49 fn from(info: CidInfo) -> Self {
50 Self {
51 cid: info.cid.into(),
52 task_id: info.task_id.0,
53 flags: info.flags,
54 }
55 }
56}
57
58/// Enumerate every HID++ feature the device on `route` reports — used by
59/// `openlogi diag features` to confirm which DPI / SmartShift / etc.
60/// feature IDs a given peripheral actually exposes (e.g. whether a mouse
61/// speaks `0x2201 AdjustableDpi`, `0x2202 ExtendedAdjustableDpi`, or both —
62/// `write::dpi` drives either).
63pub async fn dump_features(
64 backend: &dyn HidBackend,
65 route: &DeviceRoute,
66) -> Result<Vec<FeatureEntry>, WriteError> {
67 let index = route.device_index();
68 with_route(backend, route, move |channel| async move {
69 let mut device = Device::new(Arc::clone(&channel), index)
70 .await
71 .map_err(|_| WriteError::DeviceUnreachable { index })?;
72 // The root feature exposes the FeatureSet (0x0001) at a fixed
73 // address; we look it up directly rather than going through
74 // `enumerate_features` so the iteration is observable.
75 let feature_set_info = device
76 .root()
77 .get_feature(FeatureSetFeature::ID)
78 .await
79 .map_err(|e| {
80 classify_hidpp_error(e, HidppOperation::DumpFeatures, FeatureSetFeature::ID)
81 })?
82 .ok_or(WriteError::FeatureUnsupported {
83 feature_hex: FeatureSetFeature::ID,
84 })?;
85 let feature_set = device.add_feature::<FeatureSetFeature>(feature_set_info.index);
86 let count = feature_set.count().await.map_err(|e| {
87 classify_hidpp_error(e, HidppOperation::DumpFeatures, FeatureSetFeature::ID)
88 })?;
89 let mut entries = Vec::with_capacity(usize::from(count));
90 for i in 0..=count {
91 let info = feature_set.get_feature(i).await.map_err(|e| {
92 classify_hidpp_error(e, HidppOperation::DumpFeatures, FeatureSetFeature::ID)
93 })?;
94 entries.push(FeatureEntry {
95 id: info.id,
96 version: info.version,
97 typ: info.typ,
98 });
99 }
100 Ok(entries)
101 })
102 .await
103}
104
105/// Enumerate the device's HID++ `0x1b04` reprogrammable controls. This is a
106/// diagnostics-only probe used to discover controls for newly released devices.
107/// For example, MX Master 4 has both a Gesture Button and a separate Haptic
108/// Sense Panel in the thumb area; this probe lets us identify the panel's CID
109/// and capabilities before wiring it into the capture/remapping model.
110pub async fn dump_reprog_controls(
111 backend: &dyn HidBackend,
112 route: &DeviceRoute,
113) -> Result<Vec<ReprogControlEntry>, WriteError> {
114 let index = route.device_index();
115 with_route(backend, route, move |channel| async move {
116 let device = Device::new(Arc::clone(&channel), index)
117 .await
118 .map_err(|_| WriteError::DeviceUnreachable { index })?;
119 let info = device
120 .root()
121 .get_feature(reprog_controls::FEATURE_ID)
122 .await
123 .map_err(|e| {
124 classify_hidpp_error(e, HidppOperation::DumpFeatures, reprog_controls::FEATURE_ID)
125 })?
126 .ok_or(WriteError::FeatureUnsupported {
127 feature_hex: reprog_controls::FEATURE_ID,
128 })?;
129 let rc = ReprogControlsV4::new(Arc::clone(&channel), index, info.index);
130 let count = rc.get_count().await.map_err(|e| {
131 classify_hidpp_error(e, HidppOperation::DumpFeatures, reprog_controls::FEATURE_ID)
132 })?;
133 let mut entries = Vec::with_capacity(usize::from(count));
134 for i in 0..count {
135 let control = rc.get_cid_info(i).await.map_err(|e| {
136 classify_hidpp_error(e, HidppOperation::DumpFeatures, reprog_controls::FEATURE_ID)
137 })?;
138 entries.push(control.into());
139 }
140 Ok(entries)
141 })
142 .await
143}
144
145/// Diagnostic read of the device's raw battery report — the unified `0x1004`
146/// fields, or the legacy `0x1000` `discharge_level`/`next_level`/`status`. For
147/// `openlogi diag battery`: surfaces exactly what the firmware reports so a
148/// claim like "MX2S shows 0% while charging" can be confirmed against the wire
149/// instead of guessed (the GUI only ever shows the mapped value).
150pub async fn read_battery_raw(
151 backend: &dyn HidBackend,
152 route: &DeviceRoute,
153) -> Result<String, WriteError> {
154 let index = route.device_index();
155 with_route(backend, route, move |channel| async move {
156 let mut device = Device::new(Arc::clone(&channel), index)
157 .await
158 .map_err(|_| WriteError::DeviceUnreachable { index })?;
159
160 match open_feature::<UnifiedBatteryFeature>(&mut device).await {
161 Ok(feature) => {
162 let info = feature
163 .get_battery_info()
164 .await
165 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
166 return Ok(format!(
167 "0x1004 UnifiedBattery: percentage={} level={:?} status={:?}",
168 info.charging_percentage, info.level, info.status
169 ));
170 }
171 Err(WriteError::FeatureUnsupported { .. }) => {}
172 Err(e) => return Err(e),
173 }
174
175 match open_feature::<BatteryStatusFeature>(&mut device).await {
176 Ok(feature) => {
177 let info = feature
178 .get_battery_level_status()
179 .await
180 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
181 return Ok(format!(
182 "0x1000 BatteryStatus: discharge_level={} next_level={} status={:?}",
183 info.discharge_level, info.next_level, info.status
184 ));
185 }
186 Err(WriteError::FeatureUnsupported { .. }) => {}
187 Err(e) => return Err(e),
188 }
189
190 // Reached only when neither 0x1004 nor 0x1000 is present; report the
191 // preferred feature rather than implying 0x1000 was specifically absent.
192 Err(WriteError::FeatureUnsupported {
193 feature_hex: 0x1004,
194 })
195 })
196 .await
197}
198
199/// Firmware fields for one entity whose record the device answered and this
200/// parser decoded.
201///
202/// Owned, constructible data converted from `hidpp`'s
203/// `DeviceEntityFirmwareInfo`, the same way [`ReprogControlEntry`] is
204/// converted from `CidInfo`: consumers get the structured record and decide
205/// how to render it, rather than being handed a pre-formatted string with the
206/// rest of the fields dropped.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct FirmwareEntityInfo {
209 /// What the entity is: main application, bootloader, radio stack, and so
210 /// on.
211 pub kind: DeviceEntityType,
212 /// Three-letter prefix of the firmware name, e.g. `MPM`.
213 pub prefix: String,
214 /// Firmware number, BCD-decoded by the protocol layer.
215 pub number: u8,
216 /// Firmware revision, BCD-decoded by the protocol layer.
217 pub revision: u8,
218 /// Firmware build, BCD-decoded by the protocol layer.
219 pub build: u16,
220 /// Whether this is the entity currently running.
221 pub active: bool,
222 /// USB or wireless product ID the entity runs under. A bootloader entity
223 /// reports the PID the device enumerates as while in DFU mode; only the
224 /// active entity is required to report a real value, so an inactive one
225 /// may be zero.
226 pub transport_pid: u16,
227 /// Optional extra versioning bytes. Device-specific and usually all zero,
228 /// carried verbatim because a device that does populate them is exactly
229 /// the device a report is being collected for.
230 pub extra_version: [u8; 5],
231}
232
233impl From<DeviceEntityFirmwareInfo> for FirmwareEntityInfo {
234 fn from(info: DeviceEntityFirmwareInfo) -> Self {
235 Self {
236 kind: info.entity_type,
237 prefix: info.firmware_prefix,
238 number: info.firmware_number,
239 revision: info.revision,
240 build: info.build,
241 active: info.active,
242 transport_pid: info.transport_pid,
243 extra_version: info.extra_version,
244 }
245 }
246}
247
248/// One firmware entity a device reports through HID++ `0x0003` function 1.
249/// Returned by [`dump_firmware_entities`] so a device report can name the
250/// exact firmware it is running.
251///
252/// There are two states and only two: the device answered with a record that
253/// decoded, or it did not. An enum makes "a version with no kind" and "an
254/// error alongside a version" unrepresentable rather than merely unreachable.
255#[derive(Debug, Clone)]
256pub enum FirmwareEntity {
257 /// The entity's record was read and decoded.
258 Readable {
259 /// Index of the entity in the device's own table.
260 index: u8,
261 /// The decoded firmware record.
262 info: FirmwareEntityInfo,
263 },
264 /// The device declared the entity, but its record could not be read.
265 ///
266 /// Reported rather than dropped: omitting the row would claim the device
267 /// has fewer firmware images than it says it has, and a device that cannot
268 /// describe one of its own images is what a bug report needs to say.
269 Unreadable {
270 /// Index of the entity in the device's own table.
271 index: u8,
272 /// Why the record could not be read.
273 error: WriteError,
274 },
275}
276
277/// Read every firmware entity the device on `route` reports.
278///
279/// A device lists its main application firmware alongside its bootloader and,
280/// on many models, a separate radio stack. `openlogi diag features` prints
281/// them so a bug report names the firmware that produced the behaviour rather
282/// than just the model.
283///
284/// A single entity the *device* declined or answered unparseably does not fail
285/// the call — see [`FirmwareEntity::Unreadable`]. A channel failure does: the
286/// route is gone, not the entity.
287pub async fn dump_firmware_entities(
288 backend: &dyn HidBackend,
289 route: &DeviceRoute,
290) -> Result<Vec<FirmwareEntity>, WriteError> {
291 let index = route.device_index();
292 with_route(backend, route, move |channel| async move {
293 dump_firmware_entities_on_channel(&channel, index).await
294 })
295 .await
296}
297
298/// [`dump_firmware_entities`] against an already-open channel, the shape the
299/// tests drive a scripted device through.
300pub(crate) async fn dump_firmware_entities_on_channel(
301 channel: &Arc<HidppChannel>,
302 index: u8,
303) -> Result<Vec<FirmwareEntity>, WriteError> {
304 let mut device = Device::new(Arc::clone(channel), index)
305 .await
306 .map_err(|_| WriteError::DeviceUnreachable { index })?;
307 let feature = open_feature::<DeviceInformationFeature>(&mut device).await?;
308 let info = feature.get_device_info().await.map_err(|e| {
309 classify_hidpp_error(
310 e,
311 HidppOperation::DumpFeatures,
312 DeviceInformationFeature::ID,
313 )
314 })?;
315
316 let mut entries = Vec::with_capacity(usize::from(info.entity_count));
317 for entity in 0..info.entity_count {
318 match feature.get_fw_info(entity).await {
319 Ok(fw) => entries.push(FirmwareEntity::Readable {
320 index: entity,
321 info: fw.into(),
322 }),
323 // The device answered about *this* entity and the answer was no:
324 // it refused the read, or it sent a record this parser cannot
325 // decode (a G502's radio stack reports a build field that is not
326 // valid BCD). The rest of the table is still worth reading.
327 Err(e @ (Hidpp20Error::Feature(_) | Hidpp20Error::UnsupportedResponse)) => {
328 entries.push(FirmwareEntity::Unreadable {
329 index: entity,
330 error: classify_hidpp_error(
331 e,
332 HidppOperation::DumpFeatures,
333 DeviceInformationFeature::ID,
334 ),
335 });
336 }
337 // A channel failure says nothing about the entity — the route
338 // disappeared. Carrying on would spend a timeout per remaining
339 // entity and then print malformed-firmware rows for a disconnect.
340 Err(e) => {
341 return Err(classify_hidpp_error(
342 e,
343 HidppOperation::DumpFeatures,
344 DeviceInformationFeature::ID,
345 ));
346 }
347 }
348 }
349 Ok(entries)
350}