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